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.
@@ -0,0 +1,614 @@
1
+ import sharp, { type OverlayOptions } from "sharp";
2
+ import { Resvg } from "@resvg/resvg-js";
3
+ import satori from "satori";
4
+ import path from "path";
5
+ import { resolvePosition, resolveDimension } from "./zones.ts";
6
+ import { DEPTH_ORDER, AUTO_SHADOW } from "./presets.ts";
7
+ import { loadFonts } from "./fonts.ts";
8
+ import { jsxToReact } from "./satori-jsx.ts";
9
+ import type {
10
+ Overlay,
11
+ ImageOverlay,
12
+ SatoriTextOverlay,
13
+ ShapeOverlay,
14
+ GradientOverlay,
15
+ WatermarkOverlay,
16
+ DepthLayer,
17
+ ShadowConfig,
18
+ } from "./types.ts";
19
+ import { log } from "./operations.ts";
20
+
21
+ export async function composite(
22
+ baseImage: Buffer,
23
+ overlays: Overlay[],
24
+ width: number,
25
+ height: number,
26
+ assetDir: string,
27
+ verbose = false
28
+ ): Promise<Buffer> {
29
+ let canvasBuffer = await sharp(baseImage)
30
+ .resize(width, height, { fit: "cover", position: "center" })
31
+ .png()
32
+ .toBuffer();
33
+
34
+ // Sort overlays by depth
35
+ const sorted = [...overlays].sort((a, b) => {
36
+ const da = DEPTH_ORDER[a.depth || "overlay"] ?? 3;
37
+ const db = DEPTH_ORDER[b.depth || "overlay"] ?? 3;
38
+ return da - db;
39
+ });
40
+
41
+ for (const overlay of sorted) {
42
+ if (verbose) log(`Compositing ${overlay.type} at depth ${overlay.depth || "overlay"}`);
43
+
44
+ canvasBuffer = await compositeOverlay(
45
+ canvasBuffer,
46
+ overlay,
47
+ width,
48
+ height,
49
+ assetDir,
50
+ verbose
51
+ );
52
+ }
53
+
54
+ return canvasBuffer;
55
+ }
56
+
57
+ async function compositeOverlay(
58
+ canvasBuffer: Buffer,
59
+ overlay: Overlay,
60
+ canvasWidth: number,
61
+ canvasHeight: number,
62
+ assetDir: string,
63
+ verbose: boolean
64
+ ): Promise<Buffer> {
65
+ switch (overlay.type) {
66
+ case "image":
67
+ return compositeImage(canvasBuffer, overlay, canvasWidth, canvasHeight, assetDir, verbose);
68
+ case "satori-text":
69
+ return compositeSatoriText(canvasBuffer, overlay, canvasWidth, canvasHeight, verbose);
70
+ case "shape":
71
+ return compositeShape(canvasBuffer, overlay, canvasWidth, canvasHeight, verbose);
72
+ case "gradient-overlay":
73
+ return compositeGradient(canvasBuffer, overlay, canvasWidth, canvasHeight, verbose);
74
+ case "watermark":
75
+ return compositeWatermark(canvasBuffer, overlay, canvasWidth, canvasHeight, assetDir, verbose);
76
+ }
77
+ }
78
+
79
+ async function compositeImage(
80
+ canvasBuffer: Buffer,
81
+ overlay: ImageOverlay,
82
+ canvasWidth: number,
83
+ canvasHeight: number,
84
+ assetDir: string,
85
+ verbose: boolean
86
+ ): Promise<Buffer> {
87
+ const assetPath = path.resolve(assetDir, overlay.src);
88
+ let asset = sharp(assetPath);
89
+ const meta = await asset.metadata();
90
+ const origW = meta.width || 100;
91
+ const origH = meta.height || 100;
92
+
93
+ // Resolve target dimensions
94
+ let targetW = resolveDimension(overlay.width, canvasWidth, origW);
95
+ let targetH = resolveDimension(overlay.height, canvasHeight, origH);
96
+
97
+ // If only one dimension specified, maintain aspect ratio
98
+ if (overlay.width && !overlay.height) {
99
+ targetH = Math.round(targetW * (origH / origW));
100
+ } else if (overlay.height && !overlay.width) {
101
+ targetW = Math.round(targetH * (origW / origH));
102
+ }
103
+
104
+ // Apply mask
105
+ if (overlay.mask) {
106
+ asset = await applyMask(asset, targetW, targetH, overlay.mask);
107
+ }
108
+
109
+ // Apply border radius
110
+ if (overlay.borderRadius) {
111
+ asset = await applyBorderRadius(asset, targetW, targetH, overlay.borderRadius);
112
+ }
113
+
114
+ // Resize
115
+ asset = asset.resize(targetW, targetH, { fit: "cover" });
116
+
117
+ // Apply rotation
118
+ if (overlay.rotation) {
119
+ asset = asset.rotate(overlay.rotation, { background: { r: 0, g: 0, b: 0, alpha: 0 } });
120
+ }
121
+
122
+ let assetBuffer = await asset.png().toBuffer();
123
+
124
+ // Calculate position
125
+ const pos = resolvePosition(
126
+ overlay.zone || "hero-center",
127
+ canvasWidth,
128
+ canvasHeight,
129
+ targetW,
130
+ targetH,
131
+ overlay.anchor || "center"
132
+ );
133
+
134
+ const composites: OverlayOptions[] = [];
135
+
136
+ // Shadow
137
+ if (overlay.shadow) {
138
+ const shadowConfig = resolveShadow(overlay.shadow, overlay.depth);
139
+ if (shadowConfig) {
140
+ const shadowBuf = await createShadow(assetBuffer, targetW, targetH, shadowConfig);
141
+ composites.push({
142
+ input: shadowBuf,
143
+ left: Math.max(0, pos.x + shadowConfig.offsetX),
144
+ top: Math.max(0, pos.y + shadowConfig.offsetY),
145
+ blend: "over",
146
+ });
147
+ }
148
+ }
149
+
150
+ // Glow
151
+ if (overlay.glow) {
152
+ const glowBuf = await createGlow(assetBuffer, targetW, targetH, overlay.glow);
153
+ const glowExtend = overlay.glow.spread + overlay.glow.blur;
154
+ composites.push({
155
+ input: glowBuf,
156
+ left: Math.max(0, pos.x - glowExtend),
157
+ top: Math.max(0, pos.y - glowExtend),
158
+ blend: "over",
159
+ });
160
+ }
161
+
162
+ // Reflection
163
+ if (overlay.reflection) {
164
+ const reflBuf = await createReflection(assetBuffer, targetW, targetH, overlay.reflection);
165
+ composites.push({
166
+ input: reflBuf,
167
+ left: pos.x,
168
+ top: pos.y + targetH + 2,
169
+ blend: "over",
170
+ });
171
+ }
172
+
173
+ // Main image
174
+ const opacity = overlay.opacity ?? 1;
175
+ if (opacity < 1) {
176
+ assetBuffer = await applyOpacity(assetBuffer, opacity);
177
+ }
178
+
179
+ composites.push({
180
+ input: assetBuffer,
181
+ left: Math.max(0, pos.x),
182
+ top: Math.max(0, pos.y),
183
+ blend: "over",
184
+ });
185
+
186
+ return sharp(canvasBuffer)
187
+ .composite(composites)
188
+ .png()
189
+ .toBuffer();
190
+ }
191
+
192
+ async function compositeSatoriText(
193
+ canvasBuffer: Buffer,
194
+ overlay: SatoriTextOverlay,
195
+ canvasWidth: number,
196
+ canvasHeight: number,
197
+ verbose: boolean
198
+ ): Promise<Buffer> {
199
+ const textW = overlay.width || canvasWidth;
200
+ const textH = overlay.height || canvasHeight;
201
+
202
+ const fonts = await loadFonts();
203
+ const reactElement = jsxToReact(overlay.jsx);
204
+
205
+ const svg = await satori(reactElement, {
206
+ width: textW,
207
+ height: textH,
208
+ fonts,
209
+ });
210
+
211
+ const resvg = new Resvg(svg, {
212
+ fitTo: { mode: "width", value: textW },
213
+ });
214
+ const pngBuffer = resvg.render().asPng();
215
+
216
+ const pos = resolvePosition(
217
+ overlay.zone || "title-area",
218
+ canvasWidth,
219
+ canvasHeight,
220
+ textW,
221
+ textH,
222
+ overlay.anchor || "center"
223
+ );
224
+
225
+ let input: Buffer = Buffer.from(pngBuffer);
226
+ const opacity = overlay.opacity ?? 1;
227
+ if (opacity < 1) {
228
+ input = await applyOpacity(input, opacity);
229
+ }
230
+
231
+ return sharp(canvasBuffer)
232
+ .composite([
233
+ {
234
+ input,
235
+ left: Math.max(0, pos.x),
236
+ top: Math.max(0, pos.y),
237
+ blend: "over",
238
+ },
239
+ ])
240
+ .png()
241
+ .toBuffer();
242
+ }
243
+
244
+ async function compositeShape(
245
+ canvasBuffer: Buffer,
246
+ overlay: ShapeOverlay,
247
+ canvasWidth: number,
248
+ canvasHeight: number,
249
+ verbose: boolean
250
+ ): Promise<Buffer> {
251
+ const w = overlay.width || 100;
252
+ const h = overlay.height || 100;
253
+
254
+ let svgContent = "";
255
+ const fill = overlay.fill || "white";
256
+ const stroke = overlay.stroke || "none";
257
+ const strokeW = overlay.strokeWidth || 0;
258
+
259
+ switch (overlay.shape) {
260
+ case "rect":
261
+ svgContent = `<rect x="${strokeW / 2}" y="${strokeW / 2}" width="${w - strokeW}" height="${h - strokeW}" rx="${overlay.borderRadius || 0}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}"/>`;
262
+ break;
263
+ case "circle":
264
+ svgContent = `<ellipse cx="${w / 2}" cy="${h / 2}" rx="${(w - strokeW) / 2}" ry="${(h - strokeW) / 2}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeW}"/>`;
265
+ break;
266
+ case "line":
267
+ if (overlay.from && overlay.to) {
268
+ svgContent = `<line x1="${overlay.from.x}" y1="${overlay.from.y}" x2="${overlay.to.x}" y2="${overlay.to.y}" stroke="${stroke || fill}" stroke-width="${strokeW || 2}"/>`;
269
+ }
270
+ break;
271
+ case "arrow":
272
+ if (overlay.from && overlay.to) {
273
+ const headSize = overlay.headSize || 10;
274
+ svgContent = `
275
+ <defs><marker id="ah" markerWidth="${headSize}" markerHeight="${headSize}" refX="${headSize}" refY="${headSize / 2}" orient="auto">
276
+ <polygon points="0 0, ${headSize} ${headSize / 2}, 0 ${headSize}" fill="${stroke || fill}" />
277
+ </marker></defs>
278
+ <line x1="${overlay.from.x}" y1="${overlay.from.y}" x2="${overlay.to.x}" y2="${overlay.to.y}" stroke="${stroke || fill}" stroke-width="${strokeW || 2}" marker-end="url(#ah)"/>`;
279
+ }
280
+ break;
281
+ }
282
+
283
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">${svgContent}</svg>`;
284
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: w } });
285
+ let pngBuffer: Buffer = Buffer.from(resvg.render().asPng());
286
+
287
+ const pos = resolvePosition(
288
+ overlay.zone || "hero-center",
289
+ canvasWidth,
290
+ canvasHeight,
291
+ w,
292
+ h,
293
+ "center"
294
+ );
295
+
296
+ const opacity = overlay.opacity ?? 1;
297
+ if (opacity < 1) {
298
+ pngBuffer = await applyOpacity(pngBuffer, opacity);
299
+ }
300
+
301
+ return sharp(canvasBuffer)
302
+ .composite([
303
+ {
304
+ input: pngBuffer,
305
+ left: Math.max(0, pos.x),
306
+ top: Math.max(0, pos.y),
307
+ blend: "over",
308
+ },
309
+ ])
310
+ .png()
311
+ .toBuffer();
312
+ }
313
+
314
+ async function compositeGradient(
315
+ canvasBuffer: Buffer,
316
+ overlay: GradientOverlay,
317
+ canvasWidth: number,
318
+ canvasHeight: number,
319
+ verbose: boolean
320
+ ): Promise<Buffer> {
321
+ const fonts = await loadFonts();
322
+
323
+ const jsx = {
324
+ type: "div",
325
+ props: {
326
+ style: {
327
+ width: canvasWidth,
328
+ height: canvasHeight,
329
+ backgroundImage: overlay.gradient,
330
+ display: "flex",
331
+ },
332
+ children: [],
333
+ },
334
+ };
335
+
336
+ const svg = await satori(jsx as any, {
337
+ width: canvasWidth,
338
+ height: canvasHeight,
339
+ fonts,
340
+ });
341
+
342
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: canvasWidth } });
343
+ let gradBuffer: Buffer = Buffer.from(resvg.render().asPng());
344
+
345
+ const opacity = overlay.opacity ?? 1;
346
+ if (opacity < 1) {
347
+ gradBuffer = await applyOpacity(gradBuffer, opacity);
348
+ }
349
+
350
+ const blend = overlay.blend || "normal";
351
+ const sharpBlend = blend === "normal" ? "over" : blend;
352
+
353
+ return sharp(canvasBuffer)
354
+ .composite([
355
+ {
356
+ input: gradBuffer,
357
+ left: 0,
358
+ top: 0,
359
+ blend: sharpBlend as any,
360
+ },
361
+ ])
362
+ .png()
363
+ .toBuffer();
364
+ }
365
+
366
+ async function compositeWatermark(
367
+ canvasBuffer: Buffer,
368
+ overlay: WatermarkOverlay,
369
+ canvasWidth: number,
370
+ canvasHeight: number,
371
+ assetDir: string,
372
+ verbose: boolean
373
+ ): Promise<Buffer> {
374
+ const assetPath = path.resolve(assetDir, overlay.src);
375
+ const size = overlay.size || 48;
376
+ const margin = overlay.margin || 20;
377
+ const opacity = overlay.opacity ?? 0.3;
378
+
379
+ let asset = await sharp(assetPath)
380
+ .resize(size, size, { fit: "inside" })
381
+ .png()
382
+ .toBuffer();
383
+
384
+ const meta = await sharp(asset).metadata();
385
+ const w = meta.width || size;
386
+ const h = meta.height || size;
387
+
388
+ if (opacity < 1) {
389
+ asset = await applyOpacity(asset, opacity);
390
+ }
391
+
392
+ let x: number, y: number;
393
+ switch (overlay.position || "bottom-right") {
394
+ case "bottom-right":
395
+ x = canvasWidth - w - margin;
396
+ y = canvasHeight - h - margin;
397
+ break;
398
+ case "bottom-left":
399
+ x = margin;
400
+ y = canvasHeight - h - margin;
401
+ break;
402
+ case "top-right":
403
+ x = canvasWidth - w - margin;
404
+ y = margin;
405
+ break;
406
+ case "top-left":
407
+ x = margin;
408
+ y = margin;
409
+ break;
410
+ }
411
+
412
+ return sharp(canvasBuffer)
413
+ .composite([
414
+ {
415
+ input: asset,
416
+ left: x!,
417
+ top: y!,
418
+ blend: "over",
419
+ },
420
+ ])
421
+ .png()
422
+ .toBuffer();
423
+ }
424
+
425
+ // --- Effect helpers ---
426
+
427
+ function resolveShadow(
428
+ shadow: ShadowConfig | "auto",
429
+ depth?: DepthLayer
430
+ ): ShadowConfig | null {
431
+ if (shadow === "auto") {
432
+ const preset = AUTO_SHADOW[depth || "foreground"];
433
+ if (!preset) return null;
434
+ return {
435
+ blur: preset.blur,
436
+ color: `rgba(0,0,0,0.5)`,
437
+ offsetX: preset.offset,
438
+ offsetY: preset.offset,
439
+ opacity: preset.opacity,
440
+ };
441
+ }
442
+ return shadow;
443
+ }
444
+
445
+ async function createShadow(
446
+ assetBuffer: Buffer,
447
+ width: number,
448
+ height: number,
449
+ config: ShadowConfig
450
+ ): Promise<Buffer> {
451
+ // Tint to shadow color and blur
452
+ let shadow = sharp(assetBuffer)
453
+ .ensureAlpha()
454
+ .tint(config.color)
455
+ .blur(Math.max(0.3, config.blur));
456
+
457
+ let buf = await shadow.png().toBuffer();
458
+
459
+ if (config.opacity !== undefined && config.opacity < 1) {
460
+ buf = await applyOpacity(buf, config.opacity);
461
+ }
462
+
463
+ return buf;
464
+ }
465
+
466
+ async function createGlow(
467
+ assetBuffer: Buffer,
468
+ width: number,
469
+ height: number,
470
+ config: { color: string; blur: number; spread: number }
471
+ ): Promise<Buffer> {
472
+ const extend = config.spread + config.blur;
473
+
474
+ let glow = sharp(assetBuffer)
475
+ .ensureAlpha()
476
+ .tint(config.color)
477
+ .extend({
478
+ top: extend,
479
+ bottom: extend,
480
+ left: extend,
481
+ right: extend,
482
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
483
+ })
484
+ .blur(Math.max(0.3, config.blur));
485
+
486
+ return glow.png().toBuffer();
487
+ }
488
+
489
+ async function createReflection(
490
+ assetBuffer: Buffer,
491
+ width: number,
492
+ height: number,
493
+ config: { opacity: number; fadeHeight: number }
494
+ ): Promise<Buffer> {
495
+ // Flip vertically
496
+ const flipped = await sharp(assetBuffer).flip().png().toBuffer();
497
+
498
+ // Create gradient alpha mask (opaque at top, transparent at bottom)
499
+ const fadeH = Math.round(height * (config.fadeHeight / 100));
500
+ const gradientSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
501
+ <defs>
502
+ <linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
503
+ <stop offset="0" stop-color="white" stop-opacity="1"/>
504
+ <stop offset="${fadeH / height}" stop-color="white" stop-opacity="0"/>
505
+ </linearGradient>
506
+ </defs>
507
+ <rect width="${width}" height="${height}" fill="url(#fade)"/>
508
+ </svg>`;
509
+
510
+ const maskResvg = new Resvg(gradientSvg, { fitTo: { mode: "width", value: width } });
511
+ const maskBuffer = Buffer.from(maskResvg.render().asPng());
512
+
513
+ // Apply mask
514
+ let result = await sharp(flipped)
515
+ .composite([{ input: maskBuffer, blend: "dest-in" }])
516
+ .png()
517
+ .toBuffer();
518
+
519
+ // Apply opacity
520
+ if (config.opacity < 1) {
521
+ result = await applyOpacity(result, config.opacity);
522
+ }
523
+
524
+ return result;
525
+ }
526
+
527
+ async function applyMask(
528
+ asset: sharp.Sharp,
529
+ width: number,
530
+ height: number,
531
+ mask: string
532
+ ): Promise<sharp.Sharp> {
533
+ let svgShape: string;
534
+
535
+ switch (mask) {
536
+ case "circle":
537
+ svgShape = `<ellipse cx="${width / 2}" cy="${height / 2}" rx="${width / 2}" ry="${height / 2}" fill="white"/>`;
538
+ break;
539
+ case "rounded":
540
+ svgShape = `<rect width="${width}" height="${height}" rx="${Math.min(width, height) * 0.1}" ry="${Math.min(width, height) * 0.1}" fill="white"/>`;
541
+ break;
542
+ case "hexagon": {
543
+ const cx = width / 2, cy = height / 2;
544
+ const r = Math.min(width, height) / 2;
545
+ const pts = Array.from({ length: 6 }, (_, i) => {
546
+ const angle = (Math.PI / 3) * i - Math.PI / 2;
547
+ return `${cx + r * Math.cos(angle)},${cy + r * Math.sin(angle)}`;
548
+ }).join(" ");
549
+ svgShape = `<polygon points="${pts}" fill="white"/>`;
550
+ break;
551
+ }
552
+ case "diamond": {
553
+ const cx = width / 2, cy = height / 2;
554
+ svgShape = `<polygon points="${cx},0 ${width},${cy} ${cx},${height} 0,${cy}" fill="white"/>`;
555
+ break;
556
+ }
557
+ default:
558
+ // Custom SVG path data
559
+ svgShape = `<path d="${mask}" fill="white"/>`;
560
+ }
561
+
562
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${svgShape}</svg>`;
563
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: width } });
564
+ const maskBuffer = Buffer.from(resvg.render().asPng());
565
+
566
+ const resized = await asset.resize(width, height, { fit: "cover" }).png().toBuffer();
567
+
568
+ const masked = await sharp(resized)
569
+ .composite([{ input: maskBuffer, blend: "dest-in" }])
570
+ .png()
571
+ .toBuffer();
572
+
573
+ return sharp(masked);
574
+ }
575
+
576
+ async function applyBorderRadius(
577
+ asset: sharp.Sharp,
578
+ width: number,
579
+ height: number,
580
+ radius: number
581
+ ): Promise<sharp.Sharp> {
582
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
583
+ <rect width="${width}" height="${height}" rx="${radius}" ry="${radius}" fill="white"/>
584
+ </svg>`;
585
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: width } });
586
+ const maskBuffer = Buffer.from(resvg.render().asPng());
587
+
588
+ const resized = await asset.resize(width, height, { fit: "cover" }).png().toBuffer();
589
+
590
+ const masked = await sharp(resized)
591
+ .composite([{ input: maskBuffer, blend: "dest-in" }])
592
+ .png()
593
+ .toBuffer();
594
+
595
+ return sharp(masked);
596
+ }
597
+
598
+ async function applyOpacity(buffer: Buffer, opacity: number): Promise<Buffer> {
599
+ // Multiply all alpha values by opacity
600
+ const { data, info } = await sharp(buffer)
601
+ .ensureAlpha()
602
+ .raw()
603
+ .toBuffer({ resolveWithObject: true });
604
+
605
+ for (let i = 3; i < data.length; i += 4) {
606
+ data[i] = Math.round(data[i]! * opacity);
607
+ }
608
+
609
+ return sharp(data, {
610
+ raw: { width: info.width, height: info.height, channels: 4 },
611
+ })
612
+ .png()
613
+ .toBuffer();
614
+ }
package/src/config.ts ADDED
@@ -0,0 +1,102 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import type { PictureItConfig } from "./types.ts";
4
+
5
+ export const APP_DIR = path.join(
6
+ process.env["HOME"] || process.env["USERPROFILE"] || "~",
7
+ ".picture-it"
8
+ );
9
+ const CONFIG_PATH = path.join(APP_DIR, "config.json");
10
+
11
+ function loadConfigFile(): PictureItConfig {
12
+ try {
13
+ const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
14
+ return JSON.parse(raw) as PictureItConfig;
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ function saveConfigFile(config: PictureItConfig): void {
21
+ fs.mkdirSync(APP_DIR, { recursive: true });
22
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), {
23
+ mode: 0o600,
24
+ });
25
+ }
26
+
27
+ export function getConfig(): PictureItConfig {
28
+ const file = loadConfigFile();
29
+ return {
30
+ fal_key: process.env["FAL_KEY"] || file.fal_key,
31
+ default_model: file.default_model,
32
+ default_platform: file.default_platform,
33
+ default_grade: file.default_grade,
34
+ };
35
+ }
36
+
37
+ export function setConfigValue(key: string, value: string): void {
38
+ const config = loadConfigFile();
39
+ (config as any)[key] = value;
40
+ saveConfigFile(config);
41
+ }
42
+
43
+ export function getConfigValue(key: string): string | undefined {
44
+ const config = loadConfigFile();
45
+ return (config as any)[key];
46
+ }
47
+
48
+ export function listConfig(): PictureItConfig {
49
+ return loadConfigFile();
50
+ }
51
+
52
+ export function clearConfig(): void {
53
+ try {
54
+ fs.unlinkSync(CONFIG_PATH);
55
+ } catch {
56
+ // Already gone
57
+ }
58
+ }
59
+
60
+ export function maskKey(key: string): string {
61
+ if (key.length <= 8) return "****";
62
+ return key.slice(0, 4) + "..." + key.slice(-4);
63
+ }
64
+
65
+ export function getKeySource(
66
+ key: "fal_key" | "anthropic_api_key"
67
+ ): { value: string; source: string } | null {
68
+
69
+ const envKey = key === "fal_key" ? "FAL_KEY" : "ANTHROPIC_API_KEY";
70
+ if (process.env[envKey]) {
71
+ return { value: process.env[envKey]!, source: "env variable" };
72
+ }
73
+
74
+ const file = loadConfigFile();
75
+ const fileVal = file[key];
76
+ if (fileVal) {
77
+ return { value: fileVal, source: "config.json" };
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ export function ensureKeys(
84
+ ...keys: ("fal_key" | "anthropic_api_key")[]
85
+ ): void {
86
+ const config = getConfig();
87
+ const missing: string[] = [];
88
+
89
+ for (const k of keys) {
90
+ if (!config[k]) {
91
+ missing.push(k === "fal_key" ? "FAL_KEY" : "ANTHROPIC_API_KEY");
92
+ }
93
+ }
94
+
95
+ if (missing.length > 0) {
96
+ process.stderr.write(
97
+ `No API keys configured for: ${missing.join(", ")}\n` +
98
+ `Run 'picture-it auth' to set up.\n`
99
+ );
100
+ process.exit(1);
101
+ }
102
+ }