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/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
+ });