picture-it 0.2.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/README.md +243 -0
- package/hero.png +0 -0
- package/index.ts +493 -0
- package/package.json +60 -0
- package/scripts/download-fonts.ts +14 -0
- package/src/compositor.ts +614 -0
- package/src/config.ts +102 -0
- package/src/contrast.ts +155 -0
- package/src/fal.ts +218 -0
- package/src/fonts.ts +165 -0
- package/src/model-router.ts +78 -0
- package/src/operations.ts +85 -0
- package/src/pipeline.ts +243 -0
- package/src/postprocess.ts +124 -0
- package/src/presets.ts +105 -0
- package/src/satori-jsx.ts +17 -0
- package/src/templates/index.ts +457 -0
- package/src/types.ts +226 -0
- package/src/zones.ts +63 -0
package/index.ts
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
import { log, parseSize, readInput, writeOutput, ensureFalKey } from "./src/operations.ts";
|
|
7
|
+
import { configureFal, generate, edit, removeBg, upscale, uploadFile, uploadBuffer, cropToExact } from "./src/fal.ts";
|
|
8
|
+
import { composite } from "./src/compositor.ts";
|
|
9
|
+
import { applyColorGrade, applyGrain, applyVignette } from "./src/postprocess.ts";
|
|
10
|
+
import { executePipeline, createGradientBackground } from "./src/pipeline.ts";
|
|
11
|
+
import { getTemplate } from "./src/templates/index.ts";
|
|
12
|
+
import { checkAndFixContrast } from "./src/contrast.ts";
|
|
13
|
+
import { PLATFORM_PRESETS } from "./src/presets.ts";
|
|
14
|
+
import { setConfigValue, getConfigValue, listConfig, clearConfig, maskKey, getKeySource } from "./src/config.ts";
|
|
15
|
+
import { downloadFonts, getFontDirectory } from "./src/fonts.ts";
|
|
16
|
+
import type { ColorGrade, Overlay, PipelineStep, BatchEntry } from "./src/types.ts";
|
|
17
|
+
|
|
18
|
+
const program = new Command();
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.name("picture-it")
|
|
22
|
+
.description("Photoshop for AI agents — composable image operations")
|
|
23
|
+
.version("0.2.0");
|
|
24
|
+
|
|
25
|
+
// ─── GENERATE ─────────────────────────────────────────────
|
|
26
|
+
program
|
|
27
|
+
.command("generate")
|
|
28
|
+
.description("Generate an image from a text prompt")
|
|
29
|
+
.requiredOption("--prompt <text>", "Image description")
|
|
30
|
+
.option("--model <name>", "FAL model (flux-schnell, flux-dev, seedream)")
|
|
31
|
+
.option("--size <WxH>", "Output dimensions")
|
|
32
|
+
.option("--platform <name>", "Platform preset for size")
|
|
33
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
34
|
+
.option("--verbose", "Detailed output")
|
|
35
|
+
.action(async (opts) => {
|
|
36
|
+
const falKey = ensureFalKey();
|
|
37
|
+
configureFal(falKey);
|
|
38
|
+
const { width, height } = parseSize(opts.size, opts.platform);
|
|
39
|
+
const buf = await generate({ prompt: opts.prompt, model: opts.model, width, height, verbose: opts.verbose });
|
|
40
|
+
const cropped = await cropToExact(buf, width, height);
|
|
41
|
+
const out = await writeOutput(cropped, opts.output);
|
|
42
|
+
console.log(out);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ─── EDIT ─────────────────────────────────────────────────
|
|
46
|
+
program
|
|
47
|
+
.command("edit")
|
|
48
|
+
.description("Edit images using AI — the primary command")
|
|
49
|
+
.requiredOption("--prompt <text>", "Edit instructions")
|
|
50
|
+
.requiredOption("-i, --input <paths...>", "Input image(s)")
|
|
51
|
+
.option("--model <name>", "FAL model (seedream, banana2, banana-pro)")
|
|
52
|
+
.option("--size <WxH>", "Output dimensions")
|
|
53
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
54
|
+
.option("--verbose", "Detailed output")
|
|
55
|
+
.action(async (opts) => {
|
|
56
|
+
const falKey = ensureFalKey();
|
|
57
|
+
configureFal(falKey);
|
|
58
|
+
|
|
59
|
+
const inputs = opts.input as string[];
|
|
60
|
+
for (const f of inputs) {
|
|
61
|
+
if (!fs.existsSync(f)) { log(`Not found: ${f}`); process.exit(1); }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (opts.verbose) log(`Uploading ${inputs.length} image(s)...`);
|
|
65
|
+
const urls = await Promise.all(inputs.map((f: string) => uploadFile(path.resolve(f))));
|
|
66
|
+
|
|
67
|
+
const meta = await sharp(inputs[0]!).metadata();
|
|
68
|
+
const { width, height } = opts.size
|
|
69
|
+
? parseSize(opts.size)
|
|
70
|
+
: { width: meta.width || 1200, height: meta.height || 630 };
|
|
71
|
+
|
|
72
|
+
const buf = await edit({ inputUrls: urls, prompt: opts.prompt, model: opts.model, width, height, verbose: opts.verbose });
|
|
73
|
+
const cropped = await cropToExact(buf, width, height);
|
|
74
|
+
const out = await writeOutput(cropped, opts.output);
|
|
75
|
+
console.log(out);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── REMOVE-BG ───────────────────────────────────────────
|
|
79
|
+
program
|
|
80
|
+
.command("remove-bg")
|
|
81
|
+
.description("Remove background from an image")
|
|
82
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
83
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
84
|
+
.option("--verbose", "Detailed output")
|
|
85
|
+
.action(async (opts) => {
|
|
86
|
+
const falKey = ensureFalKey();
|
|
87
|
+
configureFal(falKey);
|
|
88
|
+
const url = await uploadFile(path.resolve(opts.input));
|
|
89
|
+
const buf = await removeBg({ inputUrl: url, verbose: opts.verbose });
|
|
90
|
+
const out = await writeOutput(buf, opts.output);
|
|
91
|
+
console.log(out);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ─── REPLACE-BG ──────────────────────────────────────────
|
|
95
|
+
program
|
|
96
|
+
.command("replace-bg")
|
|
97
|
+
.description("Remove background and generate a new one")
|
|
98
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
99
|
+
.requiredOption("--prompt <text>", "New background description")
|
|
100
|
+
.option("--model <name>", "FAL model for new background")
|
|
101
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
102
|
+
.option("--verbose", "Detailed output")
|
|
103
|
+
.action(async (opts) => {
|
|
104
|
+
const falKey = ensureFalKey();
|
|
105
|
+
configureFal(falKey);
|
|
106
|
+
|
|
107
|
+
const inputBuf = await readInput(opts.input);
|
|
108
|
+
const meta = await sharp(inputBuf).metadata();
|
|
109
|
+
const w = meta.width || 1200;
|
|
110
|
+
const h = meta.height || 630;
|
|
111
|
+
|
|
112
|
+
if (opts.verbose) log("Removing background...");
|
|
113
|
+
const url = await uploadBuffer(inputBuf, "input.png");
|
|
114
|
+
const cutout = await removeBg({ inputUrl: url, verbose: opts.verbose });
|
|
115
|
+
|
|
116
|
+
if (opts.verbose) log("Generating new background...");
|
|
117
|
+
const bg = await generate({ prompt: opts.prompt, model: opts.model, width: w, height: h, verbose: opts.verbose });
|
|
118
|
+
const bgCropped = await cropToExact(bg, w, h);
|
|
119
|
+
|
|
120
|
+
if (opts.verbose) log("Compositing...");
|
|
121
|
+
const result = await sharp(bgCropped)
|
|
122
|
+
.composite([{ input: cutout, blend: "over" }])
|
|
123
|
+
.png()
|
|
124
|
+
.toBuffer();
|
|
125
|
+
|
|
126
|
+
const out = await writeOutput(result, opts.output);
|
|
127
|
+
console.log(out);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ─── UPSCALE ─────────────────────────────────────────────
|
|
131
|
+
program
|
|
132
|
+
.command("upscale")
|
|
133
|
+
.description("AI upscale an image")
|
|
134
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
135
|
+
.option("--scale <n>", "Scale factor", "2")
|
|
136
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
137
|
+
.option("--verbose", "Detailed output")
|
|
138
|
+
.action(async (opts) => {
|
|
139
|
+
const falKey = ensureFalKey();
|
|
140
|
+
configureFal(falKey);
|
|
141
|
+
const url = await uploadFile(path.resolve(opts.input));
|
|
142
|
+
const buf = await upscale({ inputUrl: url, scale: parseInt(opts.scale), verbose: opts.verbose });
|
|
143
|
+
const out = await writeOutput(buf, opts.output);
|
|
144
|
+
console.log(out);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ─── CROP ────────────────────────────────────────────────
|
|
148
|
+
program
|
|
149
|
+
.command("crop")
|
|
150
|
+
.description("Crop/resize an image to exact dimensions")
|
|
151
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
152
|
+
.requiredOption("--size <WxH>", "Target dimensions")
|
|
153
|
+
.option("--position <pos>", "Crop position (attention, center, entropy, top, bottom)", "attention")
|
|
154
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
155
|
+
.action(async (opts) => {
|
|
156
|
+
const inputBuf = await readInput(opts.input);
|
|
157
|
+
const { width, height } = parseSize(opts.size);
|
|
158
|
+
const buf = await cropToExact(inputBuf, width, height, opts.position);
|
|
159
|
+
const out = await writeOutput(buf, opts.output);
|
|
160
|
+
console.log(out);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── GRADE ───────────────────────────────────────────────
|
|
164
|
+
program
|
|
165
|
+
.command("grade")
|
|
166
|
+
.description("Apply color grading")
|
|
167
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
168
|
+
.requiredOption("--name <grade>", "Grade: cinematic, moody, vibrant, clean, warm-editorial, cool-tech")
|
|
169
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
170
|
+
.action(async (opts) => {
|
|
171
|
+
const buf = await applyColorGrade(await readInput(opts.input), opts.name as ColorGrade);
|
|
172
|
+
const out = await writeOutput(buf, opts.output);
|
|
173
|
+
console.log(out);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ─── GRAIN ───────────────────────────────────────────────
|
|
177
|
+
program
|
|
178
|
+
.command("grain")
|
|
179
|
+
.description("Add film grain")
|
|
180
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
181
|
+
.option("--intensity <n>", "Grain intensity 0-1", "0.07")
|
|
182
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
183
|
+
.action(async (opts) => {
|
|
184
|
+
const buf = await applyGrain(await readInput(opts.input), parseFloat(opts.intensity));
|
|
185
|
+
const out = await writeOutput(buf, opts.output);
|
|
186
|
+
console.log(out);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ─── VIGNETTE ────────────────────────────────────────────
|
|
190
|
+
program
|
|
191
|
+
.command("vignette")
|
|
192
|
+
.description("Add edge vignette")
|
|
193
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
194
|
+
.option("--opacity <n>", "Vignette opacity 0-1", "0.35")
|
|
195
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
196
|
+
.action(async (opts) => {
|
|
197
|
+
const buf = await applyVignette(await readInput(opts.input), parseFloat(opts.opacity));
|
|
198
|
+
const out = await writeOutput(buf, opts.output);
|
|
199
|
+
console.log(out);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ─── TEXT ────────────────────────────────────────────────
|
|
203
|
+
program
|
|
204
|
+
.command("text")
|
|
205
|
+
.description("Render text onto an image using Satori")
|
|
206
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
207
|
+
.option("--title <text>", "Text to render (simple mode)")
|
|
208
|
+
.option("--font <name>", "Font family", "Space Grotesk")
|
|
209
|
+
.option("--color <color>", "Text color", "white")
|
|
210
|
+
.option("--font-size <n>", "Font size in pixels", "64")
|
|
211
|
+
.option("--zone <name>", "Position zone", "hero-center")
|
|
212
|
+
.option("--jsx <path>", "JSX overlay JSON file (advanced mode)")
|
|
213
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
214
|
+
.option("--verbose", "Detailed output")
|
|
215
|
+
.action(async (opts) => {
|
|
216
|
+
const inputBuf = await readInput(opts.input);
|
|
217
|
+
const meta = await sharp(inputBuf).metadata();
|
|
218
|
+
const w = meta.width || 1200;
|
|
219
|
+
const h = meta.height || 630;
|
|
220
|
+
|
|
221
|
+
let overlays: Overlay[];
|
|
222
|
+
|
|
223
|
+
if (opts.jsx) {
|
|
224
|
+
overlays = JSON.parse(fs.readFileSync(path.resolve(opts.jsx), "utf-8"));
|
|
225
|
+
} else if (opts.title) {
|
|
226
|
+
overlays = [{
|
|
227
|
+
type: "satori-text" as const,
|
|
228
|
+
jsx: {
|
|
229
|
+
tag: "div",
|
|
230
|
+
props: {
|
|
231
|
+
style: { display: "flex", alignItems: "center", justifyContent: "center", width: "100%", height: "100%" },
|
|
232
|
+
},
|
|
233
|
+
children: [{
|
|
234
|
+
tag: "span",
|
|
235
|
+
props: {
|
|
236
|
+
style: {
|
|
237
|
+
fontSize: parseInt(opts.fontSize),
|
|
238
|
+
fontFamily: opts.font,
|
|
239
|
+
fontWeight: 700,
|
|
240
|
+
color: opts.color,
|
|
241
|
+
textShadow: "0 2px 10px rgba(0,0,0,0.5)",
|
|
242
|
+
textAlign: "center",
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
children: [opts.title],
|
|
246
|
+
}],
|
|
247
|
+
},
|
|
248
|
+
zone: opts.zone,
|
|
249
|
+
width: Math.round(w * 0.9),
|
|
250
|
+
height: Math.round(h * 0.3),
|
|
251
|
+
anchor: "center" as const,
|
|
252
|
+
depth: "overlay" as const,
|
|
253
|
+
}];
|
|
254
|
+
} else {
|
|
255
|
+
log("Provide --title or --jsx");
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const result = await composite(inputBuf, overlays, w, h, process.cwd(), opts.verbose);
|
|
260
|
+
const out = await writeOutput(result, opts.output);
|
|
261
|
+
console.log(out);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ─── COMPOSE ─────────────────────────────────────────────
|
|
265
|
+
program
|
|
266
|
+
.command("compose")
|
|
267
|
+
.description("Composite overlays onto a background image")
|
|
268
|
+
.requiredOption("-i, --input <path>", "Background image")
|
|
269
|
+
.requiredOption("--overlays <path>", "Overlays JSON file")
|
|
270
|
+
.option("--size <WxH>", "Output dimensions")
|
|
271
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
272
|
+
.option("--verbose", "Detailed output")
|
|
273
|
+
.action(async (opts) => {
|
|
274
|
+
const bgBuf = await readInput(opts.input);
|
|
275
|
+
const bgMeta = await sharp(bgBuf).metadata();
|
|
276
|
+
const { width, height } = opts.size
|
|
277
|
+
? parseSize(opts.size)
|
|
278
|
+
: { width: bgMeta.width || 1200, height: bgMeta.height || 630 };
|
|
279
|
+
|
|
280
|
+
const overlays: Overlay[] = JSON.parse(fs.readFileSync(path.resolve(opts.overlays), "utf-8"));
|
|
281
|
+
const result = await composite(bgBuf, overlays, width, height, process.cwd(), opts.verbose);
|
|
282
|
+
const out = await writeOutput(result, opts.output);
|
|
283
|
+
console.log(out);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ─── TEMPLATE ────────────────────────────────────────────
|
|
287
|
+
program
|
|
288
|
+
.command("template <name>")
|
|
289
|
+
.description("Generate from a built-in template (no AI)")
|
|
290
|
+
.option("--platform <name>", "Platform preset", "blog-featured")
|
|
291
|
+
.option("--size <WxH>", "Output dimensions")
|
|
292
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
293
|
+
.option("--verbose", "Detailed output")
|
|
294
|
+
.option("--left-logo <path>", "Left logo")
|
|
295
|
+
.option("--right-logo <path>", "Right logo")
|
|
296
|
+
.option("--logo <path>", "Logo asset")
|
|
297
|
+
.option("--title <text>", "Title text")
|
|
298
|
+
.option("--subtitle <text>", "Subtitle")
|
|
299
|
+
.option("--badge <text>", "Badge text")
|
|
300
|
+
.option("--glow-color <hex>", "Glow color")
|
|
301
|
+
.option("--vs-text <text>", "VS text")
|
|
302
|
+
.option("--left-label <text>", "Left label")
|
|
303
|
+
.option("--right-label <text>", "Right label")
|
|
304
|
+
.option("--text-color <color>", "Text color")
|
|
305
|
+
.option("--background <css>", "CSS gradient")
|
|
306
|
+
.option("--site-name <text>", "Site name")
|
|
307
|
+
.option("--author-name <text>", "Author name")
|
|
308
|
+
.option("--description <text>", "Description")
|
|
309
|
+
.option("--position <pos>", "Position")
|
|
310
|
+
.action(async (name, opts) => {
|
|
311
|
+
const tmpl = getTemplate(name);
|
|
312
|
+
if (!tmpl) {
|
|
313
|
+
log(`Unknown template: ${name}. Available: vs-comparison, feature-hero, text-hero, social-card`);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { width, height } = parseSize(opts.size, opts.platform);
|
|
318
|
+
|
|
319
|
+
const data: Record<string, unknown> = {};
|
|
320
|
+
for (const [key, value] of Object.entries(opts)) {
|
|
321
|
+
if (value !== undefined && typeof value !== "function") {
|
|
322
|
+
data[key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())] = value;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const result = tmpl(data, width, height);
|
|
327
|
+
const bgBuffer = await createGradientBackground(result.background, width, height);
|
|
328
|
+
const output = await composite(bgBuffer, result.overlays, width, height, process.cwd(), opts.verbose);
|
|
329
|
+
const out = await writeOutput(output, opts.output);
|
|
330
|
+
console.log(out);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─── PIPELINE ────────────────────────────────────────────
|
|
334
|
+
program
|
|
335
|
+
.command("pipeline")
|
|
336
|
+
.description("Execute a multi-step pipeline from JSON")
|
|
337
|
+
.requiredOption("--spec <path>", "Pipeline JSON file")
|
|
338
|
+
.option("-o, --output <path>", "Output file", "output.png")
|
|
339
|
+
.option("--verbose", "Detailed output")
|
|
340
|
+
.action(async (opts) => {
|
|
341
|
+
const specPath = path.resolve(opts.spec);
|
|
342
|
+
if (!fs.existsSync(specPath)) { log(`Not found: ${specPath}`); process.exit(1); }
|
|
343
|
+
const steps: PipelineStep[] = JSON.parse(fs.readFileSync(specPath, "utf-8"));
|
|
344
|
+
const out = await executePipeline(steps, opts.output, opts.verbose);
|
|
345
|
+
console.log(out);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ─── BATCH ───────────────────────────────────────────────
|
|
349
|
+
program
|
|
350
|
+
.command("batch")
|
|
351
|
+
.description("Execute multiple pipelines from JSON")
|
|
352
|
+
.requiredOption("--spec <path>", "Batch spec JSON file")
|
|
353
|
+
.option("--output-dir <dir>", "Output directory", ".")
|
|
354
|
+
.option("--verbose", "Detailed output")
|
|
355
|
+
.action(async (opts) => {
|
|
356
|
+
const specPath = path.resolve(opts.spec);
|
|
357
|
+
if (!fs.existsSync(specPath)) { log(`Not found: ${specPath}`); process.exit(1); }
|
|
358
|
+
|
|
359
|
+
const entries: BatchEntry[] = JSON.parse(fs.readFileSync(specPath, "utf-8"));
|
|
360
|
+
const outputDir = path.resolve(opts.outputDir);
|
|
361
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
362
|
+
|
|
363
|
+
const results: string[] = [];
|
|
364
|
+
for (const entry of entries) {
|
|
365
|
+
const outputPath = path.resolve(outputDir, entry.output || `${entry.id}.png`);
|
|
366
|
+
try {
|
|
367
|
+
await executePipeline(entry.pipeline, outputPath, opts.verbose);
|
|
368
|
+
results.push(outputPath);
|
|
369
|
+
if (opts.verbose) log(`Done: ${outputPath}`);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
log(`Failed ${entry.id}: ${(e as Error).message}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
console.log(JSON.stringify(results));
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ─── INFO ────────────────────────────────────────────────
|
|
378
|
+
program
|
|
379
|
+
.command("info")
|
|
380
|
+
.description("Analyze an image — dimensions, colors, content type")
|
|
381
|
+
.requiredOption("-i, --input <path>", "Input image")
|
|
382
|
+
.action(async (opts) => {
|
|
383
|
+
const inputPath = path.resolve(opts.input);
|
|
384
|
+
if (!fs.existsSync(inputPath)) { log(`Not found: ${inputPath}`); process.exit(1); }
|
|
385
|
+
|
|
386
|
+
const img = sharp(inputPath);
|
|
387
|
+
const meta = await img.metadata();
|
|
388
|
+
const stats = await img.stats();
|
|
389
|
+
const fileStat = fs.statSync(inputPath);
|
|
390
|
+
|
|
391
|
+
const w = meta.width || 0;
|
|
392
|
+
const h = meta.height || 0;
|
|
393
|
+
const ratio = w / (h || 1);
|
|
394
|
+
const hasAlpha = meta.hasAlpha === true && meta.channels === 4;
|
|
395
|
+
|
|
396
|
+
// Dominant colors via tiny resize
|
|
397
|
+
const { data } = await img.resize(8, 8, { fit: "cover" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
398
|
+
const colorCounts = new Map<string, number>();
|
|
399
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
400
|
+
if (data[i + 3]! < 128) continue;
|
|
401
|
+
const hex = "#" + [data[i]!, data[i + 1]!, data[i + 2]!]
|
|
402
|
+
.map(c => (Math.round(c / 32) * 32).toString(16).padStart(2, "0")).join("");
|
|
403
|
+
colorCounts.set(hex, (colorCounts.get(hex) || 0) + 1);
|
|
404
|
+
}
|
|
405
|
+
const colors = [...colorCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([h]) => h);
|
|
406
|
+
|
|
407
|
+
let contentType = "photo";
|
|
408
|
+
if (Math.abs(ratio - 1) < 0.15 && hasAlpha) contentType = w <= 256 ? "avatar" : "icon";
|
|
409
|
+
else if (Math.abs(ratio - 1) < 0.15 && !hasAlpha && w <= 256) contentType = "avatar";
|
|
410
|
+
else if (ratio > 1.3 && !hasAlpha) contentType = "screenshot";
|
|
411
|
+
else if (hasAlpha) contentType = "cutout";
|
|
412
|
+
|
|
413
|
+
console.log(JSON.stringify({
|
|
414
|
+
path: inputPath,
|
|
415
|
+
filename: path.basename(inputPath),
|
|
416
|
+
width: w,
|
|
417
|
+
height: h,
|
|
418
|
+
aspectRatio: Math.round(ratio * 100) / 100,
|
|
419
|
+
format: meta.format,
|
|
420
|
+
hasTransparency: hasAlpha,
|
|
421
|
+
dominantColors: colors,
|
|
422
|
+
contentType,
|
|
423
|
+
sizeBytes: fileStat.size,
|
|
424
|
+
}, null, 2));
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ─── DOWNLOAD-FONTS ─────────────────────────────────────
|
|
428
|
+
program
|
|
429
|
+
.command("download-fonts")
|
|
430
|
+
.description("Download fonts required for text and template commands")
|
|
431
|
+
.option("--force", "Redownload existing font files")
|
|
432
|
+
.action(async (opts) => {
|
|
433
|
+
const result = await downloadFonts({
|
|
434
|
+
force: opts.force,
|
|
435
|
+
onProgress: (message) => log(message),
|
|
436
|
+
});
|
|
437
|
+
log(`Fonts ready in ${result.dir}`);
|
|
438
|
+
log("Text, compose, and template commands can use them immediately.");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
// ─── AUTH ────────────────────────────────────────────────
|
|
442
|
+
program
|
|
443
|
+
.command("auth")
|
|
444
|
+
.description("Configure API keys")
|
|
445
|
+
.option("--fal <key>", "Set FAL API key")
|
|
446
|
+
.option("--status", "Show key status")
|
|
447
|
+
.option("--clear", "Remove all keys")
|
|
448
|
+
.action(async (opts) => {
|
|
449
|
+
if (opts.status) {
|
|
450
|
+
const falInfo = getKeySource("fal_key");
|
|
451
|
+
log(falInfo ? `FAL_KEY: ${maskKey(falInfo.value)} (${falInfo.source}) ✓` : "FAL_KEY: not configured");
|
|
452
|
+
log(`Fonts: ${getFontDirectory()}`);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
if (opts.clear) { clearConfig(); log("Keys cleared."); return; }
|
|
456
|
+
if (opts.fal) { setConfigValue("fal_key", opts.fal); log(`FAL key saved: ${maskKey(opts.fal)}`); return; }
|
|
457
|
+
|
|
458
|
+
// Interactive
|
|
459
|
+
const readline = await import("readline");
|
|
460
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
461
|
+
const ask = (q: string): Promise<string> => new Promise((resolve) => rl.question(q, resolve));
|
|
462
|
+
const falKey = await ask("FAL API key: ");
|
|
463
|
+
if (falKey.trim()) { setConfigValue("fal_key", falKey.trim()); log(`FAL key saved: ${maskKey(falKey.trim())}`); }
|
|
464
|
+
rl.close();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ─── CONFIG ──────────────────────────────────────────────
|
|
468
|
+
program
|
|
469
|
+
.command("config")
|
|
470
|
+
.description("Manage configuration")
|
|
471
|
+
.argument("<action>", "set, get, or list")
|
|
472
|
+
.argument("[key]", "Config key")
|
|
473
|
+
.argument("[value]", "Config value")
|
|
474
|
+
.action((action, key, value) => {
|
|
475
|
+
switch (action) {
|
|
476
|
+
case "set":
|
|
477
|
+
if (!key || !value) { log("Usage: picture-it config set <key> <value>"); process.exit(1); }
|
|
478
|
+
setConfigValue(key, value); log(`Set ${key} = ${value}`); break;
|
|
479
|
+
case "get":
|
|
480
|
+
if (!key) { log("Usage: picture-it config get <key>"); process.exit(1); }
|
|
481
|
+
const val = getConfigValue(key);
|
|
482
|
+
val ? console.log(val) : log(`${key} not set`); break;
|
|
483
|
+
case "list": {
|
|
484
|
+
const cfg = listConfig();
|
|
485
|
+
for (const [k, v] of Object.entries(cfg)) {
|
|
486
|
+
log(k.includes("key") ? `${k}: ${maskKey(v as string)}` : `${k}: ${v}`);
|
|
487
|
+
} break;
|
|
488
|
+
}
|
|
489
|
+
default: log(`Unknown: ${action}. Use set, get, or list.`); process.exit(1);
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "picture-it",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Photoshop for AI agents. Composable image operations from the CLI.",
|
|
5
|
+
"module": "./index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"picture-it": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"src",
|
|
13
|
+
"scripts",
|
|
14
|
+
"README.md",
|
|
15
|
+
"hero.png"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "bun run index.ts",
|
|
19
|
+
"download-fonts": "bun run scripts/download-fonts.ts",
|
|
20
|
+
"publish:dry-run": "bun publish --dry-run"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/geongeorge/picture-it.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/geongeorge/picture-it#readme",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/geongeorge/picture-it/issues"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"ai",
|
|
32
|
+
"image",
|
|
33
|
+
"cli",
|
|
34
|
+
"graphics",
|
|
35
|
+
"open-graph",
|
|
36
|
+
"social-images",
|
|
37
|
+
"satori",
|
|
38
|
+
"fal"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.3.0"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/bun": "latest",
|
|
48
|
+
"@types/node": "^25.5.2"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": "^5"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@fal-ai/client": "^1.9.5",
|
|
55
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
56
|
+
"commander": "^14.0.3",
|
|
57
|
+
"satori": "^0.26.0",
|
|
58
|
+
"sharp": "^0.34.5"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { downloadFonts } from "../src/fonts.ts";
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const result = await downloadFonts({
|
|
6
|
+
onProgress: (message) => console.log(message),
|
|
7
|
+
});
|
|
8
|
+
console.log(`\nFonts ready in ${result.dir}.`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
main().catch((e) => {
|
|
12
|
+
console.error(e);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|