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
|
@@ -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
|
+
}
|