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,243 @@
1
+ import sharp from "sharp";
2
+ import satori from "satori";
3
+ import { Resvg } from "@resvg/resvg-js";
4
+ import path from "path";
5
+ import fs from "fs";
6
+ import { log, parseSize, ensureFalKey, writeOutput } from "./operations.ts";
7
+ import { configureFal, generate, edit, removeBg, uploadFile, uploadBuffer, cropToExact } from "./fal.ts";
8
+ import { composite } from "./compositor.ts";
9
+ import { applyColorGrade, applyGrain, applyVignette } from "./postprocess.ts";
10
+ import { loadFonts } from "./fonts.ts";
11
+ import type { PipelineStep, Overlay } from "./types.ts";
12
+
13
+ export async function executePipeline(
14
+ steps: PipelineStep[],
15
+ outputPath: string,
16
+ verbose = false
17
+ ): Promise<string> {
18
+ let buffer: Buffer | null = null;
19
+ const falKey = ensureFalKey();
20
+ configureFal(falKey);
21
+
22
+ for (let i = 0; i < steps.length; i++) {
23
+ const step = steps[i]!;
24
+ if (verbose) log(`Pipeline step ${i + 1}/${steps.length}: ${step.op}`);
25
+
26
+ switch (step.op) {
27
+ case "generate": {
28
+ const { width, height } = parseSize(step.size, step.platform);
29
+ buffer = await generate({
30
+ prompt: step.prompt,
31
+ model: step.model,
32
+ width,
33
+ height,
34
+ verbose,
35
+ });
36
+ buffer = await cropToExact(buffer, width, height);
37
+ break;
38
+ }
39
+
40
+ case "edit": {
41
+ if (!buffer && (!step.assets || step.assets.length === 0)) {
42
+ throw new Error("Edit step requires input buffer or assets");
43
+ }
44
+ const urls: string[] = [];
45
+ if (buffer) {
46
+ urls.push(await uploadBuffer(buffer, "input.png"));
47
+ }
48
+ if (step.assets) {
49
+ for (const asset of step.assets) {
50
+ urls.push(await uploadFile(path.resolve(asset)));
51
+ }
52
+ }
53
+ const size = step.size ? parseSize(step.size) : buffer ? await getBufferSize(buffer) : { width: 1200, height: 630 };
54
+ buffer = await edit({
55
+ inputUrls: urls,
56
+ prompt: step.prompt,
57
+ model: step.model,
58
+ width: size.width,
59
+ height: size.height,
60
+ verbose,
61
+ });
62
+ buffer = await cropToExact(buffer, size.width, size.height);
63
+ break;
64
+ }
65
+
66
+ case "remove-bg": {
67
+ if (!buffer) throw new Error("remove-bg requires input");
68
+ const url = await uploadBuffer(buffer, "input.png");
69
+ buffer = await removeBg({ inputUrl: url, verbose });
70
+ break;
71
+ }
72
+
73
+ case "replace-bg": {
74
+ if (!buffer) throw new Error("replace-bg requires input");
75
+ // Remove bg from current buffer
76
+ const cutoutUrl = await uploadBuffer(buffer, "input.png");
77
+ const cutout = await removeBg({ inputUrl: cutoutUrl, verbose });
78
+ // Generate new background
79
+ const size = await getBufferSize(buffer);
80
+ const bg = await generate({
81
+ prompt: step.prompt,
82
+ model: step.model,
83
+ width: size.width,
84
+ height: size.height,
85
+ verbose,
86
+ });
87
+ const bgCropped = await cropToExact(bg, size.width, size.height);
88
+ // Composite cutout onto new bg
89
+ buffer = await sharp(bgCropped)
90
+ .composite([{ input: cutout, blend: "over" }])
91
+ .png()
92
+ .toBuffer();
93
+ break;
94
+ }
95
+
96
+ case "crop": {
97
+ if (!buffer) throw new Error("crop requires input");
98
+ const { width, height } = parseSize(step.size);
99
+ const pos = (step.position || "attention") as any;
100
+ buffer = await cropToExact(buffer, width, height, pos);
101
+ break;
102
+ }
103
+
104
+ case "grade": {
105
+ if (!buffer) throw new Error("grade requires input");
106
+ buffer = await applyColorGrade(buffer, step.name);
107
+ break;
108
+ }
109
+
110
+ case "grain": {
111
+ if (!buffer) throw new Error("grain requires input");
112
+ buffer = await applyGrain(buffer, step.intensity);
113
+ break;
114
+ }
115
+
116
+ case "vignette": {
117
+ if (!buffer) throw new Error("vignette requires input");
118
+ buffer = await applyVignette(buffer, step.opacity);
119
+ break;
120
+ }
121
+
122
+ case "text": {
123
+ if (!buffer) throw new Error("text requires input");
124
+ buffer = await renderTextOnto(buffer, step);
125
+ break;
126
+ }
127
+
128
+ case "compose": {
129
+ if (!buffer) throw new Error("compose requires input");
130
+ let overlays: Overlay[];
131
+ if (typeof step.overlays === "string") {
132
+ overlays = JSON.parse(fs.readFileSync(path.resolve(step.overlays), "utf-8"));
133
+ } else {
134
+ overlays = step.overlays;
135
+ }
136
+ const size = await getBufferSize(buffer);
137
+ buffer = await composite(buffer, overlays, size.width, size.height, process.cwd(), verbose);
138
+ break;
139
+ }
140
+
141
+ case "upscale": {
142
+ if (!buffer) throw new Error("upscale requires input");
143
+ const { upscale: upscaleFn } = await import("./fal.ts");
144
+ const url = await uploadBuffer(buffer, "input.png");
145
+ buffer = await upscaleFn({ inputUrl: url, scale: step.scale, verbose });
146
+ break;
147
+ }
148
+ }
149
+ }
150
+
151
+ if (!buffer) throw new Error("Pipeline produced no output");
152
+
153
+ const finalPath = await writeOutput(buffer, outputPath);
154
+ return finalPath;
155
+ }
156
+
157
+ async function getBufferSize(buffer: Buffer): Promise<{ width: number; height: number }> {
158
+ const meta = await sharp(buffer).metadata();
159
+ return { width: meta.width || 1200, height: meta.height || 630 };
160
+ }
161
+
162
+ async function renderTextOnto(
163
+ buffer: Buffer,
164
+ step: { title: string; font?: string; color?: string; fontSize?: number; zone?: string }
165
+ ): Promise<Buffer> {
166
+ const meta = await sharp(buffer).metadata();
167
+ const w = meta.width || 1200;
168
+ const h = meta.height || 630;
169
+ const fonts = await loadFonts();
170
+
171
+ const fontSize = step.fontSize || 64;
172
+ const fontFamily = step.font || "Space Grotesk";
173
+ const color = step.color || "white";
174
+
175
+ const jsx = {
176
+ type: "div",
177
+ props: {
178
+ style: {
179
+ display: "flex",
180
+ alignItems: "center",
181
+ justifyContent: "center",
182
+ width: "100%",
183
+ height: "100%",
184
+ },
185
+ children: {
186
+ type: "span",
187
+ props: {
188
+ style: {
189
+ fontSize,
190
+ fontFamily,
191
+ fontWeight: 700,
192
+ color,
193
+ textShadow: "0 2px 10px rgba(0,0,0,0.5)",
194
+ textAlign: "center",
195
+ },
196
+ children: step.title,
197
+ },
198
+ },
199
+ },
200
+ };
201
+
202
+ const textW = Math.round(w * 0.9);
203
+ const textH = Math.round(h * 0.3);
204
+
205
+ const svg = await satori(jsx as any, { width: textW, height: textH, fonts });
206
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: textW } });
207
+ const textPng = Buffer.from(resvg.render().asPng());
208
+
209
+ // Position based on zone
210
+ const { resolvePosition } = await import("./zones.ts");
211
+ const { ZONES } = await import("./types.ts");
212
+ const zoneName = step.zone || "hero-center";
213
+ const pos = resolvePosition(
214
+ zoneName as any,
215
+ w, h, textW, textH, "center"
216
+ );
217
+
218
+ return sharp(buffer)
219
+ .composite([{ input: textPng, left: Math.max(0, pos.x), top: Math.max(0, pos.y), blend: "over" }])
220
+ .png()
221
+ .toBuffer();
222
+ }
223
+
224
+ // Gradient background helper (used by templates)
225
+ export async function createGradientBackground(
226
+ gradient: string,
227
+ width: number,
228
+ height: number
229
+ ): Promise<Buffer> {
230
+ const fonts = await loadFonts();
231
+
232
+ const jsx = {
233
+ type: "div",
234
+ props: {
235
+ style: { width, height, backgroundImage: gradient, display: "flex" },
236
+ children: [],
237
+ },
238
+ };
239
+
240
+ const svg = await satori(jsx as any, { width, height, fonts });
241
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: width } });
242
+ return Buffer.from(resvg.render().asPng());
243
+ }
@@ -0,0 +1,124 @@
1
+ import sharp from "sharp";
2
+ import { Resvg } from "@resvg/resvg-js";
3
+ import type { ColorGrade } from "./types.ts";
4
+
5
+ export async function applyColorGrade(
6
+ buffer: Buffer,
7
+ grade: ColorGrade
8
+ ): Promise<Buffer> {
9
+ let img = sharp(buffer);
10
+
11
+ switch (grade) {
12
+ case "cinematic":
13
+ // Teal shadows, warm highlights
14
+ img = img
15
+ .recomb([
16
+ [1.05, 0, 0.05],
17
+ [0, 1.1, 0],
18
+ [0.05, 0, 1.15],
19
+ ])
20
+ .linear(1.1, -10);
21
+ break;
22
+ case "moody":
23
+ // Desaturated, crushed blacks
24
+ img = img.modulate({ saturation: 0.8 }).linear(1.15, 5);
25
+ break;
26
+ case "vibrant":
27
+ img = img.modulate({ saturation: 1.3 });
28
+ break;
29
+ case "clean":
30
+ img = img.sharpen({ sigma: 0.5 });
31
+ break;
32
+ case "warm-editorial":
33
+ img = img
34
+ .tint({ r: 255, g: 220, b: 180 })
35
+ .modulate({ saturation: 0.9 });
36
+ break;
37
+ case "cool-tech":
38
+ img = img
39
+ .tint({ r: 180, g: 200, b: 255 })
40
+ .linear(1.2, -15);
41
+ break;
42
+ }
43
+
44
+ return img.png().toBuffer();
45
+ }
46
+
47
+ export async function applyGrain(
48
+ buffer: Buffer,
49
+ intensity = 0.07
50
+ ): Promise<Buffer> {
51
+ const meta = await sharp(buffer).metadata();
52
+ const w = meta.width!;
53
+ const h = meta.height!;
54
+
55
+ // Generate noise texture
56
+ const noiseData = Buffer.alloc(w * h * 4);
57
+ for (let i = 0; i < noiseData.length; i += 4) {
58
+ const v = Math.round(128 + (Math.random() - 0.5) * 80);
59
+ noiseData[i] = v; // R
60
+ noiseData[i + 1] = v; // G
61
+ noiseData[i + 2] = v; // B
62
+ noiseData[i + 3] = Math.round(255 * intensity); // A
63
+ }
64
+
65
+ const noiseBuffer = await sharp(noiseData, {
66
+ raw: { width: w, height: h, channels: 4 },
67
+ })
68
+ .png()
69
+ .toBuffer();
70
+
71
+ return sharp(buffer)
72
+ .composite([{ input: noiseBuffer, blend: "overlay" }])
73
+ .png()
74
+ .toBuffer();
75
+ }
76
+
77
+ export async function applyVignette(
78
+ buffer: Buffer,
79
+ opacity = 0.35
80
+ ): Promise<Buffer> {
81
+ const meta = await sharp(buffer).metadata();
82
+ const w = meta.width!;
83
+ const h = meta.height!;
84
+
85
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">
86
+ <defs>
87
+ <radialGradient id="vig" cx="50%" cy="50%" r="70%">
88
+ <stop offset="50%" stop-color="black" stop-opacity="0"/>
89
+ <stop offset="100%" stop-color="black" stop-opacity="${opacity}"/>
90
+ </radialGradient>
91
+ </defs>
92
+ <rect width="${w}" height="${h}" fill="url(#vig)"/>
93
+ </svg>`;
94
+
95
+ const resvg = new Resvg(svg, { fitTo: { mode: "width", value: w } });
96
+ const vigBuffer = Buffer.from(resvg.render().asPng());
97
+
98
+ return sharp(buffer)
99
+ .composite([{ input: vigBuffer, blend: "multiply" }])
100
+ .png()
101
+ .toBuffer();
102
+ }
103
+
104
+ export async function finalizeOutput(
105
+ buffer: Buffer,
106
+ outputPath: string,
107
+ quality = 90
108
+ ): Promise<void> {
109
+ const ext = outputPath.split(".").pop()?.toLowerCase();
110
+ let img = sharp(buffer);
111
+
112
+ switch (ext) {
113
+ case "jpg":
114
+ case "jpeg":
115
+ await img.jpeg({ quality: quality || 85 }).toFile(outputPath);
116
+ break;
117
+ case "webp":
118
+ await img.webp({ quality: quality || 85 }).toFile(outputPath);
119
+ break;
120
+ default:
121
+ await img.png({ quality: quality || 90 }).toFile(outputPath);
122
+ break;
123
+ }
124
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { PlatformPreset, StylePreset, ColorGrade } from "./types.ts";
2
+
3
+ export const PLATFORM_PRESETS: Record<string, PlatformPreset> = {
4
+ "blog-featured": {
5
+ width: 1200,
6
+ height: 630,
7
+ safeZone: "10% inset all sides",
8
+ minHeading: 48,
9
+ defaultGrade: "cinematic",
10
+ },
11
+ "blog-inline": {
12
+ width: 800,
13
+ height: 450,
14
+ safeZone: "5% inset",
15
+ },
16
+ "og-image": {
17
+ width: 1200,
18
+ height: 630,
19
+ safeZone: "key content within center 1000x500",
20
+ },
21
+ "twitter-header": {
22
+ width: 1500,
23
+ height: 500,
24
+ safeZone: "center 60% only (sides crop on mobile)",
25
+ },
26
+ "instagram-square": {
27
+ width: 1080,
28
+ height: 1080,
29
+ safeZone: "10% inset, avoid bottom 15%",
30
+ },
31
+ "instagram-story": {
32
+ width: 1080,
33
+ height: 1920,
34
+ safeZone: "avoid top 15% and bottom 20%",
35
+ },
36
+ "linkedin-post": {
37
+ width: 1200,
38
+ height: 627,
39
+ safeZone: "similar to OG",
40
+ },
41
+ "youtube-thumbnail": {
42
+ width: 1280,
43
+ height: 720,
44
+ safeZone: "avoid bottom-right 20% (duration badge)",
45
+ notes: "High contrast required, text readable at small size",
46
+ },
47
+ "shopify-app-listing": {
48
+ width: 1200,
49
+ height: 628,
50
+ safeZone: "10% inset",
51
+ },
52
+ };
53
+
54
+ export const STYLE_PRESETS: Record<string, StylePreset> = {
55
+ "dark-tech": {
56
+ falPromptStyle:
57
+ "deep purple/blue tones, neon accents, particle dust, tech atmosphere",
58
+ font: "Space Grotesk",
59
+ defaultGrade: "cinematic",
60
+ glowDefault: "derive from asset dominant color",
61
+ },
62
+ "minimal-light": {
63
+ falPromptStyle: "clean white/soft gray, subtle shadows, airy, bright",
64
+ font: "Inter",
65
+ defaultGrade: "clean",
66
+ },
67
+ "gradient-mesh": {
68
+ falPromptStyle: "vibrant multi-color mesh gradients, bold saturated",
69
+ font: "Space Grotesk",
70
+ defaultGrade: "vibrant",
71
+ },
72
+ editorial: {
73
+ falPromptStyle: "muted earth tones, textured paper, sophisticated",
74
+ font: "DM Serif Display",
75
+ defaultGrade: "warm-editorial",
76
+ },
77
+ glassmorphism: {
78
+ falPromptStyle: "frosted layers, translucent surfaces, soft blur",
79
+ font: "Inter",
80
+ defaultGrade: "cool-tech",
81
+ },
82
+ };
83
+
84
+ export const DEPTH_ORDER: Record<string, number> = {
85
+ background: 0,
86
+ midground: 1,
87
+ foreground: 2,
88
+ overlay: 3,
89
+ frame: 4,
90
+ };
91
+
92
+ export const AUTO_SHADOW: Record<string, { blur: number; offset: number; opacity: number }> = {
93
+ midground: { blur: 10, offset: 4, opacity: 0.2 },
94
+ foreground: { blur: 20, offset: 8, opacity: 0.3 },
95
+ overlay: { blur: 4, offset: 2, opacity: 0.15 },
96
+ };
97
+
98
+ export const COLOR_GRADES: Record<ColorGrade, string> = {
99
+ cinematic: "Slight teal shadows, warm highlights",
100
+ moody: "Desaturated, crushed blacks",
101
+ vibrant: "Boosted saturation, warmth",
102
+ clean: "Slight sharpening only",
103
+ "warm-editorial": "Golden tones, slight desat",
104
+ "cool-tech": "Blue shift, high contrast",
105
+ };
@@ -0,0 +1,17 @@
1
+ import type { SatoriJSX } from "./types.ts";
2
+
3
+ // Convert our JSON JSX tree to React-like elements for Satori
4
+ export function jsxToReact(node: SatoriJSX | string): any {
5
+ if (typeof node === "string") return node;
6
+
7
+ const { tag, props = {}, children = [] } = node;
8
+ const childElements = children.map((c) => jsxToReact(c));
9
+
10
+ return {
11
+ type: tag,
12
+ props: {
13
+ ...props,
14
+ children: childElements.length === 1 ? childElements[0] : childElements.length > 0 ? childElements : undefined,
15
+ },
16
+ };
17
+ }