[object Object]

← back to CelebritySignatures

wear: fix the black-garment print contrast bug for real (TK-10286)

9f1ba52477e05215d0d4c42e7ac0776283ceb1fb · 2026-09-08 17:05:42 -0700 · Steve Abrams

Resumes the standing KNOWN ISSUE noted in wear-templates.json since the
2026-09-08 verification session: Printify prints a design's own pixel colors
as-is (no auto-recolor for dark garments), so the black polo's dark-ink
signature printed as low-contrast dark-gray-on-black on the REAL product —
confirmed visually earlier this session, never fixed until now.

Added recolorSignatureToLight() in server.js (and the same logic duplicated
in scripts/reconcile-wear-orders.mjs, which has its own independent draft-
creation path) — shells out to ImageMagick (already on this box) via stdin/
stdout piping, no temp files: `magick -background none - -channel RGB -fill
#f5f3ee -colorize 100% png:-`. This recolors every opaque pixel to a light
ink while preserving the alpha channel exactly (mirrors the client-side
canvas recolorToLight() used in the on-site preview and the 3D decal).
Handles both PNG and SVG signature sources (needed -background none — without
it, ImageMagick's default SVG rasterization fills a white background instead
of preserving transparency, verified by testing both formats). Cached by a
hash of the source URL under public/assets/recolored-signatures/ (gitignored
— regenerable, not something to track) so a re-ordered signature isn't
reprocessed. Fails open: any error (magick missing, fetch failure, an 8s
timeout) falls back to the original image rather than blocking order/draft
creation — a low-contrast print is a quality issue, not a reason to lose the
customer's order record.

Wired into both draft-creation paths (/wear-success and
reconcile-wear-orders.mjs): for each cart line item, look up its garment+
color's hex from wear-templates.json, and if luminance < 0.4 (dark), recolor
before setting design_image_url on the draft.

Verified the actual fix, not just the mechanism: recolored a real signature,
uploaded it to Printify, and rendered a real throwaway draft product on the
black polo — the signature is now clearly legible (light ink) instead of the
low-contrast dark-gray from the original verification. Deleted immediately
after. Also verified the recolor pipeline handles SVG-sourced signatures
correctly (transparency preserved) and that reconcile-wear-orders.mjs still
runs clean against the existing test order. Local E2E re-confirmed sales
still fully gated off (comingSoon:true) afterward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm

Files touched

Diff

commit 9f1ba52477e05215d0d4c42e7ac0776283ceb1fb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 17:05:42 2026 -0700

    wear: fix the black-garment print contrast bug for real (TK-10286)
    
    Resumes the standing KNOWN ISSUE noted in wear-templates.json since the
    2026-09-08 verification session: Printify prints a design's own pixel colors
    as-is (no auto-recolor for dark garments), so the black polo's dark-ink
    signature printed as low-contrast dark-gray-on-black on the REAL product —
    confirmed visually earlier this session, never fixed until now.
    
    Added recolorSignatureToLight() in server.js (and the same logic duplicated
    in scripts/reconcile-wear-orders.mjs, which has its own independent draft-
    creation path) — shells out to ImageMagick (already on this box) via stdin/
    stdout piping, no temp files: `magick -background none - -channel RGB -fill
    #f5f3ee -colorize 100% png:-`. This recolors every opaque pixel to a light
    ink while preserving the alpha channel exactly (mirrors the client-side
    canvas recolorToLight() used in the on-site preview and the 3D decal).
    Handles both PNG and SVG signature sources (needed -background none — without
    it, ImageMagick's default SVG rasterization fills a white background instead
    of preserving transparency, verified by testing both formats). Cached by a
    hash of the source URL under public/assets/recolored-signatures/ (gitignored
    — regenerable, not something to track) so a re-ordered signature isn't
    reprocessed. Fails open: any error (magick missing, fetch failure, an 8s
    timeout) falls back to the original image rather than blocking order/draft
    creation — a low-contrast print is a quality issue, not a reason to lose the
    customer's order record.
    
    Wired into both draft-creation paths (/wear-success and
    reconcile-wear-orders.mjs): for each cart line item, look up its garment+
    color's hex from wear-templates.json, and if luminance < 0.4 (dark), recolor
    before setting design_image_url on the draft.
    
    Verified the actual fix, not just the mechanism: recolored a real signature,
    uploaded it to Printify, and rendered a real throwaway draft product on the
    black polo — the signature is now clearly legible (light ink) instead of the
    low-contrast dark-gray from the original verification. Deleted immediately
    after. Also verified the recolor pipeline handles SVG-sourced signatures
    correctly (transparency preserved) and that reconcile-wear-orders.mjs still
    runs clean against the existing test order. Local E2E re-confirmed sales
    still fully gated off (comingSoon:true) afterward.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm
