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,155 @@
1
+ import sharp from "sharp";
2
+ import { resolvePosition, resolveDimension } from "./zones.ts";
3
+ import type { SatoriTextOverlay, GradientOverlay, Overlay } from "./types.ts";
4
+
5
+ function log(msg: string) {
6
+ process.stderr.write(`[picture-it] ${msg}\n`);
7
+ }
8
+
9
+ // WCAG relative luminance
10
+ function relativeLuminance(r: number, g: number, b: number): number {
11
+ const [rs, gs, bs] = [r, g, b].map((c) => {
12
+ const s = c / 255;
13
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
14
+ });
15
+ return 0.2126 * rs! + 0.7152 * gs! + 0.0722 * bs!;
16
+ }
17
+
18
+ function contrastRatio(l1: number, l2: number): number {
19
+ const lighter = Math.max(l1, l2);
20
+ const darker = Math.min(l1, l2);
21
+ return (lighter + 0.05) / (darker + 0.05);
22
+ }
23
+
24
+ function parseColor(color: string): { r: number; g: number; b: number } | null {
25
+ // Handle hex
26
+ if (color.startsWith("#")) {
27
+ const hex = color.slice(1);
28
+ if (hex.length === 3) {
29
+ return {
30
+ r: parseInt(hex[0]! + hex[0]!, 16),
31
+ g: parseInt(hex[1]! + hex[1]!, 16),
32
+ b: parseInt(hex[2]! + hex[2]!, 16),
33
+ };
34
+ }
35
+ if (hex.length >= 6) {
36
+ return {
37
+ r: parseInt(hex.slice(0, 2), 16),
38
+ g: parseInt(hex.slice(2, 4), 16),
39
+ b: parseInt(hex.slice(4, 6), 16),
40
+ };
41
+ }
42
+ }
43
+
44
+ // Handle rgb/rgba
45
+ const rgbMatch = color.match(
46
+ /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
47
+ );
48
+ if (rgbMatch) {
49
+ return {
50
+ r: parseInt(rgbMatch[1]!),
51
+ g: parseInt(rgbMatch[2]!),
52
+ b: parseInt(rgbMatch[3]!),
53
+ };
54
+ }
55
+
56
+ // Named colors
57
+ const named: Record<string, { r: number; g: number; b: number }> = {
58
+ white: { r: 255, g: 255, b: 255 },
59
+ black: { r: 0, g: 0, b: 0 },
60
+ };
61
+ return named[color.toLowerCase()] || null;
62
+ }
63
+
64
+ function extractTextColor(jsx: any): string {
65
+ if (jsx?.props?.style?.color) return jsx.props.style.color;
66
+ if (jsx?.children) {
67
+ for (const child of Array.isArray(jsx.children) ? jsx.children : [jsx.children]) {
68
+ if (typeof child === "object") {
69
+ const c = extractTextColor(child);
70
+ if (c) return c;
71
+ }
72
+ }
73
+ }
74
+ return "white"; // default assumption
75
+ }
76
+
77
+ export async function checkAndFixContrast(
78
+ baseBuffer: Buffer,
79
+ overlays: Overlay[],
80
+ canvasWidth: number,
81
+ canvasHeight: number
82
+ ): Promise<Overlay[]> {
83
+ const result = [...overlays];
84
+ const inserts: { index: number; overlay: GradientOverlay }[] = [];
85
+
86
+ for (let i = 0; i < result.length; i++) {
87
+ const overlay = result[i]!;
88
+ if (overlay.type !== "satori-text") continue;
89
+
90
+ const textOverlay = overlay as SatoriTextOverlay;
91
+ const textW = textOverlay.width || canvasWidth;
92
+ const textH = textOverlay.height || canvasHeight;
93
+
94
+ const pos = resolvePosition(
95
+ textOverlay.zone || "title-area",
96
+ canvasWidth,
97
+ canvasHeight,
98
+ textW,
99
+ textH,
100
+ textOverlay.anchor || "center"
101
+ );
102
+
103
+ // Crop the region where text will land
104
+ const cropX = Math.max(0, Math.min(pos.x, canvasWidth - 1));
105
+ const cropY = Math.max(0, Math.min(pos.y, canvasHeight - 1));
106
+ const cropW = Math.min(textW, canvasWidth - cropX);
107
+ const cropH = Math.min(textH, canvasHeight - cropY);
108
+
109
+ if (cropW <= 0 || cropH <= 0) continue;
110
+
111
+ const region = await sharp(baseBuffer)
112
+ .extract({ left: cropX, top: cropY, width: cropW, height: cropH })
113
+ .grayscale()
114
+ .stats();
115
+
116
+ const bgLuminance = (region.channels[0]?.mean || 128) / 255;
117
+
118
+ // Extract text color from JSX
119
+ const textColor = extractTextColor(textOverlay.jsx);
120
+ const parsed = parseColor(textColor);
121
+ const textLum = parsed
122
+ ? relativeLuminance(parsed.r, parsed.g, parsed.b)
123
+ : 1; // assume white
124
+
125
+ const ratio = contrastRatio(bgLuminance, textLum);
126
+
127
+ if (ratio < 4.5) {
128
+ log(
129
+ `Low contrast (${ratio.toFixed(1)}:1) at ${JSON.stringify(textOverlay.zone)}, adding safety overlay`
130
+ );
131
+
132
+ // Determine if we need dark or light overlay
133
+ const needDark = textLum > 0.5; // Light text → dark overlay behind it
134
+ const gradColor = needDark ? "rgba(0,0,0,0.6)" : "rgba(255,255,255,0.6)";
135
+
136
+ const safetyOverlay: GradientOverlay = {
137
+ type: "gradient-overlay",
138
+ gradient: `linear-gradient(180deg, transparent 0%, ${gradColor} 30%, ${gradColor} 70%, transparent 100%)`,
139
+ opacity: 0.7,
140
+ blend: "normal",
141
+ depth: (textOverlay.depth as any) || "overlay",
142
+ };
143
+
144
+ // Insert before the text overlay
145
+ inserts.push({ index: i, overlay: safetyOverlay });
146
+ }
147
+ }
148
+
149
+ // Insert safety overlays in reverse order to maintain indices
150
+ for (const ins of inserts.reverse()) {
151
+ result.splice(ins.index, 0, ins.overlay);
152
+ }
153
+
154
+ return result;
155
+ }
package/src/fal.ts ADDED
@@ -0,0 +1,218 @@
1
+ import { fal } from "@fal-ai/client";
2
+ import fs from "fs";
3
+ import sharp from "sharp";
4
+ import { log } from "./operations.ts";
5
+ import {
6
+ getEndpoint,
7
+ getCost,
8
+ selectGenerateModel,
9
+ selectEditModel,
10
+ mapAspectRatio,
11
+ mapResolution,
12
+ } from "./model-router.ts";
13
+ import type { FalModel, CropPosition } from "./types.ts";
14
+
15
+ export function configureFal(apiKey: string) {
16
+ fal.config({ credentials: apiKey });
17
+ }
18
+
19
+ // --- Upload ---
20
+
21
+ export async function uploadFile(filePath: string): Promise<string> {
22
+ const buffer = fs.readFileSync(filePath);
23
+ const filename = filePath.split("/").pop() || "image.png";
24
+ const file = new File([buffer], filename, { type: "image/png" });
25
+ return fal.storage.upload(file);
26
+ }
27
+
28
+ export async function uploadBuffer(buffer: Buffer, filename: string): Promise<string> {
29
+ const file = new File([buffer], filename, { type: "image/png" });
30
+ return fal.storage.upload(file);
31
+ }
32
+
33
+ // --- Generate (no input images) ---
34
+
35
+ export async function generate(opts: {
36
+ prompt: string;
37
+ model?: string;
38
+ width?: number;
39
+ height?: number;
40
+ verbose?: boolean;
41
+ }): Promise<Buffer> {
42
+ const model = selectGenerateModel(opts.model, opts.verbose);
43
+ const endpoint = getEndpoint(model);
44
+ const cost = getCost(model);
45
+
46
+ log(`FAL generate: ${model} @ $${cost.toFixed(3)}`);
47
+
48
+ const w = opts.width || 1200;
49
+ const h = opts.height || 630;
50
+
51
+ const input: Record<string, unknown> = {
52
+ prompt: opts.prompt,
53
+ num_images: 1,
54
+ image_size: mapFluxSize(w, h),
55
+ };
56
+
57
+ const result = await fal.subscribe(endpoint, {
58
+ input,
59
+ logs: true,
60
+ onQueueUpdate: (update) => {
61
+ if (update.status === "IN_PROGRESS" && opts.verbose) {
62
+ for (const entry of (update as any).logs || []) {
63
+ log(`FAL: ${entry.message}`);
64
+ }
65
+ }
66
+ },
67
+ });
68
+
69
+ return downloadResult(result);
70
+ }
71
+
72
+ // --- Edit (with input images) ---
73
+
74
+ export async function edit(opts: {
75
+ inputUrls: string[];
76
+ prompt: string;
77
+ model?: string;
78
+ width?: number;
79
+ height?: number;
80
+ verbose?: boolean;
81
+ }): Promise<Buffer> {
82
+ const model = selectEditModel(opts.inputUrls.length, opts.model, opts.verbose);
83
+ const endpoint = getEndpoint(model);
84
+ const cost = getCost(model);
85
+
86
+ log(`FAL edit: ${model} @ $${cost.toFixed(2)} | ${opts.inputUrls.length} input(s)`);
87
+
88
+ const w = opts.width || 1200;
89
+ const h = opts.height || 630;
90
+
91
+ let input: Record<string, unknown>;
92
+
93
+ if (model === "seedream") {
94
+ input = {
95
+ prompt: opts.prompt,
96
+ image_urls: opts.inputUrls,
97
+ image_size: seedreamSize(w, h),
98
+ num_images: 1,
99
+ max_images: 1,
100
+ };
101
+ } else if (model === "banana2") {
102
+ input = {
103
+ prompt: opts.prompt,
104
+ image_urls: opts.inputUrls,
105
+ aspect_ratio: mapAspectRatio(w, h),
106
+ resolution: mapResolution(w, h),
107
+ output_format: "png",
108
+ num_images: 1,
109
+ limit_generations: true,
110
+ };
111
+ } else {
112
+ // banana-pro — minimal params, let it auto-detect
113
+ input = {
114
+ prompt: opts.prompt,
115
+ image_urls: opts.inputUrls,
116
+ num_images: 1,
117
+ };
118
+ }
119
+
120
+ const result = await fal.subscribe(endpoint, {
121
+ input,
122
+ logs: true,
123
+ onQueueUpdate: (update) => {
124
+ if (update.status === "IN_PROGRESS" && opts.verbose) {
125
+ for (const entry of (update as any).logs || []) {
126
+ log(`FAL: ${entry.message}`);
127
+ }
128
+ }
129
+ },
130
+ });
131
+
132
+ return downloadResult(result);
133
+ }
134
+
135
+ // --- Remove background ---
136
+
137
+ export async function removeBg(opts: {
138
+ inputUrl: string;
139
+ verbose?: boolean;
140
+ }): Promise<Buffer> {
141
+ log("FAL: birefnet background removal");
142
+
143
+ const result = await fal.subscribe("fal-ai/birefnet", {
144
+ input: { image_url: opts.inputUrl },
145
+ });
146
+
147
+ const outputUrl = (result as any).data?.image?.url;
148
+ if (!outputUrl) throw new Error("birefnet returned no image");
149
+
150
+ const response = await fetch(outputUrl);
151
+ return Buffer.from(await response.arrayBuffer());
152
+ }
153
+
154
+ // --- Upscale ---
155
+
156
+ export async function upscale(opts: {
157
+ inputUrl: string;
158
+ scale?: number;
159
+ verbose?: boolean;
160
+ }): Promise<Buffer> {
161
+ log(`FAL: upscale ${opts.scale || 2}x`);
162
+
163
+ // Use creative upscaler
164
+ const result = await fal.subscribe("fal-ai/creative-upscaler", {
165
+ input: {
166
+ image_url: opts.inputUrl,
167
+ scale: opts.scale || 2,
168
+ },
169
+ });
170
+
171
+ return downloadResult(result);
172
+ }
173
+
174
+ // --- Crop to exact size ---
175
+
176
+ export async function cropToExact(
177
+ buffer: Buffer,
178
+ width: number,
179
+ height: number,
180
+ position: CropPosition = "attention"
181
+ ): Promise<Buffer> {
182
+ return sharp(buffer)
183
+ .resize(width, height, {
184
+ fit: "cover",
185
+ position: position as any,
186
+ })
187
+ .png()
188
+ .toBuffer();
189
+ }
190
+
191
+ // --- Helpers ---
192
+
193
+ async function downloadResult(result: any): Promise<Buffer> {
194
+ const url = result?.data?.images?.[0]?.url || result?.data?.image?.url;
195
+ if (!url) throw new Error("FAL returned no image URL");
196
+
197
+ const response = await fetch(url);
198
+ if (!response.ok) throw new Error(`Failed to download: ${response.status}`);
199
+ return Buffer.from(await response.arrayBuffer());
200
+ }
201
+
202
+ function mapFluxSize(w: number, h: number): string {
203
+ const ratio = w / h;
204
+ if (Math.abs(ratio - 1) < 0.1) return "square_hd";
205
+ if (ratio > 1.5) return "landscape_16_9";
206
+ if (ratio < 0.67) return "portrait_16_9";
207
+ if (ratio > 1) return "landscape_4_3";
208
+ return "portrait_4_3";
209
+ }
210
+
211
+ function seedreamSize(w: number, h: number): unknown {
212
+ // SeedDream supports custom dimensions (1920-4096px per axis)
213
+ if (w >= 1920 && h >= 1080 && w <= 4096 && h <= 4096) {
214
+ return { width: w, height: h };
215
+ }
216
+ // Fall back to preset
217
+ return "auto_2K";
218
+ }
package/src/fonts.ts ADDED
@@ -0,0 +1,165 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { APP_DIR } from "./config.ts";
4
+
5
+ const USER_FONT_DIR = path.join(APP_DIR, "fonts");
6
+ const LOCAL_FONT_DIR = path.join(import.meta.dirname, "..", "fonts");
7
+
8
+ interface SatoriFont {
9
+ name: string;
10
+ data: ArrayBuffer;
11
+ weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
12
+ style: "normal" | "italic";
13
+ }
14
+
15
+ const FONT_FILES: {
16
+ name: string;
17
+ file: string;
18
+ weight: SatoriFont["weight"];
19
+ style: SatoriFont["style"];
20
+ url: string;
21
+ }[] = [
22
+ {
23
+ name: "Inter",
24
+ file: "Inter-Regular.ttf",
25
+ weight: 400,
26
+ style: "normal",
27
+ url: "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuLyfMZg.ttf",
28
+ },
29
+ {
30
+ name: "Inter",
31
+ file: "Inter-SemiBold.ttf",
32
+ weight: 600,
33
+ style: "normal",
34
+ url: "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuGKYMZg.ttf",
35
+ },
36
+ {
37
+ name: "Inter",
38
+ file: "Inter-Bold.ttf",
39
+ weight: 700,
40
+ style: "normal",
41
+ url: "https://fonts.gstatic.com/s/inter/v20/UcCO3FwrK3iLTeHuS_nVMrMxCp50SjIw2boKoduKmMEVuFuYMZg.ttf",
42
+ },
43
+ {
44
+ name: "Space Grotesk",
45
+ file: "SpaceGrotesk-Medium.ttf",
46
+ weight: 500,
47
+ style: "normal",
48
+ url: "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj7aUUsj.ttf",
49
+ },
50
+ {
51
+ name: "Space Grotesk",
52
+ file: "SpaceGrotesk-Bold.ttf",
53
+ weight: 700,
54
+ style: "normal",
55
+ url: "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj4PVksj.ttf",
56
+ },
57
+ {
58
+ name: "DM Serif Display",
59
+ file: "DMSerifDisplay-Regular.ttf",
60
+ weight: 400,
61
+ style: "normal",
62
+ url: "https://fonts.gstatic.com/s/dmserifdisplay/v17/-nFnOHM81r4j6k0gjAW3mujVU2B2K_c.ttf",
63
+ },
64
+ ];
65
+
66
+ let cachedFonts: SatoriFont[] | null = null;
67
+
68
+ function getFontPathCandidates(file: string): string[] {
69
+ return [
70
+ path.join(USER_FONT_DIR, file),
71
+ path.join(LOCAL_FONT_DIR, file),
72
+ ];
73
+ }
74
+
75
+ function readFontFile(file: string): Buffer | null {
76
+ for (const fontPath of getFontPathCandidates(file)) {
77
+ try {
78
+ return fs.readFileSync(fontPath);
79
+ } catch {
80
+ // Try the next font location.
81
+ }
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ export function getFontDirectory(): string {
88
+ return USER_FONT_DIR;
89
+ }
90
+
91
+ export async function downloadFonts(options: {
92
+ force?: boolean;
93
+ onProgress?: (message: string) => void;
94
+ } = {}): Promise<{ dir: string; downloaded: string[]; skipped: string[] }> {
95
+ const { force = false, onProgress } = options;
96
+ const downloaded: string[] = [];
97
+ const skipped: string[] = [];
98
+
99
+ fs.mkdirSync(USER_FONT_DIR, { recursive: true });
100
+
101
+ for (const font of FONT_FILES) {
102
+ const outPath = path.join(USER_FONT_DIR, font.file);
103
+
104
+ if (!force && fs.existsSync(outPath) && fs.statSync(outPath).size > 0) {
105
+ skipped.push(font.file);
106
+ onProgress?.(`Already installed: ${font.file}`);
107
+ continue;
108
+ }
109
+
110
+ onProgress?.(`Downloading: ${font.file}`);
111
+ const res = await fetch(font.url);
112
+ if (!res.ok) {
113
+ throw new Error(`Failed to download ${font.file}: ${res.status} ${res.statusText}`);
114
+ }
115
+
116
+ const buf = Buffer.from(await res.arrayBuffer());
117
+ fs.writeFileSync(outPath, buf);
118
+ downloaded.push(font.file);
119
+ onProgress?.(`Saved: ${font.file} (${(buf.length / 1024).toFixed(0)} KB)`);
120
+ }
121
+
122
+ cachedFonts = null;
123
+
124
+ return {
125
+ dir: USER_FONT_DIR,
126
+ downloaded,
127
+ skipped,
128
+ };
129
+ }
130
+
131
+ export async function loadFonts(): Promise<SatoriFont[]> {
132
+ if (cachedFonts) return cachedFonts;
133
+
134
+ const fonts: SatoriFont[] = [];
135
+ const available: string[] = [];
136
+ const missing: string[] = [];
137
+
138
+ for (const f of FONT_FILES) {
139
+ const data = readFontFile(f.file);
140
+ if (!data) {
141
+ missing.push(f.file);
142
+ continue;
143
+ }
144
+
145
+ fonts.push({
146
+ name: f.name,
147
+ data: data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength),
148
+ weight: f.weight,
149
+ style: f.style,
150
+ });
151
+ available.push(f.file);
152
+ }
153
+
154
+ if (missing.length > 0) {
155
+ process.stderr.write(`[picture-it] Warning: missing fonts: ${missing.join(", ")}\n`);
156
+ process.stderr.write(`[picture-it] Run: picture-it download-fonts to fetch them\n`);
157
+ }
158
+
159
+ if (fonts.length === 0) {
160
+ throw new Error("No fonts available. Run: picture-it download-fonts");
161
+ }
162
+
163
+ cachedFonts = fonts;
164
+ return fonts;
165
+ }
@@ -0,0 +1,78 @@
1
+ import type { FalModel } from "./types.ts";
2
+ import { log } from "./operations.ts";
3
+
4
+ const MODEL_ENDPOINTS: Record<FalModel, string> = {
5
+ seedream: "fal-ai/bytedance/seedream/v4.5/edit",
6
+ banana2: "fal-ai/nano-banana-2/edit",
7
+ "banana-pro": "fal-ai/nano-banana-pro/edit",
8
+ "flux-dev": "fal-ai/flux/dev",
9
+ "flux-schnell": "fal-ai/flux/schnell",
10
+ };
11
+
12
+ const MODEL_COSTS: Record<FalModel, number> = {
13
+ seedream: 0.04,
14
+ banana2: 0.08,
15
+ "banana-pro": 0.15,
16
+ "flux-dev": 0.03,
17
+ "flux-schnell": 0.003,
18
+ };
19
+
20
+ export function getEndpoint(model: FalModel): string {
21
+ return MODEL_ENDPOINTS[model];
22
+ }
23
+
24
+ export function getCost(model: FalModel): number {
25
+ return MODEL_COSTS[model];
26
+ }
27
+
28
+ export function selectGenerateModel(explicit?: string, verbose = false): FalModel {
29
+ if (explicit && explicit in MODEL_ENDPOINTS) return explicit as FalModel;
30
+
31
+ const model: FalModel = "flux-schnell";
32
+ if (verbose) log(`Model: ${model} ($${getCost(model)}) — fast generation`);
33
+ return model;
34
+ }
35
+
36
+ export function selectEditModel(
37
+ inputCount: number,
38
+ explicit?: string,
39
+ verbose = false
40
+ ): FalModel {
41
+ if (explicit && explicit in MODEL_ENDPOINTS) return explicit as FalModel;
42
+
43
+ let model: FalModel;
44
+ let reason: string;
45
+
46
+ if (inputCount > 10) {
47
+ model = "banana2";
48
+ reason = `${inputCount} inputs (>10), needs banana2`;
49
+ } else {
50
+ model = "seedream";
51
+ reason = "default edit model, $0.04";
52
+ }
53
+
54
+ if (verbose) log(`Model: ${model} ($${getCost(model)}) — ${reason}`);
55
+ return model;
56
+ }
57
+
58
+ export function mapAspectRatio(width: number, height: number): string {
59
+ const ratio = width / height;
60
+ if (Math.abs(ratio - 1) < 0.1) return "1:1";
61
+ if (Math.abs(ratio - 16 / 9) < 0.15) return "16:9";
62
+ if (Math.abs(ratio - 9 / 16) < 0.15) return "9:16";
63
+ if (Math.abs(ratio - 4 / 3) < 0.15) return "4:3";
64
+ if (Math.abs(ratio - 3 / 4) < 0.15) return "3:4";
65
+ if (Math.abs(ratio - 3 / 2) < 0.15) return "3:2";
66
+ if (Math.abs(ratio - 2 / 3) < 0.15) return "2:3";
67
+ if (Math.abs(ratio - 21 / 9) < 0.2) return "21:9";
68
+ if (ratio >= 3.5) return "4:1";
69
+ return "auto";
70
+ }
71
+
72
+ export function mapResolution(width: number, height: number): string {
73
+ const maxDim = Math.max(width, height);
74
+ if (maxDim <= 512) return "0.5K";
75
+ if (maxDim <= 1024) return "1K";
76
+ if (maxDim <= 2048) return "2K";
77
+ return "4K";
78
+ }
@@ -0,0 +1,85 @@
1
+ import sharp from "sharp";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { PLATFORM_PRESETS } from "./presets.ts";
5
+
6
+ export function log(msg: string) {
7
+ process.stderr.write(`[picture-it] ${msg}\n`);
8
+ }
9
+
10
+ export function parseSize(
11
+ sizeStr?: string,
12
+ platform?: string
13
+ ): { width: number; height: number } {
14
+ if (sizeStr) {
15
+ const [w, h] = sizeStr.split("x").map(Number);
16
+ if (w && h) return { width: w, height: h };
17
+ }
18
+ if (platform && PLATFORM_PRESETS[platform]) {
19
+ return {
20
+ width: PLATFORM_PRESETS[platform]!.width,
21
+ height: PLATFORM_PRESETS[platform]!.height,
22
+ };
23
+ }
24
+ return { width: 1200, height: 630 };
25
+ }
26
+
27
+ export async function readInput(inputPath: string): Promise<Buffer> {
28
+ if (!fs.existsSync(inputPath)) {
29
+ log(`Input not found: ${inputPath}`);
30
+ process.exit(1);
31
+ }
32
+ return sharp(inputPath).png().toBuffer();
33
+ }
34
+
35
+ export async function writeOutput(
36
+ buffer: Buffer,
37
+ outputPath: string
38
+ ): Promise<string> {
39
+ const resolved = path.resolve(outputPath);
40
+ const ext = path.extname(resolved).toLowerCase();
41
+
42
+ let img = sharp(buffer);
43
+ switch (ext) {
44
+ case ".jpg":
45
+ case ".jpeg":
46
+ await img.jpeg({ quality: 90 }).toFile(resolved);
47
+ break;
48
+ case ".webp":
49
+ await img.webp({ quality: 90 }).toFile(resolved);
50
+ break;
51
+ default:
52
+ await img.png({ quality: 90 }).toFile(resolved);
53
+ break;
54
+ }
55
+
56
+ return resolved;
57
+ }
58
+
59
+ export function ensureFalKey(): string {
60
+ const config = loadFalKey();
61
+ if (!config) {
62
+ log("No FAL API key configured. Run 'picture-it auth --fal <key>' to set up.");
63
+ process.exit(1);
64
+ }
65
+ return config;
66
+ }
67
+
68
+ function loadFalKey(): string | undefined {
69
+ // 1. Env var
70
+ if (process.env["FAL_KEY"]) return process.env["FAL_KEY"];
71
+
72
+ // 2. Config file
73
+ const configPath = path.join(
74
+ process.env["HOME"] || "~",
75
+ ".picture-it",
76
+ "config.json"
77
+ );
78
+ try {
79
+ const raw = fs.readFileSync(configPath, "utf-8");
80
+ const config = JSON.parse(raw);
81
+ return config.fal_key;
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }