← back to Wallco Ai
wallco.ai v0.1: PG schema (pd_source_designs + spoon_all_designs) · Met/Wikimedia/Smithsonian crawler · palette extraction · design generator (stub backend) · viewer on :9792 · 10 seamless tiles + 33 PD source images
28534381f83a0c1985e3edf273a221ce50f416e5 · 2026-05-11 15:59:12 -0700 · Steve
Files touched
A .gitignoreA db/migrations/001_schema.sqlA ecosystem.config.jsA package-lock.jsonA package.jsonA scripts/crawl_pd.pyA scripts/extract_palette.pyA scripts/generate_designs.jsA server.js
Diff
commit 28534381f83a0c1985e3edf273a221ce50f416e5
Author: Steve <steve@designerwallcoverings.com>
Date: Mon May 11 15:59:12 2026 -0700
wallco.ai v0.1: PG schema (pd_source_designs + spoon_all_designs) · Met/Wikimedia/Smithsonian crawler · palette extraction · design generator (stub backend) · viewer on :9792 · 10 seamless tiles + 33 PD source images
---
.gitignore | 8 +
db/migrations/001_schema.sql | 65 ++++
ecosystem.config.js | 9 +
package-lock.json | 840 +++++++++++++++++++++++++++++++++++++++++++
package.json | 17 +
scripts/crawl_pd.py | 344 ++++++++++++++++++
scripts/extract_palette.py | 53 +++
scripts/generate_designs.js | 205 +++++++++++
server.js | 217 +++++++++++
9 files changed, 1758 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..09fc806
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+data/images/
diff --git a/db/migrations/001_schema.sql b/db/migrations/001_schema.sql
new file mode 100644
index 0000000..9c3707f
--- /dev/null
+++ b/db/migrations/001_schema.sql
@@ -0,0 +1,65 @@
+-- wallco.ai — PG schema (dw_unified)
+-- Two tables:
+-- pd_source_designs — public-domain / CC0 pattern source corpus (Smithsonian, Wikimedia, LACMA, IA, V&A CC0)
+-- spoon_all_designs — generated wallco.ai designs (AI-original + variants), tracks provenance
+
+CREATE TABLE IF NOT EXISTS pd_source_designs (
+ id BIGSERIAL PRIMARY KEY,
+ source TEXT NOT NULL, -- 'smithsonian' | 'wikimedia' | 'lacma' | 'ia' | 'va_cc0'
+ source_id TEXT NOT NULL, -- ID at source (e.g. 'edanmdm:nmah_1234')
+ title TEXT,
+ creator TEXT,
+ date_created TEXT, -- free-text; PD spans centuries, hard to normalize
+ license TEXT NOT NULL, -- 'CC0' | 'PD' | 'PDMark' (must be one of these for inclusion)
+ source_url TEXT NOT NULL, -- canonical source page
+ image_url TEXT NOT NULL, -- high-res image URL
+ local_path TEXT, -- ~/Projects/wallco-ai/data/images/<source>/<id>.jpg once downloaded
+ width_px INTEGER,
+ height_px INTEGER,
+ bytes BIGINT,
+ raw_metadata JSONB, -- whatever the source API returned
+ tags TEXT[], -- source-provided tags
+ motifs TEXT[], -- LLM-extracted motifs (e.g. 'peony', 'scroll', 'damask')
+ dominant_hex TEXT,
+ palette JSONB, -- [{hex, pct}, ...]
+ category TEXT, -- 'floral' | 'damask' | 'geometric' | 'art-deco' | etc.
+ ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (source, source_id)
+);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_source ON pd_source_designs(source);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_category ON pd_source_designs(category);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_dominant ON pd_source_designs(dominant_hex);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_motifs ON pd_source_designs USING GIN(motifs);
+CREATE INDEX IF NOT EXISTS idx_pd_source_designs_tags ON pd_source_designs USING GIN(tags);
+
+
+CREATE TABLE IF NOT EXISTS spoon_all_designs (
+ id BIGSERIAL PRIMARY KEY,
+ brand TEXT NOT NULL DEFAULT 'wallco.ai',
+ kind TEXT NOT NULL CHECK (kind IN ('seamless_tile','mural','mural_panel')),
+ width_in NUMERIC(8,2),
+ height_in NUMERIC(8,2),
+ panels INTEGER, -- mural: how many panels; null for seamless tile
+ generator TEXT, -- 'sdxl-text' | 'sdxl-img2img' | 'comfy' | 'replicate' | 'stub'
+ prompt TEXT, -- text prompt used (always recorded for provenance)
+ negative_prompt TEXT,
+ seed BIGINT,
+ steps INTEGER,
+ cfg_scale NUMERIC(5,2),
+ pd_source_ids BIGINT[], -- which pd_source_designs rows fed this generation (if any)
+ image_url TEXT, -- public CDN/serve URL (when deployed)
+ local_path TEXT, -- ~/Projects/wallco-ai/data/generated/<id>.png
+ dominant_hex TEXT,
+ palette JSONB,
+ motifs TEXT[], -- LLM-extracted
+ tags TEXT[],
+ category TEXT,
+ is_published BOOLEAN NOT NULL DEFAULT FALSE,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_kind ON spoon_all_designs(kind);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_category ON spoon_all_designs(category);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_motifs ON spoon_all_designs USING GIN(motifs);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_tags ON spoon_all_designs USING GIN(tags);
+CREATE INDEX IF NOT EXISTS idx_spoon_all_published ON spoon_all_designs(is_published);
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..1bdba17
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,9 @@
+module.exports = {
+ apps: [{
+ name: 'wallco-ai-viewer',
+ cwd: '/Users/stevestudio2/Projects/wallco-ai',
+ script: 'server.js',
+ env: { PORT: 9792 },
+ max_memory_restart: '256M'
+ }]
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..7eb5d1d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,840 @@
+{
+ "name": "wallco-ai",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "wallco-ai",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "dotenv": "^17.4.2",
+ "express": "^5.2.1"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
+ "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.7.0",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.1",
+ "raw-body": "^3.0.1",
+ "type-is": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
+ "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c5f9381
--- /dev/null
+++ b/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "wallco-ai",
+ "version": "1.0.0",
+ "main": "server.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "start": "node server.js"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "dependencies": {
+ "dotenv": "^17.4.2",
+ "express": "^5.2.1"
+ }
+}
diff --git a/scripts/crawl_pd.py b/scripts/crawl_pd.py
new file mode 100644
index 0000000..7ffedb9
--- /dev/null
+++ b/scripts/crawl_pd.py
@@ -0,0 +1,344 @@
+#!/usr/bin/env python3
+"""
+Multi-source public-domain pattern corpus crawler for wallco.ai.
+
+Sources:
+ - Wikimedia Commons (no key, polite 2 req/s)
+ Categories: Textile_patterns, Floral_patterns, Decorative_patterns,
+ Geometric_patterns, Damask, Paisley_patterns, Toile, Brocade,
+ Files_from_the_Smithsonian, Files_from_the_Cooper_Hewitt,_Smithsonian_Design_Museum
+ - Met Museum Open Access (no key, no advertised rate limit)
+ Filter: isPublicDomain=true, classification in our pattern set
+ - Smithsonian / Cooper Hewitt (DEMO_KEY 30/hr — used sparingly)
+ Query: unit_code=CHNDM AND wallpaper
+
+Writes to dw_unified.pd_source_designs. Idempotent on (source, source_id).
+Downloads images to ~/Projects/wallco-ai/data/images/<source>/<id>.<ext>.
+
+Run:
+ python3 scripts/crawl_pd.py --source wikimedia --limit 200
+ python3 scripts/crawl_pd.py --source met --limit 500
+ python3 scripts/crawl_pd.py --source smithsonian --limit 50
+"""
+import os, sys, json, time, argparse, urllib.request, ssl, subprocess
+from pathlib import Path
+from urllib.parse import quote
+
+ROOT = Path.home() / 'Projects' / 'wallco-ai'
+IMGROOT = ROOT / 'data' / 'images'
+LOG = ROOT / 'logs' / 'crawl_pd.log'
+IMGROOT.mkdir(parents=True, exist_ok=True)
+LOG.parent.mkdir(parents=True, exist_ok=True)
+
+CTX = ssl.create_default_context()
+
+WIKIMEDIA_CATEGORIES = [
+ 'Textile_patterns', 'Floral_patterns', 'Decorative_patterns',
+ 'Geometric_patterns', 'Damask', 'Paisley_patterns', 'Toile_de_Jouy',
+ 'Brocade', 'Wallpaper_(material)', 'Embroidery_patterns'
+]
+
+MET_PATTERN_QUERIES = ['wallpaper', 'textile', 'damask', 'brocade', 'pattern', 'design', 'ornament']
+
+PD_LICENSES = {'CC0', 'PD', 'Public domain', 'Public domain mark', 'PD-old', 'PD-self', 'PD-Art', 'PDM'}
+
+
+def http_get_json(url, timeout=20):
+ req = urllib.request.Request(url, headers={'User-Agent': 'wallco.ai-crawler/0.1 (research; contact: steve@designerwallcoverings.com)'})
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r:
+ return json.loads(r.read().decode('utf-8'))
+
+
+def http_download(url, dest, timeout=60):
+ req = urllib.request.Request(url, headers={'User-Agent': 'wallco.ai-crawler/0.1'})
+ with urllib.request.urlopen(req, timeout=timeout, context=CTX) as r, open(dest, 'wb') as f:
+ f.write(r.read())
+
+
+def psql(sql):
+ r = subprocess.run(['psql', 'dw_unified', '-At', '-q'], input=sql, capture_output=True, text=True)
+ if r.returncode != 0:
+ raise RuntimeError(f"psql failed: {r.stderr}")
+ return r.stdout.strip()
+
+
+def esc(s):
+ if s is None: return 'NULL'
+ return "'" + str(s).replace("'", "''") + "'"
+
+
+def upsert(source, source_id, **fields):
+ cols = ['source', 'source_id'] + list(fields.keys())
+ vals_clean = [esc(source), esc(source_id)]
+ for k, v in fields.items():
+ if v is None:
+ vals_clean.append('NULL')
+ elif isinstance(v, list):
+ arr = "ARRAY[" + ",".join(esc(x) for x in v) + "]::text[]"
+ vals_clean.append(arr)
+ elif isinstance(v, dict):
+ vals_clean.append(f"{esc(json.dumps(v))}::jsonb")
+ elif isinstance(v, bool):
+ vals_clean.append('TRUE' if v else 'FALSE')
+ elif isinstance(v, (int, float)):
+ vals_clean.append(str(v))
+ else:
+ vals_clean.append(esc(v))
+ update_set = ", ".join(f"{k}=EXCLUDED.{k}" for k in fields.keys())
+ sql = f"""
+INSERT INTO pd_source_designs ({", ".join(cols)})
+VALUES ({", ".join(vals_clean)})
+ON CONFLICT (source, source_id) DO UPDATE SET {update_set}, ingested_at=NOW()
+RETURNING id;
+"""
+ return psql(sql)
+
+
+# -------- Wikimedia Commons --------
+
+def crawl_wikimedia(limit=None, polite_s=0.5):
+ saved = 0
+ skipped_license = 0
+ for cat in WIKIMEDIA_CATEGORIES:
+ cont = ''
+ while True:
+ url = f'https://commons.wikimedia.org/w/api.php?action=query&list=categorymembers&cmtitle=Category:{cat}&cmtype=file&cmlimit=200&format=json' + cont
+ try:
+ d = http_get_json(url)
+ except Exception as e:
+ print(f" cat {cat} fetch err: {e}")
+ break
+ members = d.get('query', {}).get('categorymembers', []) or []
+ for m in members:
+ title = m['title'] # e.g. 'File:foo.jpg'
+ if not title.startswith('File:'):
+ continue
+ # Fetch imageinfo
+ ii_url = f'https://commons.wikimedia.org/w/api.php?action=query&titles={quote(title)}&prop=imageinfo&iiprop=url|extmetadata|size|mime&format=json'
+ try:
+ ii = http_get_json(ii_url)
+ except Exception as e:
+ print(f" imageinfo err for {title}: {e}")
+ continue
+ pages = ii.get('query', {}).get('pages', {}) or {}
+ page = next(iter(pages.values()), None)
+ if not page: continue
+ infos = page.get('imageinfo') or []
+ if not infos: continue
+ info = infos[0]
+ exm = info.get('extmetadata', {}) or {}
+ license_short = (exm.get('LicenseShortName', {}) or {}).get('value', '')
+ if not any(lic.lower() in license_short.lower() for lic in PD_LICENSES):
+ skipped_license += 1
+ time.sleep(polite_s)
+ continue
+ source_id = title
+ creator = (exm.get('Artist', {}) or {}).get('value', '')
+ date_created = (exm.get('DateTimeOriginal', {}) or {}).get('value', '') or (exm.get('DateTime', {}) or {}).get('value', '')
+ desc = (exm.get('ImageDescription', {}) or {}).get('value', '')
+ cats = (exm.get('Categories', {}) or {}).get('value', '')
+ # strip HTML
+ import re
+ creator_clean = re.sub(r'<[^>]+>', '', creator).strip()[:250]
+ desc_clean = re.sub(r'<[^>]+>', '', desc).strip()
+ tags = [t.strip() for t in cats.split('|') if t.strip()][:30]
+
+ img_url = info.get('url')
+ width = info.get('width')
+ height = info.get('height')
+ bytes_ = info.get('size')
+
+ # Download
+ ext = (img_url or '').rsplit('.', 1)[-1].lower()[:5] or 'jpg'
+ dest_dir = IMGROOT / 'wikimedia'
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ safe = title.replace('File:', '').replace(' ', '_').replace('/', '_')[:120]
+ dest = dest_dir / f'{safe}'
+ if not dest.exists() and img_url:
+ try:
+ http_download(img_url, dest)
+ except Exception as e:
+ print(f" dl err {title}: {e}")
+ time.sleep(polite_s)
+ continue
+
+ row_id = upsert(
+ 'wikimedia',
+ source_id,
+ title=desc_clean[:300] or title.replace('File:', ''),
+ creator=creator_clean,
+ date_created=date_created[:60] if date_created else None,
+ license=license_short or 'PD',
+ source_url=f'https://commons.wikimedia.org/wiki/{quote(title)}',
+ image_url=img_url,
+ local_path=str(dest),
+ width_px=width,
+ height_px=height,
+ bytes=bytes_,
+ raw_metadata=exm,
+ tags=tags,
+ category=cat.replace('_', '-').lower()
+ )
+ saved += 1
+ if saved % 10 == 0:
+ print(f" [{saved}] wikimedia saved · cat={cat} · last={title[:60]}")
+ if limit and saved >= limit:
+ print(f" hit limit {limit}, stopping")
+ return saved, skipped_license
+ time.sleep(polite_s)
+
+ cont_obj = d.get('continue')
+ if not cont_obj:
+ break
+ cont = '&cmcontinue=' + cont_obj['cmcontinue']
+ return saved, skipped_license
+
+
+# -------- Met Museum Open Access --------
+
+def crawl_met(limit=None, polite_s=0.4):
+ saved = 0
+ skipped_non_pd = 0
+ seen_ids = set()
+ for q in MET_PATTERN_QUERIES:
+ try:
+ ids_resp = http_get_json(f'https://collectionapi.metmuseum.org/public/collection/v1/search?q={quote(q)}&hasImages=true')
+ except Exception as e:
+ print(f" met search err for {q}: {e}")
+ continue
+ ids = ids_resp.get('objectIDs') or []
+ print(f" Met query '{q}' returned {len(ids)} candidates")
+ for oid in ids:
+ if oid in seen_ids: continue
+ seen_ids.add(oid)
+ try:
+ o = http_get_json(f'https://collectionapi.metmuseum.org/public/collection/v1/objects/{oid}')
+ except Exception as e:
+ print(f" object {oid} err: {e}")
+ time.sleep(polite_s); continue
+ if not o.get('isPublicDomain'):
+ skipped_non_pd += 1
+ time.sleep(polite_s); continue
+ primary = o.get('primaryImage') or o.get('primaryImageSmall')
+ if not primary:
+ time.sleep(polite_s); continue
+ title = o.get('title') or f"Met #{oid}"
+ ext = primary.rsplit('.', 1)[-1].split('?')[0][:5] or 'jpg'
+ dest_dir = IMGROOT / 'met'
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ dest = dest_dir / f'met_{oid}.{ext}'
+ if not dest.exists():
+ try:
+ http_download(primary, dest)
+ except Exception as e:
+ print(f" met dl err {oid}: {e}")
+ time.sleep(polite_s); continue
+ tags = [t.get('term') for t in (o.get('tags') or []) if t.get('term')]
+ row = upsert(
+ 'met',
+ str(oid),
+ title=title[:300],
+ creator=o.get('artistDisplayName') or '',
+ date_created=o.get('objectDate') or None,
+ license='CC0',
+ source_url=o.get('objectURL') or f'https://www.metmuseum.org/art/collection/search/{oid}',
+ image_url=primary,
+ local_path=str(dest),
+ raw_metadata={k: o.get(k) for k in ('classification','medium','culture','department','dimensions','period','objectName')},
+ tags=tags,
+ category=(o.get('classification') or '').lower().replace(' ', '-') or 'pattern'
+ )
+ saved += 1
+ if saved % 5 == 0:
+ print(f" [{saved}] met saved · oid={oid} · {title[:50]}")
+ if limit and saved >= limit:
+ return saved, skipped_non_pd
+ time.sleep(polite_s)
+ return saved, skipped_non_pd
+
+
+# -------- Smithsonian / Cooper Hewitt --------
+
+def crawl_smithsonian(limit=50, polite_s=2.5, api_key='DEMO_KEY'):
+ # DEMO_KEY = 30 req/hour. With polite_s=2.5 we do ~24 req/min — too fast.
+ # 30 req/hour = 1 every 120s. Stick to that or set a real api_key in .env.
+ # For initial seeding we just sample.
+ saved = 0
+ start = 0
+ while True:
+ url = f'https://api.si.edu/openaccess/api/v1.0/search?q=unit_code%3ACHNDM+AND+wallpaper&start={start}&rows=20&api_key={api_key}'
+ try:
+ d = http_get_json(url)
+ except Exception as e:
+ print(f" si search err: {e}")
+ break
+ rows = (d.get('response') or {}).get('rows') or []
+ if not rows: break
+ for r in rows:
+ dnr = (r.get('content') or {}).get('descriptiveNonRepeating') or {}
+ om = dnr.get('online_media') or {}
+ media = om.get('media') or []
+ ind = (r.get('content') or {}).get('indexedStructured') or {}
+ for m in media[:5]: # keep at most 5 images per record
+ usage = (m.get('usage') or {}).get('access')
+ if usage != 'CC0':
+ continue
+ img_url = m.get('content') or m.get('thumbnail')
+ if not img_url: continue
+ source_id = f"{r.get('id')}|{m.get('idsId') or img_url[-40:]}"
+ dest_dir = IMGROOT / 'smithsonian'
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ ext = 'jpg'
+ safe = source_id.replace('|','_').replace('/','_')[:120]
+ dest = dest_dir / f'{safe}.{ext}'
+ if not dest.exists():
+ try:
+ http_download(img_url, dest)
+ except Exception as e:
+ print(f" si dl err {source_id}: {e}")
+ continue
+ upsert(
+ 'smithsonian',
+ source_id,
+ title=(r.get('title') or '')[:300],
+ creator=(', '.join((ind.get('name') or [])))[:250],
+ date_created=(', '.join(ind.get('date') or []))[:60],
+ license='CC0',
+ source_url=f"https://www.si.edu/object/{r.get('id')}",
+ image_url=img_url,
+ local_path=str(dest),
+ raw_metadata={k: ind.get(k) for k in ('object_type','topic','culture','place','date')},
+ tags=(ind.get('topic') or [])[:20],
+ category=(((ind.get('object_type') or [None])[0]) or 'wallpaper').lower().replace(' ', '-')
+ )
+ saved += 1
+ if saved % 5 == 0:
+ print(f" [{saved}] smithsonian saved · {r.get('title','')[:40]}")
+ if limit and saved >= limit:
+ return saved
+ time.sleep(polite_s)
+ start += 20
+ if start >= 500: break
+ return saved
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--source', choices=['wikimedia', 'met', 'smithsonian', 'all'], default='all')
+ ap.add_argument('--limit', type=int, default=200, help='per-source cap')
+ args = ap.parse_args()
+ if args.source in ('wikimedia', 'all'):
+ n, sk = crawl_wikimedia(limit=args.limit)
+ print(f"WIKIMEDIA: saved {n}, skipped non-PD {sk}")
+ if args.source in ('met', 'all'):
+ n, sk = crawl_met(limit=args.limit)
+ print(f"MET: saved {n}, skipped non-PD {sk}")
+ if args.source in ('smithsonian', 'all'):
+ n = crawl_smithsonian(limit=min(args.limit, 50))
+ print(f"SMITHSONIAN: saved {n}")
+ print("\n--- DB counts ---")
+ print(psql("SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1;"))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/extract_palette.py b/scripts/extract_palette.py
new file mode 100644
index 0000000..40e7f19
--- /dev/null
+++ b/scripts/extract_palette.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+"""Extract dominant_hex + 5-color palette for every pd_source_designs row
+with a local image but no palette yet. Reuses the SP-onboarding pattern."""
+import json, subprocess
+from pathlib import Path
+from PIL import Image
+from collections import Counter
+
+def dominant_palette(path, k=5):
+ img = Image.open(path).convert('RGB')
+ img.thumbnail((300, 300))
+ pal = img.quantize(colors=k, method=Image.Quantize.MEDIANCUT).convert('RGB')
+ pixels = list(pal.getdata())
+ cnt = Counter(pixels)
+ total = sum(cnt.values())
+ return [{'hex': '#{:02x}{:02x}{:02x}'.format(*rgb), 'pct': round(n/total*100, 2)} for rgb, n in cnt.most_common(k)]
+
+def fetchall_pending():
+ sql = "SELECT json_agg(t) FROM (SELECT id, local_path FROM pd_source_designs WHERE local_path IS NOT NULL AND palette IS NULL ORDER BY id) t;"
+ out = subprocess.run(['psql','dw_unified','-At','-q','-c',sql], capture_output=True, text=True, check=True).stdout.strip()
+ return json.loads(out) if out and out != '' else []
+
+def main():
+ rows = fetchall_pending()
+ print(f"{len(rows)} rows pending palette extraction")
+ updates = []
+ failed = 0
+ for i, r in enumerate(rows, 1):
+ p = Path(r['local_path'])
+ if not p.exists():
+ failed += 1; continue
+ try:
+ palette = dominant_palette(p)
+ dominant = palette[0]['hex']
+ updates.append((r['id'], dominant, palette))
+ if i % 10 == 0:
+ print(f" [{i:>4}/{len(rows)}] {dominant} {p.name[:60]}")
+ except Exception as e:
+ failed += 1
+ print(f" [{i:>4}/{len(rows)}] FAIL {p.name[:50]}: {e}")
+ fp = Path('/tmp/wallco_palette.sql')
+ with fp.open('w') as f:
+ f.write("BEGIN;\n")
+ for rid, dh, pal in updates:
+ pj = json.dumps(pal).replace("'", "''")
+ f.write(f"UPDATE pd_source_designs SET dominant_hex='{dh}', palette='{pj}'::jsonb WHERE id={rid};\n")
+ f.write("COMMIT;\n")
+ subprocess.run(['psql','dw_unified','-q','-f', str(fp)], check=True)
+ fp.unlink()
+ print(f"\nUpdated {len(updates)} · failed {failed}")
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/generate_designs.js b/scripts/generate_designs.js
new file mode 100644
index 0000000..7730d90
--- /dev/null
+++ b/scripts/generate_designs.js
@@ -0,0 +1,205 @@
+#!/usr/bin/env node
+/**
+ * Wallco.ai design generator — produces N seamless 24x24 tiles + mural panels
+ * and stores in dw_unified.spoon_all_designs.
+ *
+ * Generation backends (env-driven):
+ * GEN_BACKEND=stub — fast placeholder (1024×1024 noise tile). Default.
+ * GEN_BACKEND=replicate — Replicate SDXL via REPLICATE_API_TOKEN.
+ * GEN_BACKEND=comfy — local ComfyUI at COMFY_URL (e.g. http://127.0.0.1:8188).
+ * GEN_BACKEND=ollama-sd — Ollama with SD-capable model (not common; mostly LLMs).
+ *
+ * Stub mode lets us validate the schema + storage + viewer pipeline today
+ * without a real diffusion endpoint. Swap GEN_BACKEND once Steve has SDXL wired up.
+ *
+ * Usage:
+ * node scripts/generate_designs.js --n 10 --kind seamless_tile --category floral
+ * node scripts/generate_designs.js --n 1 --kind mural --width 108 --height 96 --panels 4
+ */
+require('dotenv').config({ path: require('path').join(__dirname,'..','.env') });
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync, spawnSync } = require('child_process');
+
+const ROOT = path.join(__dirname, '..');
+const OUT = path.join(ROOT, 'data', 'generated');
+fs.mkdirSync(OUT, { recursive: true });
+
+const BACKEND = process.env.GEN_BACKEND || 'stub';
+
+function args() {
+ const a = process.argv.slice(2);
+ const o = { n: 10, kind: 'seamless_tile', width: 24, height: 24, panels: null, category: 'mixed', prompts: null };
+ for (let i = 0; i < a.length; i++) {
+ const v = a[i+1];
+ switch (a[i]) {
+ case '--n': o.n = parseInt(v, 10); i++; break;
+ case '--kind': o.kind = v; i++; break;
+ case '--width': o.width = parseFloat(v); i++; break;
+ case '--height': o.height = parseFloat(v); i++; break;
+ case '--panels': o.panels = parseInt(v, 10); i++; break;
+ case '--category': o.category = v; i++; break;
+ case '--prompts': o.prompts = v.split('|'); i++; break;
+ }
+ }
+ return o;
+}
+
+const PROMPT_LIBRARY = {
+ floral: [
+ 'Art Nouveau peony repeat, sage and rose, watercolour on cream linen, hand-painted seamless tile',
+ 'Botanical herbarium pressed-flower pattern, muted olive and ochre, archival paper texture',
+ 'English chintz floral spray, navy blue ground, golden yellow blossoms, traditional damask host',
+ 'Loose pastel azalea repeat, soft pinks and lavenders, hand-drawn watercolour, 1920s parlour wallpaper'
+ ],
+ geometric: [
+ 'Art Deco fan motif repeat, brass and verdigris on jet black, gilded edge, 1925 ocean liner',
+ 'Bauhaus colorblock grid, primary red blue yellow on bone white, geometric seamless tile',
+ 'Mid-century atomic burst pattern, terracotta and teal, 1955 wallcovering',
+ 'Moorish tile lattice, midnight blue and antique brass, seamless 24x24 tile'
+ ],
+ damask: [
+ 'Classical Italian damask, oyster on champagne, hand-engraved feel, formal salon wallpaper',
+ 'Modern reinterpreted damask, charcoal grey on slate ground, minimalist scale',
+ 'Persian arabesque damask, gold thread on indigo silk feel, dimensional'
+ ],
+ mixed: [
+ 'Hand-painted toile de Jouy, sage on cream linen, pastoral scene, fine line work',
+ 'Block-printed Indian paisley repeat, indigo and madder on calico ground, hand-wood-block feel',
+ 'Japanese asanoha geometric, persimmon and slate, washi paper texture'
+ ]
+};
+
+function pickPrompts(category, n) {
+ const pool = PROMPT_LIBRARY[category] || PROMPT_LIBRARY.mixed;
+ const all = [];
+ while (all.length < n) all.push(pool[all.length % pool.length]);
+ return all.slice(0, n);
+}
+
+// ---- backends -------------------------------------------------------------
+
+function genStub(prompt, seed, outPath) {
+ // Generate a deterministic-from-seed 'pattern' using ImageMagick-style HSL gradient via Python+Pillow.
+ // Encodes prompt hash into hue so the output is at least visually distinguishable.
+ const py = `
+import sys, hashlib, random
+from PIL import Image, ImageDraw, ImageFilter
+prompt = sys.argv[1]; seed = int(sys.argv[2]); out = sys.argv[3]
+random.seed(seed)
+h = int(hashlib.sha1(prompt.encode()).hexdigest()[:6], 16)
+hue = h % 360
+import colorsys
+W = H = 1024
+img = Image.new('RGB', (W, H))
+d = ImageDraw.Draw(img)
+def hsl_to_rgb(h, s, l):
+ r,g,b = colorsys.hls_to_rgb(h/360.0, l, s)
+ return (int(r*255), int(g*255), int(b*255))
+img.paste(hsl_to_rgb(hue, 0.55, 0.78), (0,0,W,H))
+# Draw motifs in a tileable grid for seamless feel
+random.seed(seed)
+for _ in range(60):
+ cx = random.randint(0, W); cy = random.randint(0, H)
+ r = random.randint(20, 90)
+ hh = (hue + random.randint(-30, 30)) % 360
+ fill = hsl_to_rgb(hh, 0.65, 0.62)
+ outline = hsl_to_rgb(hh, 0.85, 0.32)
+ d.ellipse((cx-r, cy-r, cx+r, cy+r), fill=fill, outline=outline, width=2)
+img = img.filter(ImageFilter.GaussianBlur(radius=2))
+img.save(out, 'PNG')
+print(out)
+`;
+ const r = spawnSync('python3', ['-c', py, prompt, String(seed), outPath], { encoding: 'utf8' });
+ if (r.status !== 0) throw new Error('stub gen failed: ' + r.stderr);
+ return outPath;
+}
+
+function genReplicate(prompt, _seed, _outPath) {
+ throw new Error('Replicate backend not configured — set REPLICATE_API_TOKEN and SDXL_MODEL_VERSION');
+}
+
+function genComfy() {
+ throw new Error('ComfyUI backend not configured — set COMFY_URL and a workflow JSON path');
+}
+
+function generate(prompt, seed, outPath) {
+ switch (BACKEND) {
+ case 'replicate': return genReplicate(prompt, seed, outPath);
+ case 'comfy': return genComfy(prompt, seed, outPath);
+ default: return genStub(prompt, seed, outPath);
+ }
+}
+
+// ---- persist --------------------------------------------------------------
+
+function psql(sql) {
+ const r = spawnSync('psql', ['dw_unified', '-At', '-q'], { input: sql, encoding: 'utf8' });
+ if (r.status !== 0) throw new Error(r.stderr);
+ return r.stdout.trim();
+}
+
+function persistDesign({ kind, prompt, seed, localPath, width, height, panels, category }) {
+ // Extract palette via Pillow
+ const py = `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB')
+img.thumbnail((300,300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+pixels = list(pal.getdata()); cnt = Counter(pixels); total = sum(cnt.values())
+palette = [{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb), 'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]
+print(json.dumps(palette))
+`;
+ const r = spawnSync('python3', ['-c', py, localPath], { encoding: 'utf8' });
+ let palette = [], dominant = null;
+ try { palette = JSON.parse(r.stdout.trim()); dominant = palette[0]?.hex; } catch {}
+
+ const palJson = JSON.stringify(palette).replace(/'/g, "''");
+ const promptEsc = prompt.replace(/'/g, "''");
+ const sql = `
+INSERT INTO spoon_all_designs (kind, width_in, height_in, panels, generator, prompt, seed, dominant_hex, palette, local_path, category, is_published)
+VALUES (
+ '${kind}', ${width}, ${height}, ${panels == null ? 'NULL' : panels},
+ '${BACKEND}', '${promptEsc}', ${seed},
+ ${dominant ? "'" + dominant + "'" : 'NULL'},
+ '${palJson}'::jsonb,
+ '${localPath.replace(/'/g, "''")}',
+ '${category.replace(/'/g, "''")}',
+ FALSE
+) RETURNING id;`;
+ return psql(sql);
+}
+
+// ---- main -----------------------------------------------------------------
+
+function main() {
+ const opt = args();
+ console.log(`Generating ${opt.n} × ${opt.kind} (${opt.width}"×${opt.height}", category=${opt.category}, backend=${BACKEND})`);
+
+ const prompts = opt.prompts || pickPrompts(opt.category, opt.n);
+ const created = [];
+ for (let i = 0; i < opt.n; i++) {
+ const prompt = prompts[i];
+ const seed = crypto.randomInt(1, 2**31 - 1);
+ const filename = `${Date.now()}_${seed}.png`;
+ const outPath = path.join(OUT, filename);
+ try {
+ generate(prompt, seed, outPath);
+ const id = persistDesign({
+ kind: opt.kind, prompt, seed, localPath: outPath,
+ width: opt.width, height: opt.height, panels: opt.panels,
+ category: opt.category
+ });
+ console.log(` ✓ #${id} · seed ${seed} · ${path.basename(outPath)}`);
+ created.push(id);
+ } catch (e) {
+ console.log(` ✗ ${e.message}`);
+ }
+ }
+ console.log(`\nCreated ${created.length}/${opt.n} designs · IDs: ${created.join(',')}`);
+}
+
+main();
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..a61df85
--- /dev/null
+++ b/server.js
@@ -0,0 +1,217 @@
+#!/usr/bin/env node
+/**
+ * Wallco.ai local viewer — http://127.0.0.1:9792
+ *
+ * Two tabs:
+ * /pd — public-domain source corpus (Met / Wikimedia / Smithsonian)
+ * /designs — wallco.ai generated designs (spoon_all_designs)
+ * Standing rule: sort + density slider (localStorage-persisted).
+ */
+require('dotenv').config();
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const { execSync } = require('child_process');
+
+const app = express();
+const PORT = process.env.PORT || 9792;
+const DATA_ROOT = path.join(__dirname, 'data');
+
+function psql(sql) {
+ return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+
+// Serve local image files (so the viewer can show downloaded PD images)
+app.use('/img', express.static(DATA_ROOT, { maxAge: '7d' }));
+
+app.get('/api/pd', (_req, res) => {
+ try {
+ const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+ SELECT id, source, source_id, title, creator, date_created, license,
+ source_url, image_url, local_path, dominant_hex, palette,
+ category, tags, motifs
+ FROM pd_source_designs ORDER BY ingested_at DESC LIMIT 2000
+ ) t;`);
+ res.type('application/json').send(raw || '[]');
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/designs', (_req, res) => {
+ try {
+ const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+ SELECT id, kind, width_in, height_in, panels, generator, prompt,
+ seed, local_path, dominant_hex, palette, category, tags,
+ motifs, is_published, created_at
+ FROM spoon_all_designs ORDER BY created_at DESC LIMIT 2000
+ ) t;`);
+ res.type('application/json').send(raw || '[]');
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/counts', (_req, res) => {
+ try {
+ const pd = psql(`SELECT json_agg(t) FROM (SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1) t;`);
+ const designs = psql(`SELECT COUNT(*) FROM spoon_all_designs;`);
+ res.json({ pd: JSON.parse(pd || '[]'), designs: parseInt(designs || '0', 10) });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Helper: convert local_path absolute → /img relative URL
+function imgUrl(localPath) {
+ if (!localPath) return null;
+ if (localPath.startsWith(DATA_ROOT)) return '/img/' + localPath.slice(DATA_ROOT.length + 1);
+ return null;
+}
+
+app.get('/', (_req, res) => {
+ res.type('html').send(`<!doctype html>
+<meta charset="utf-8">
+<title>wallco.ai — corpus + designs</title>
+<style>
+ :root { --cols: 5; }
+ *,*::before,*::after { box-sizing: border-box; }
+ body { margin:0; font:13px/1.4 -apple-system, system-ui, sans-serif; color:#1a1a1a; background:#0f0e0c; }
+ header { padding: 24px 32px; border-bottom: 1px solid #2a2825; background:#1a1816; color:#e8e2d6; display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; }
+ header h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
+ header .tag { font-size:12px; color:#aaa; }
+ nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
+ nav a.on { color:#fff; border-bottom:1px solid #d2b15c; }
+ .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
+ .controls label { font-size:11px; color:#bba; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
+ .controls select, .controls input[type=range] { font:inherit; padding:4px 8px; border:1px solid #3a3631; background:#16140f; color:#fff; border-radius:2px; }
+ .controls input[type=range] { width:140px; }
+ main { padding: 24px 32px; }
+ .grid { display:grid; gap:18px; grid-template-columns: repeat(var(--cols), 1fr); }
+ .card { background:#1a1816; border:1px solid #2a2825; border-radius:2px; overflow:hidden; color:#ddd; }
+ .card .img { aspect-ratio: 1; background:#0d0c0a no-repeat center/cover; }
+ .card .meta { padding:10px 12px 14px; }
+ .card .title { font-size:12px; margin:0 0 4px; }
+ .card .sub { font-size:10px; color:#888; }
+ .card .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000060; vertical-align:middle; margin-right:4px; }
+ .card .badge { display:inline-block; padding:1px 5px; font-size:9px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-left:5px; text-transform:uppercase; letter-spacing:.05em; }
+ .empty { padding:48px; text-align:center; color:#666; }
+ a.card-link { color:inherit; text-decoration:none; display:block; }
+</style>
+<header>
+ <h1>wallco.ai</h1>
+ <span class="tag" id="count">…</span>
+ <nav>
+ <a href="#" id="tab-pd" class="on">Source corpus</a>
+ <a href="#" id="tab-designs">Generated designs</a>
+ </nav>
+ <div class="controls">
+ <label>Sort
+ <select id="sort">
+ <option value="recent">Recent</option>
+ <option value="color-wheel">Color Wheel</option>
+ <option value="light-dark">Light → Dark</option>
+ <option value="dark-light">Dark → Light</option>
+ <option value="source">Source</option>
+ <option value="title">Title A→Z</option>
+ </select>
+ </label>
+ <label>Density
+ <input type="range" id="density" min="3" max="9" step="1" value="5">
+ </label>
+ </div>
+</header>
+<main><div class="grid" id="grid"></div></main>
+<script>
+const DATA_ROOT = ${JSON.stringify(DATA_ROOT)};
+const sortSel = document.getElementById('sort');
+const dens = document.getElementById('density');
+const grid = document.getElementById('grid');
+const cnt = document.getElementById('count');
+sortSel.value = localStorage.getItem('wc.sort') || 'recent';
+dens.value = localStorage.getItem('wc.density') || 5;
+document.documentElement.style.setProperty('--cols', dens.value);
+
+let tab = localStorage.getItem('wc.tab') || 'pd';
+function setTab(t) {
+ tab = t;
+ document.getElementById('tab-pd').classList.toggle('on', t === 'pd');
+ document.getElementById('tab-designs').classList.toggle('on', t === 'designs');
+ localStorage.setItem('wc.tab', t);
+ load();
+}
+document.getElementById('tab-pd').addEventListener('click', e => { e.preventDefault(); setTab('pd'); });
+document.getElementById('tab-designs').addEventListener('click', e => { e.preventDefault(); setTab('designs'); });
+
+function hexToHsl(hex) {
+ if (!hex || hex.length < 4) return [0,0,0];
+ const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+ if (!m) return [0,0,0];
+ const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
+ const max = Math.max(r,g,b), min = Math.min(r,g,b);
+ const l = (max + min) / 2; let h = 0, s = 0;
+ if (max !== min) {
+ const d = max - min;
+ s = l > 0.5 ? d/(2-max-min) : d/(max+min);
+ if (max === r) h = (g-b)/d + (g<b?6:0);
+ else if (max === g) h = (b-r)/d + 2;
+ else h = (r-g)/d + 4;
+ h *= 60;
+ }
+ return [h,s,l];
+}
+
+function imgPath(p) {
+ if (!p) return '';
+ if (p.startsWith(DATA_ROOT)) return '/img/' + p.slice(DATA_ROOT.length + 1);
+ return p;
+}
+
+function render(data) {
+ const sort = sortSel.value;
+ const sorted = [...data].sort((a,b) => {
+ if (sort === 'recent') return 0;
+ if (sort === 'source') return (a.source||a.generator||'').localeCompare(b.source||b.generator||'');
+ if (sort === 'title') return (a.title||a.prompt||'').localeCompare(b.title||b.prompt||'');
+ const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
+ if (sort === 'light-dark') return hb[2]-ha[2];
+ if (sort === 'dark-light') return ha[2]-hb[2];
+ if (sort === 'color-wheel') {
+ const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
+ if (isaA !== isaB) return isaA ? -1 : 1;
+ if (isaA) return ha[2]-hb[2];
+ const bA = Math.floor(ha[0]/30), bB = Math.floor(hb[0]/30);
+ if (bA !== bB) return bA - bB;
+ return ha[2]-hb[2];
+ }
+ return 0;
+ });
+ cnt.textContent = sorted.length + (tab === 'pd' ? ' PD source images' : ' generated designs');
+ grid.innerHTML = sorted.map(p => {
+ const img = imgPath(p.local_path) || p.image_url || '';
+ const dot = p.dominant_hex ? \`<span class="dot" style="background:\${p.dominant_hex}" title="\${p.dominant_hex}"></span>\` : '';
+ const badge = (p.source || p.generator || '');
+ const title = p.title || p.prompt || ('#'+p.id);
+ const sub = p.creator || p.date_created || p.kind || '';
+ const link = p.source_url || '#';
+ return \`<a class="card-link" href="\${link}" target="_blank"><div class="card">
+ <div class="img" style="background-image:url('\${img}')"></div>
+ <div class="meta">
+ <div class="title">\${dot}\${title.slice(0,60)}<span class="badge">\${badge}</span></div>
+ <div class="sub">\${sub.slice(0,80)}</div>
+ </div>
+ </div></a>\`;
+ }).join('') || '<div class="empty">Empty — run scripts/crawl_pd.py or scripts/generate_designs.js</div>';
+}
+
+async function load() {
+ const r = await fetch(tab === 'pd' ? '/api/pd' : '/api/designs');
+ const data = await r.json();
+ render(data);
+}
+
+sortSel.addEventListener('change', () => { localStorage.setItem('wc.sort', sortSel.value); load(); });
+dens.addEventListener('input', () => {
+ document.documentElement.style.setProperty('--cols', dens.value);
+ localStorage.setItem('wc.density', dens.value);
+});
+
+setTab(tab);
+</script>`);
+});
+
+app.listen(PORT, '127.0.0.1', () => console.log(`wallco.ai viewer → http://127.0.0.1:${PORT}`));
(oldest)
·
back to Wallco Ai
·
wallco.ai: wire Replicate SDXL backend · inline-editor mount f5bdf9b →