Skip to content

Commit 7e2b2d9

Browse files
committed
feat(api): add new image processing nodes and update dependencies for Exif and Photon support
1 parent e03319b commit 7e2b2d9

31 files changed

Lines changed: 2919 additions & 18 deletions

apps/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,14 @@
3939
},
4040
"dependencies": {
4141
"@aws-sdk/client-ses": "^3.812.0",
42+
"@cf-wasm/photon": "^0.1.30",
4243
"@dafthunk/types": "workspace:*",
4344
"@hono/oauth-providers": "^0.7.1",
4445
"@hono/zod-validator": "^0.5.0",
4546
"@sendgrid/mail": "^8.1.5",
4647
"cloudflare": "^4.2.0",
4748
"drizzle-orm": "0.43.1",
49+
"exifreader": "^4.31.0",
4850
"hono": "^4.7.8",
4951
"jose": "^6.0.10",
5052
"jsonpath-plus": "^10.3.0",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import ExifReader from "exifreader";
2+
import { NodeExecution, NodeType } from "@dafthunk/types";
3+
4+
import {
5+
ExecutableNode,
6+
ImageParameter,
7+
NodeContext,
8+
} from "../types";
9+
10+
/**
11+
* Extracts EXIF data from an image using the ExifReader library.
12+
*/
13+
export class ExifReaderNode extends ExecutableNode {
14+
public static readonly nodeType: NodeType = {
15+
id: "exif-reader",
16+
name: "EXIF Reader",
17+
type: "exif-reader",
18+
description: "Extracts EXIF data from an image.",
19+
category: "Image",
20+
icon: "file-text", // Using 'file-text' as an icon, similar to info.
21+
inputs: [
22+
{
23+
name: "image",
24+
type: "image",
25+
description: "The input image to extract EXIF data from.",
26+
required: true,
27+
},
28+
],
29+
outputs: [
30+
{
31+
name: "exifData",
32+
type: "json", // Outputting as a JSON string
33+
description: "EXIF data extracted from the image as a JSON string.",
34+
},
35+
{
36+
name: "imagePassthrough",
37+
type: "image",
38+
description:
39+
"The original image, passed through for further processing.",
40+
},
41+
],
42+
};
43+
44+
async execute(context: NodeContext): Promise<NodeExecution> {
45+
const inputs = context.inputs as {
46+
image?: ImageParameter;
47+
};
48+
49+
const { image } = inputs;
50+
51+
if (!image || !image.data) {
52+
return this.createErrorResult("Input image is missing or invalid.");
53+
}
54+
55+
try {
56+
// ExifReader.load() expects a Buffer or ArrayBuffer.
57+
// Assuming image.data is Uint8Array, its .buffer property is ArrayBuffer.
58+
const tags = await ExifReader.load(image.data.buffer);
59+
60+
// Remove potentially very large MakerNote tag to avoid large JSON output
61+
if (tags["MakerNote"]) {
62+
delete tags["MakerNote"];
63+
}
64+
// Also remove thumbnail data if present
65+
// Add type assertion to handle complex type of tags["thumbnail"]
66+
const thumbnailTag = tags["thumbnail"] as any;
67+
if (thumbnailTag && thumbnailTag.image) {
68+
delete thumbnailTag.image;
69+
}
70+
71+
72+
// Convert tags to a JSON string.
73+
// BigInt values need to be handled for JSON.stringify
74+
const replacer = (_key: string, value: any) =>
75+
typeof value === "bigint" ? value.toString() : value;
76+
const exifDataJson = JSON.stringify(tags, replacer, 2);
77+
78+
console.log(exifDataJson);
79+
80+
return this.createSuccessResult({
81+
exifData: exifDataJson,
82+
imagePassthrough: image,
83+
});
84+
} catch (error) {
85+
const errorMessage =
86+
error instanceof Error
87+
? error.message
88+
: "Unknown error during EXIF data extraction.";
89+
console.error(`[ExifReaderNode] Error: ${errorMessage}`, error);
90+
if (error instanceof Error && error.message.includes("No Exif data")) {
91+
return this.createSuccessResult({
92+
exifData: "{}", // Return empty JSON if no EXIF data found
93+
imagePassthrough: image,
94+
});
95+
}
96+
return this.createErrorResult(errorMessage);
97+
}
98+
}
99+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { add_noise_rand, PhotonImage } from "@cf-wasm/photon";
2+
import { NodeExecution, NodeType } from "@dafthunk/types";
3+
4+
import { ExecutableNode, ImageParameter, NodeContext } from "../types";
5+
6+
/**
7+
* This node adds random Gaussian noise to an input image using the Photon library.
8+
*/
9+
export class PhotonAddNoiseNode extends ExecutableNode {
10+
public static readonly nodeType: NodeType = {
11+
id: "photon-add-noise",
12+
name: "Photon Add Noise",
13+
type: "photon-add-noise",
14+
description: "Adds randomized Gaussian noise to an image.",
15+
category: "Image",
16+
icon: "sparkles", // Icon suggesting noise or a scattered effect
17+
inputs: [
18+
{
19+
name: "image",
20+
type: "image",
21+
description: "The input image to add noise to.",
22+
required: true,
23+
},
24+
],
25+
outputs: [
26+
{
27+
name: "image",
28+
type: "image",
29+
description: "The image with added noise (PNG format).",
30+
},
31+
],
32+
};
33+
34+
async execute(context: NodeContext): Promise<NodeExecution> {
35+
const inputs = context.inputs as {
36+
image?: ImageParameter;
37+
};
38+
39+
const { image } = inputs;
40+
41+
if (!image || !image.data || !image.mimeType) {
42+
return this.createErrorResult("Input image is missing or invalid.");
43+
}
44+
45+
let photonImage: PhotonImage | undefined;
46+
47+
try {
48+
photonImage = PhotonImage.new_from_byteslice(image.data);
49+
50+
add_noise_rand(photonImage);
51+
52+
const outputBytes = photonImage.get_bytes();
53+
54+
if (!outputBytes || outputBytes.length === 0) {
55+
return this.createErrorResult(
56+
"Photon add noise operation resulted in empty image data."
57+
);
58+
}
59+
60+
const resultImage: ImageParameter = {
61+
data: outputBytes,
62+
mimeType: "image/png",
63+
};
64+
65+
return this.createSuccessResult({ image: resultImage });
66+
} catch (error) {
67+
const errorMessage =
68+
error instanceof Error
69+
? error.message
70+
: "Unknown error during Photon image add noise operation.";
71+
console.error(`[PhotonAddNoiseNode] Error: ${errorMessage}`, error);
72+
return this.createErrorResult(errorMessage);
73+
} finally {
74+
if (photonImage) {
75+
photonImage.free();
76+
}
77+
}
78+
}
79+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { adjust_brightness, PhotonImage } from "@cf-wasm/photon";
2+
import { NodeExecution, NodeType } from "@dafthunk/types";
3+
4+
import { ExecutableNode, ImageParameter, NodeContext } from "../types";
5+
6+
/**
7+
* This node adjusts the brightness of an input image using the Photon library.
8+
*/
9+
export class PhotonAdjustBrightnessNode extends ExecutableNode {
10+
public static readonly nodeType: NodeType = {
11+
id: "photon-adjust-brightness",
12+
name: "Photon Adjust Brightness",
13+
type: "photon-adjust-brightness",
14+
description:
15+
"Adjusts image brightness. Positive values increase, negative values decrease.",
16+
category: "Image",
17+
icon: "sun",
18+
inputs: [
19+
{
20+
name: "image",
21+
type: "image",
22+
description: "The input image to adjust.",
23+
required: true,
24+
},
25+
{
26+
name: "amount",
27+
type: "number",
28+
description:
29+
"Brightness adjustment amount (e.g., -100 to 100). Positive increases, negative decreases.",
30+
required: true,
31+
value: 0, // Default to no change
32+
},
33+
],
34+
outputs: [
35+
{
36+
name: "image",
37+
type: "image",
38+
description: "The brightness-adjusted image (PNG format).",
39+
},
40+
],
41+
};
42+
43+
async execute(context: NodeContext): Promise<NodeExecution> {
44+
const inputs = context.inputs as {
45+
image?: ImageParameter;
46+
amount?: number;
47+
};
48+
49+
const { image, amount } = inputs;
50+
51+
if (!image || !image.data || !image.mimeType) {
52+
return this.createErrorResult("Input image is missing or invalid.");
53+
}
54+
if (typeof amount !== "number") {
55+
return this.createErrorResult("Brightness amount must be a number.");
56+
}
57+
58+
let photonImage: PhotonImage | undefined;
59+
60+
try {
61+
// Create a PhotonImage instance from the input bytes
62+
photonImage = PhotonImage.new_from_byteslice(image.data);
63+
64+
// Adjust brightness
65+
adjust_brightness(photonImage, amount);
66+
67+
// Get the adjusted image bytes in PNG format
68+
const outputBytes = photonImage.get_bytes();
69+
70+
if (!outputBytes || outputBytes.length === 0) {
71+
return this.createErrorResult(
72+
"Photon brightness adjustment resulted in empty image data."
73+
);
74+
}
75+
76+
const adjustedImage: ImageParameter = {
77+
data: outputBytes,
78+
mimeType: "image/png",
79+
};
80+
81+
return this.createSuccessResult({ image: adjustedImage });
82+
} catch (error) {
83+
const errorMessage =
84+
error instanceof Error
85+
? error.message
86+
: "Unknown error during Photon image brightness adjustment.";
87+
console.error(
88+
`[PhotonAdjustBrightnessNode] Error: ${errorMessage}`,
89+
error
90+
);
91+
return this.createErrorResult(errorMessage);
92+
} finally {
93+
if (photonImage) {
94+
photonImage.free();
95+
}
96+
}
97+
}
98+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { adjust_contrast, PhotonImage } from "@cf-wasm/photon";
2+
import { NodeExecution, NodeType } from "@dafthunk/types";
3+
4+
import { ExecutableNode, ImageParameter, NodeContext } from "../types";
5+
6+
/**
7+
* This node adjusts the contrast of an input image using the Photon library.
8+
*/
9+
export class PhotonAdjustContrastNode extends ExecutableNode {
10+
public static readonly nodeType: NodeType = {
11+
id: "photon-adjust-contrast",
12+
name: "Photon Adjust Contrast",
13+
type: "photon-adjust-contrast",
14+
description:
15+
"Adjusts image contrast. Values typically range from -100 to 100.",
16+
category: "Image",
17+
icon: "contrast",
18+
inputs: [
19+
{
20+
name: "image",
21+
type: "image",
22+
description: "The input image to adjust.",
23+
required: true,
24+
},
25+
{
26+
name: "amount",
27+
type: "number",
28+
description:
29+
"Contrast adjustment factor (e.g., -100 to 100). Photon clamps between -255.0 and 255.0.",
30+
required: true,
31+
value: 0, // Default to no change
32+
},
33+
],
34+
outputs: [
35+
{
36+
name: "image",
37+
type: "image",
38+
description: "The contrast-adjusted image (PNG format).",
39+
},
40+
],
41+
};
42+
43+
async execute(context: NodeContext): Promise<NodeExecution> {
44+
const inputs = context.inputs as {
45+
image?: ImageParameter;
46+
amount?: number;
47+
};
48+
49+
const { image, amount } = inputs;
50+
51+
if (!image || !image.data || !image.mimeType) {
52+
return this.createErrorResult("Input image is missing or invalid.");
53+
}
54+
if (typeof amount !== "number") {
55+
return this.createErrorResult("Contrast amount must be a number.");
56+
}
57+
58+
let photonImage: PhotonImage | undefined;
59+
60+
try {
61+
// Create a PhotonImage instance from the input bytes
62+
photonImage = PhotonImage.new_from_byteslice(image.data);
63+
64+
// Adjust contrast
65+
adjust_contrast(photonImage, amount);
66+
67+
// Get the adjusted image bytes in PNG format
68+
const outputBytes = photonImage.get_bytes();
69+
70+
if (!outputBytes || outputBytes.length === 0) {
71+
return this.createErrorResult(
72+
"Photon contrast adjustment resulted in empty image data."
73+
);
74+
}
75+
76+
const adjustedImage: ImageParameter = {
77+
data: outputBytes,
78+
mimeType: "image/png",
79+
};
80+
81+
return this.createSuccessResult({ image: adjustedImage });
82+
} catch (error) {
83+
const errorMessage =
84+
error instanceof Error
85+
? error.message
86+
: "Unknown error during Photon image contrast adjustment.";
87+
console.error(`[PhotonAdjustContrastNode] Error: ${errorMessage}`, error);
88+
return this.createErrorResult(errorMessage);
89+
} finally {
90+
if (photonImage) {
91+
photonImage.free();
92+
}
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)