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/src/contrast.ts
ADDED
|
@@ -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
|
+
}
|