@calo-design/cli 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -774,7 +774,8 @@ function help() {
774
774
  ${c.dim("art push <dir>")} upload a bulk image set to the CDN + emit a remote-source manifest (login only)
775
775
  ${c.dim(" art push ./art/byo --set byo --manifest src/byo-art.ts --dry-run --force --direct")}
776
776
  ${c.dim("image \"<prompt>\"")} AI image generation via the broker (metered per user; login only)
777
- ${c.dim(" image \"…\" --out hero.png --ref photo.jpg --model google/gemini-3-pro-image --json")}
777
+ ${c.dim(" image \"…\" --out hero.png --ref photo-or-url --model google/gemini-3-pro-image --json")}
778
+ ${c.dim(" image \"…\" --ref <vessel-url> --ref-sheet <comp1> --ref-sheet <comp2> food refs: vessel + composed component sheet")}
778
779
  ${c.dim("image usage")} your monthly image-generation metering (images + USD)
779
780
  ${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
780
781
  ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
package/bin/imagegen.js CHANGED
@@ -5,11 +5,20 @@
5
5
  *
6
6
  * calo-design image "prompt" generate → writes ./<slug>.png, prints path + URL + cost
7
7
  * calo-design image "prompt" --out hero.png choose the output path
8
- * calo-design image "prompt" --ref photo.jpg reference/edit input (repeatable, ≤4)
8
+ * calo-design image "prompt" --ref photo.jpg reference/edit input (repeatable; file or https URL)
9
+ * calo-design image "prompt" --ref-sheet a.jpg --ref-sheet b.jpg
10
+ * composite component photos into ONE tile-grid
11
+ * reference sheet (row-major order — the food
12
+ * register's component sheet); counts as one ref
13
+ * calo-design image "prompt" --sheet-out s.jpg also save the composed sheet for inspection
9
14
  * calo-design image "prompt" --model <id> pick an allowed model (default: broker's)
10
15
  * calo-design image usage your monthly metering (images + USD, caps)
11
16
  * --json on either form for machine callers (the design/marketing skills use this)
12
17
  *
18
+ * Refs cap at 4 per generation (broker limit). https refs are fetched (≤6 MB) —
19
+ * this is how CDN reference art (e.g. the food-refs set) attaches without a
20
+ * local copy.
21
+ *
13
22
  * The OpenRouter key lives on the broker only; identity is the normal CLI session,
14
23
  * and the broker meters every generated image per user per month. The broker also
15
24
  * mirrors each image to the CDN (content-hashed, immutable) and returns that URL —
@@ -52,19 +61,40 @@ async function apiGet(pathname, session) {
52
61
  return json;
53
62
  }
54
63
 
55
- function refToDataUrl(file) {
56
- const mime = MIMES[path.extname(file).toLowerCase()];
57
- if (!mime) throw new Error(`--ref ${file}: must be a .png/.jpg/.jpeg/.webp file`);
64
+ const MAGIC_MIMES = [
65
+ [(b) => b.length > 2 && b[0] === 0xff && b[1] === 0xd8, "image/jpeg"],
66
+ [(b) => b.length > 7 && b.readUInt32BE(0) === 0x89504e47, "image/png"],
67
+ [(b) => b.length > 11 && b.toString("ascii", 8, 12) === "WEBP", "image/webp"],
68
+ ];
69
+
70
+ // A ref is a local file or an https URL (CDN reference art). Returns { buf, mime }.
71
+ async function loadRef(spec, flagName = "--ref") {
58
72
  let buf;
59
- try {
60
- buf = fs.readFileSync(file);
61
- } catch {
62
- throw new Error(`--ref ${file}: can't read file`);
73
+ if (/^https?:\/\//i.test(spec)) {
74
+ let res;
75
+ try {
76
+ res = await fetch(spec, { signal: AbortSignal.timeout(20000) });
77
+ } catch (e) {
78
+ throw new Error(`${flagName} ${spec}: fetch failed (${e.message})`);
79
+ }
80
+ if (!res.ok) throw new Error(`${flagName} ${spec}: HTTP ${res.status}`);
81
+ buf = Buffer.from(await res.arrayBuffer());
82
+ } else {
83
+ try {
84
+ buf = fs.readFileSync(spec);
85
+ } catch {
86
+ throw new Error(`${flagName} ${spec}: can't read file`);
87
+ }
63
88
  }
64
- if (buf.length > 6e6) throw new Error(`--ref ${file}: over 6 MB — resize it first`);
65
- return `data:${mime};base64,${buf.toString("base64")}`;
89
+ if (buf.length > 6e6) throw new Error(`${flagName} ${spec}: over 6 MB — resize it first`);
90
+ const magic = MAGIC_MIMES.find(([test]) => test(buf));
91
+ const mime = magic ? magic[1] : MIMES[path.extname(new URL(spec, "file:///").pathname).toLowerCase()];
92
+ if (!mime) throw new Error(`${flagName} ${spec}: must be a png/jpg/webp image`);
93
+ return { buf, mime };
66
94
  }
67
95
 
96
+ const toDataUrl = ({ buf, mime }) => `data:${mime};base64,${buf.toString("base64")}`;
97
+
68
98
  // "A hero shot of a grain bowl!" → "a-hero-shot-of-a-grain"
69
99
  const promptSlug = (p) =>
70
100
  p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 5).join("-") || "image";
@@ -105,7 +135,7 @@ async function cmdImage(args) {
105
135
 
106
136
  // First positional = the prompt. Walk the args so a space-separated flag value
107
137
  // (`--ref img.png`) is never mistaken for it.
108
- const VALUE_FLAGS = new Set(["--out", "--model", "--ref"]);
138
+ const VALUE_FLAGS = new Set(["--out", "--model", "--ref", "--ref-sheet", "--sheet-out"]);
109
139
  let prompt;
110
140
  for (let i = 0; i < args.length; i++) {
111
141
  if (VALUE_FLAGS.has(args[i])) i++; // skip the flag's value
@@ -113,14 +143,39 @@ async function cmdImage(args) {
113
143
  }
114
144
  if (!prompt) {
115
145
  throw new Error(
116
- 'usage: calo-design image "<prompt>" [--out file.png] [--ref img.png]... [--model id] [--json]\n or: calo-design image usage'
146
+ 'usage: calo-design image "<prompt>" [--out file.png] [--ref img-or-url]... [--ref-sheet img-or-url]... [--model id] [--json]\n or: calo-design image usage'
117
147
  );
118
148
  }
119
- const refImages = flagAll(args, "ref").map(refToDataUrl);
149
+ const refImages = [];
150
+ for (const spec of flagAll(args, "ref")) refImages.push(toDataUrl(await loadRef(spec)));
151
+
152
+ // --ref-sheet: composite N component photos into ONE row-major tile grid that
153
+ // costs a single ref slot. Order of the flags = tile order = the order the
154
+ // prompt's sheet map must bind.
155
+ const sheetSpecs = flagAll(args, "ref-sheet");
156
+ let sheetInfo = null;
157
+ if (sheetSpecs.length) {
158
+ const { composeSheet } = require("./refsheet");
159
+ const inputs = [];
160
+ for (const spec of sheetSpecs) inputs.push({ ...(await loadRef(spec, "--ref-sheet")), label: spec });
161
+ const sheet = composeSheet(inputs);
162
+ if (sheet.buf.length > 6e6) throw new Error(`--ref-sheet: composed sheet is over 6 MB (${sheetSpecs.length} tiles) — send fewer tiles`);
163
+ const sheetOut = flag(args, "sheet-out");
164
+ if (sheetOut) {
165
+ fs.mkdirSync(path.dirname(path.resolve(sheetOut)), { recursive: true });
166
+ fs.writeFileSync(sheetOut, sheet.buf);
167
+ }
168
+ refImages.push(toDataUrl({ buf: sheet.buf, mime: "image/jpeg" }));
169
+ sheetInfo = { tiles: sheetSpecs.length, cols: sheet.cols, rows: sheet.rows, ...(sheetOut ? { file: path.resolve(sheetOut) } : {}) };
170
+ }
171
+ if (refImages.length > 4) {
172
+ throw new Error(`${refImages.length} reference images — the broker caps a generation at 4. Move component refs onto --ref-sheet (one composed sheet = one ref).`);
173
+ }
120
174
  const model = flag(args, "model");
121
175
  const out = flag(args, "out");
122
176
 
123
177
  const session = await ensureSession();
178
+ if (!jsonOut && sheetInfo) log(c.dim(`Composed reference sheet: ${sheetInfo.tiles} tiles, ${sheetInfo.cols}×${sheetInfo.rows} grid (row-major)${sheetInfo.file ? ` → ${sheetInfo.file}` : ""}`));
124
179
  if (!jsonOut) log(c.dim(`Generating${model ? ` with ${model}` : ""}… (typically 10–30s)`));
125
180
  let res;
126
181
  try {
@@ -156,7 +211,7 @@ async function cmdImage(args) {
156
211
 
157
212
  if (jsonOut) {
158
213
  const { images: _images, ...rest } = data; // drop the base64 payload from machine output
159
- process.stdout.write("\n" + JSON.stringify({ ...rest, files }) + "\n");
214
+ process.stdout.write("\n" + JSON.stringify({ ...rest, files, ...(sheetInfo ? { refSheet: sheetInfo } : {}) }) + "\n");
160
215
  return;
161
216
  }
162
217
  for (const f of files) {
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Component reference sheet compositor for `calo-design image --ref-sheet`.
5
+ *
6
+ * Ported from the imagegen-lab collage builder: N component photos → one
7
+ * tile-grid JPEG (512px tiles, off-white gutterless grid, row-major order).
8
+ * The grid exists because the broker caps a generation at 4 reference images —
9
+ * a meal with more components sends ONE composed sheet instead, and the prompt
10
+ * binds each tile to its named component ("REFERENCE COMPONENT SHEET MAP").
11
+ *
12
+ * Pure-JS on purpose (jpeg-js + pngjs): the CLI stays native-dep-free so
13
+ * install never needs a compiler toolchain.
14
+ */
15
+
16
+ const jpeg = require("jpeg-js");
17
+ const { PNG } = require("pngjs");
18
+
19
+ const TILE_PX = 512; // longest edge of each tile (matches the lab's collage)
20
+ const BG = [248, 248, 246];
21
+
22
+ // Same grid ladder as the lab's _pick_grid: near-square, fills row-major.
23
+ function pickGrid(n) {
24
+ if (n <= 1) return [1, 1];
25
+ if (n <= 2) return [2, 1];
26
+ if (n <= 4) return [2, 2];
27
+ if (n <= 6) return [3, 2];
28
+ if (n <= 9) return [3, 3];
29
+ const cols = Math.ceil(Math.sqrt(n));
30
+ return [cols, Math.ceil(n / cols)];
31
+ }
32
+
33
+ function decode(buf, label) {
34
+ if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8) {
35
+ const { width, height, data } = jpeg.decode(buf, { maxMemoryUsageInMB: 256 });
36
+ return { width, height, data };
37
+ }
38
+ if (buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47) {
39
+ const { width, height, data } = PNG.sync.read(buf);
40
+ return { width, height, data };
41
+ }
42
+ throw new Error(`--ref-sheet ${label}: only JPEG/PNG images can go on a reference sheet`);
43
+ }
44
+
45
+ // Bilinear resize, RGBA in/out. Never upscales (scale capped at 1).
46
+ function resize(img, maxEdge) {
47
+ const scale = Math.min(maxEdge / img.width, maxEdge / img.height, 1);
48
+ const w = Math.max(1, Math.round(img.width * scale));
49
+ const h = Math.max(1, Math.round(img.height * scale));
50
+ if (w === img.width && h === img.height) return img;
51
+ const out = Buffer.alloc(w * h * 4);
52
+ for (let y = 0; y < h; y++) {
53
+ const sy = ((y + 0.5) * img.height) / h - 0.5;
54
+ const y0 = Math.max(0, Math.floor(sy));
55
+ const y1 = Math.min(img.height - 1, y0 + 1);
56
+ const fy = sy - y0;
57
+ for (let x = 0; x < w; x++) {
58
+ const sx = ((x + 0.5) * img.width) / w - 0.5;
59
+ const x0 = Math.max(0, Math.floor(sx));
60
+ const x1 = Math.min(img.width - 1, x0 + 1);
61
+ const fx = sx - x0;
62
+ const di = (y * w + x) * 4;
63
+ for (let ch = 0; ch < 4; ch++) {
64
+ const p00 = img.data[(y0 * img.width + x0) * 4 + ch];
65
+ const p10 = img.data[(y0 * img.width + x1) * 4 + ch];
66
+ const p01 = img.data[(y1 * img.width + x0) * 4 + ch];
67
+ const p11 = img.data[(y1 * img.width + x1) * 4 + ch];
68
+ out[di + ch] = Math.round(
69
+ p00 * (1 - fx) * (1 - fy) + p10 * fx * (1 - fy) + p01 * (1 - fx) * fy + p11 * fx * fy
70
+ );
71
+ }
72
+ }
73
+ }
74
+ return { width: w, height: h, data: out };
75
+ }
76
+
77
+ /**
78
+ * Compose the sheet. `inputs` = [{ buf, label }] in the order the caller wants
79
+ * the tiles bound (row-major). Returns { buf (JPEG), width, height, cols, rows }.
80
+ */
81
+ function composeSheet(inputs) {
82
+ if (!inputs.length) throw new Error("--ref-sheet: no images to compose");
83
+ const tiles = inputs.map((i) => resize(decode(i.buf, i.label), TILE_PX));
84
+ const [cols, rows] = pickGrid(tiles.length);
85
+ const W = cols * TILE_PX;
86
+ const H = rows * TILE_PX;
87
+ const canvas = Buffer.alloc(W * H * 4);
88
+ for (let p = 0; p < W * H; p++) {
89
+ canvas[p * 4] = BG[0];
90
+ canvas[p * 4 + 1] = BG[1];
91
+ canvas[p * 4 + 2] = BG[2];
92
+ canvas[p * 4 + 3] = 255;
93
+ }
94
+ tiles.forEach((tile, idx) => {
95
+ const cx = (idx % cols) * TILE_PX + Math.floor((TILE_PX - tile.width) / 2);
96
+ const cy = Math.floor(idx / cols) * TILE_PX + Math.floor((TILE_PX - tile.height) / 2);
97
+ for (let y = 0; y < tile.height; y++) {
98
+ // Alpha is ignored on paste: reference photos are opaque; PNG alpha
99
+ // lands on the off-white background anyway after JPEG encode.
100
+ tile.data.copy(canvas, ((cy + y) * W + cx) * 4, y * tile.width * 4, (y + 1) * tile.width * 4);
101
+ }
102
+ });
103
+ const out = jpeg.encode({ data: canvas, width: W, height: H }, 85);
104
+ return { buf: out.data, width: W, height: H, cols, rows };
105
+ }
106
+
107
+ module.exports = { composeSheet, _pickGrid: pickGrid };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "One-line setup for Calo design tooling: logs in with your Calo email and installs the calo-design skill + design-system packages. No GitHub account needed.",
5
5
  "bin": {
6
6
  "calo-design": "bin/cli.js"
@@ -16,6 +16,8 @@
16
16
  "node": ">=18"
17
17
  },
18
18
  "dependencies": {
19
+ "jpeg-js": "^0.4.4",
20
+ "pngjs": "^7.0.0",
19
21
  "qrcode-terminal": "^0.12.0"
20
22
  }
21
23
  }