← 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
M .gitignoreM scripts/reconcile-wear-orders.mjsM server.js
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 →