---
 .gitignore                        |  3 ++
 scripts/reconcile-wear-orders.mjs | 50 +++++++++++++++++++++++++++++++--
 server.js                         | 59 ++++++++++++++++++++++++++++++++++++++-
 3 files changed, 109 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index 44c4b4d..bba3be4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,6 @@ data/download-sids.json
 tmp_tm_cache/
 data/wear-orders.json
 data/pod-order-drafts.jsonl
+# runtime-generated cache (recolored dark-garment signature art) — regenerable
+# from the source signature URL, not something to track
+public/assets/recolored-signatures/
diff --git a/scripts/reconcile-wear-orders.mjs b/scripts/reconcile-wear-orders.mjs
index bdf211d..d2e73b1 100644
--- a/scripts/reconcile-wear-orders.mjs
+++ b/scripts/reconcile-wear-orders.mjs
@@ -23,10 +23,12 @@
 // Usage: node scripts/reconcile-wear-orders.mjs [--apply]
 //   (no flag) — report what WOULD change, write nothing.
 //   --apply   — persist the paid status + draft to disk.
-import { readFile, appendFile, writeFile } from 'node:fs/promises';
+import { readFile, appendFile, writeFile, mkdir } from 'node:fs/promises';
 import { readFileSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { join } from 'node:path';
+import { spawn } from 'node:child_process';
+import { createHash } from 'node:crypto';
 
 const ROOT = fileURLToPath(new URL('..', import.meta.url));
 const DATA = join(ROOT, 'data');
@@ -65,6 +67,43 @@ function wearOrderItems(order) {
   return [{ qid: order.qid, signature_name: order.signature_name, garment: order.garment, garment_label: order.garment_label,
     color: order.color, color_label: order.color_label, size: order.size, placement: order.placement }];
 }
+// Mirrors server.js's wearLuminance()/recolorSignatureToLight() exactly —
+// see that file for the full explanation (Printify prints a design's own
+// pixel colors as-is, so a dark-ink signature on a dark garment needs
+// recoloring to a light ink before it's actually printed). Fails open: any
+// error falls back to the original image rather than blocking reconciliation.
+const wearLuminance = hex => {
+  const n = parseInt(hex.slice(1), 16), r = (n>>16)&255, g = (n>>8)&255, b = n&255;
+  return (0.299*r + 0.587*g + 0.114*b) / 255;
+};
+const RECOLOR_DIR = join(ROOT, 'public', 'assets', 'recolored-signatures');
+async function recolorSignatureToLight(imageUrl) {
+  try {
+    const hash = createHash('sha256').update(imageUrl).digest('hex').slice(0, 24);
+    const outPath = join(RECOLOR_DIR, `${hash}.png`);
+    const outUrl = `/assets/recolored-signatures/${hash}.png`;
+    try { await readFile(outPath); return outUrl; } catch {}
+    const src = await fetch(imageUrl);
+    if (!src.ok) throw new Error(`fetch ${src.status}`);
+    const buf = Buffer.from(await src.arrayBuffer());
+    const out = await new Promise((resolve, reject) => {
+      const proc = spawn('magick', ['-background', 'none', '-', '-channel', 'RGB', '-fill', '#f5f3ee', '-colorize', '100%', 'png:-']);
+      const chunks = []; let errText = '';
+      const timer = setTimeout(() => { proc.kill(); reject(new Error('magick timeout')); }, 8000);
+      proc.stdout.on('data', c => chunks.push(c));
+      proc.stderr.on('data', c => errText += c);
+      proc.on('error', e => { clearTimeout(timer); reject(e); });
+      proc.on('close', code => { clearTimeout(timer); code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(errText || `magick exit ${code}`)); });
+      proc.stdin.end(buf);
+    });
+    await mkdir(RECOLOR_DIR, { recursive: true });
+    await writeFile(outPath, out);
+    return outUrl;
+  } catch (e) {
+    console.error('recolorSignatureToLight failed, using original image:', e.message);
+    return imageUrl;
+  }
+}
 
 async function main() {
   const key = resolveStripeKey();
@@ -103,16 +142,23 @@ async function main() {
     const provider = order.provider || 'printful';
     if (!order.pod_submitted) {
       if (!sigCache) sigCache = await mergedSignatures();
+      const draftTpl = await load('wear-templates.json', { garments: [] });
       const items = wearOrderItems(order);
       for (let i = 0; i < items.length; i++) {
         const it = items[i];
         const compositeId = `${order.id}.${i}`;
         if (alreadyDraftedIds.has(compositeId)) continue;
         const sig = sigCache.find(x => qidOf(x) === it.qid);
+        let designUrl = sig ? sig.signature_image_url : null;
+        if (designUrl) {
+          const g = (draftTpl.garments || []).find(x => x.id === it.garment);
+          const c = g && (g.colors || []).find(x => x.id === it.color);
+          if (c && wearLuminance(c.hex) < 0.4) designUrl = await recolorSignatureToLight(designUrl);
+        }
         const draft = { draftedAt: new Date().toISOString(), orderId: compositeId, status: 'DRAFT_UNSENT', provider,
           recipient_email: order.email, recipient: order.recipient, garment: it.garment, color: it.color, size: it.size,
           placement: it.placement, signature_name: it.signature_name,
-          design_image_url: sig ? sig.signature_image_url : null,
+          design_image_url: designUrl,
           note: `NOT SENT — awaiting Steve approval + ${provider} credentials/mapping + WEAR_SALES_LIVE=1 (drafted by reconcile-wear-orders.mjs — customer never reached /wear-success)` };
         await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
         alreadyDraftedIds.add(compositeId);
diff --git a/server.js b/server.js
index b8e4cc8..dd8e1c2 100644
--- a/server.js
+++ b/server.js
@@ -8,6 +8,7 @@ import { extname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { scryptSync, randomBytes, timingSafeEqual, createHash, sign } from 'node:crypto';
 import { readFileSync } from 'node:fs';
+import { spawn } from 'node:child_process';
 
 const ROOT = fileURLToPath(new URL('.', import.meta.url));
 const PORT = process.env.PORT || 9920;
@@ -217,6 +218,52 @@ function wearOrderItems(order) {
   return [{ qid: order.qid, signature_name: order.signature_name, garment: order.garment, garment_label: order.garment_label,
     color: order.color, color_label: order.color_label, size: order.size, placement: order.placement }];
 }
+// Mirrors public/wear.html's client-side luminance() — decides whether a
+// garment color is dark enough that the source signature art (dark ink)
+// needs recoloring to a light ink before it's actually printed.
+const wearLuminance = hex => {
+  const n = parseInt(hex.slice(1), 16), r = (n>>16)&255, g = (n>>8)&255, b = n&255;
+  return (0.299*r + 0.587*g + 0.114*b) / 255;
+};
+const RECOLOR_DIR = join(ROOT, 'public', 'assets', 'recolored-signatures');
+// Real print fix for the KNOWN ISSUE noted in wear-templates.json: Printify
+// prints a design's own pixel colors as-is (no auto-recolor for dark
+// garments), so a dark-ink signature on a dark colorway (e.g. the polo's
+// black) would print near-invisible. For orders on a dark colorway, recolor
+// the signature to a light ink — mirroring the on-site preview's own
+// recolorToLight() — via ImageMagick (already on this box for other
+// scripts), cached by URL hash so a re-ordered signature isn't reprocessed.
+// FAILS OPEN: any error (magick missing, fetch failure, timeout) falls back
+// to the original image rather than blocking order/draft creation — a
+// slightly-low-contrast print is a quality issue, not a reason to lose the
+// customer's order record.
+async function recolorSignatureToLight(imageUrl) {
+  try {
+    const hash = createHash('sha256').update(imageUrl).digest('hex').slice(0, 24);
+    const outPath = join(RECOLOR_DIR, `${hash}.png`);
+    const outUrl = `/assets/recolored-signatures/${hash}.png`;
+    try { await readFile(outPath); return outUrl; } catch {} // cache hit
+    const src = await fetch(imageUrl);
+    if (!src.ok) throw new Error(`fetch ${src.status}`);
+    const buf = Buffer.from(await src.arrayBuffer());
+    const out = await new Promise((resolve, reject) => {
+      const proc = spawn('magick', ['-background', 'none', '-', '-channel', 'RGB', '-fill', '#f5f3ee', '-colorize', '100%', 'png:-']);
+      const chunks = []; let errText = '';
+      const timer = setTimeout(() => { proc.kill(); reject(new Error('magick timeout')); }, 8000);
+      proc.stdout.on('data', c => chunks.push(c));
+      proc.stderr.on('data', c => errText += c);
+      proc.on('error', e => { clearTimeout(timer); reject(e); });
+      proc.on('close', code => { clearTimeout(timer); code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(errText || `magick exit ${code}`)); });
+      proc.stdin.end(buf);
+    });
+    await mkdir(RECOLOR_DIR, { recursive: true });
+    await writeFile(outPath, out);
+    return outUrl;
+  } catch (e) {
+    console.error('recolorSignatureToLight failed, using original image:', e.message);
+    return imageUrl;
+  }
+}
 async function adminToken() {
   // Bootstrap a local admin token on first use (data/admin-token.txt, gitignored
   // + deploy-protected). Steve reads it from the file to approve uploads.
@@ -936,16 +983,26 @@ ${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
                 try { existingLines = (await readFile(join(DATA, 'pod-order-drafts.jsonl'), 'utf8')).trim().split('\n').filter(Boolean); } catch {}
                 const alreadyDrafted = new Set(existingLines.map(l => { try { return JSON.parse(l).orderId; } catch { return null; } }));
                 const sigsAll = await mergedSignatures();
+                const draftTpl = await wearTemplates();
                 const items = wearOrderItems(order);
                 for (let i = 0; i < items.length; i++) {
                   const it = items[i];
                   const compositeId = `${order.id}.${i}`;
                   if (alreadyDrafted.has(compositeId)) continue;
                   const sig = sigsAll.find(x => qidOf(x) === it.qid);
+                  let designUrl = sig ? sig.signature_image_url : null;
+                  // Recolor to a light ink on a dark garment — see
+                  // recolorSignatureToLight()'s comment for why this matters
+                  // (Printify prints the source art's own colors as-is).
+                  if (designUrl) {
+                    const g = (draftTpl.garments || []).find(x => x.id === it.garment);
+                    const c = g && (g.colors || []).find(x => x.id === it.color);
+                    if (c && wearLuminance(c.hex) < 0.4) designUrl = await recolorSignatureToLight(designUrl);
+                  }
                   const draft = { draftedAt: new Date().toISOString(), orderId: compositeId, status: 'DRAFT_UNSENT', provider,
                     recipient_email: order.email, recipient: order.recipient, garment: it.garment, color: it.color, size: it.size,
                     placement: it.placement, signature_name: it.signature_name,
-                    design_image_url: sig ? sig.signature_image_url : null,
+                    design_image_url: designUrl,
                     note: `NOT SENT — awaiting Steve approval + ${provider} credentials/mapping + WEAR_SALES_LIVE=1` };
                   await appendFile(join(DATA, 'pod-order-drafts.jsonl'), JSON.stringify(draft) + '\n');
                 }

← bda7296 wear: real rotatable 3D viewer for the classic tee (Three.js  ·  back to CelebritySignatures  ·  wear: update stale KNOWN ISSUE notes now that the black-prin 452cc4c →