@cosmicdrift/kumiko-bundled-features 0.190.0 → 0.192.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/package.json +11 -9
- package/src/derivatives-sharp/__tests__/derivatives-sharp.integration.test.ts +124 -0
- package/src/derivatives-sharp/__tests__/render.test.ts +234 -0
- package/src/derivatives-sharp/changes.json +1 -0
- package/src/derivatives-sharp/feature.ts +22 -0
- package/src/derivatives-sharp/index.ts +2 -0
- package/src/derivatives-sharp/render.ts +201 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.192.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"./file-provider-s3-env": "./src/file-provider-s3-env/index.ts",
|
|
69
69
|
"./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
|
|
70
70
|
"./file-derivatives": "./src/file-derivatives/index.ts",
|
|
71
|
+
"./derivatives-sharp": "./src/derivatives-sharp/index.ts",
|
|
71
72
|
"./files": "./src/files/index.ts",
|
|
72
73
|
"./user-data-rights": "./src/user-data-rights/index.ts",
|
|
73
74
|
"./user-data-rights/web": "./src/user-data-rights/web/index.ts",
|
|
@@ -124,24 +125,25 @@
|
|
|
124
125
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
125
126
|
},
|
|
126
127
|
"dependencies": {
|
|
127
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
128
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.192.0",
|
|
129
|
+
"@cosmicdrift/kumiko-framework": "0.192.0",
|
|
130
|
+
"@cosmicdrift/kumiko-headless": "0.192.0",
|
|
131
|
+
"@cosmicdrift/kumiko-renderer": "0.192.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer-web": "0.192.0",
|
|
133
|
+
"@cosmicdrift/kumiko-types": "0.192.0",
|
|
133
134
|
"@mollie/api-client": "^4.5.0",
|
|
134
|
-
"imapflow": "^1.3.3",
|
|
135
|
-
"mailparser": "^3.9.8",
|
|
136
135
|
"@node-rs/argon2": "^2.0.2",
|
|
137
136
|
"@types/mailparser": "^3.4.6",
|
|
138
137
|
"@types/nodemailer": "^8.0.0",
|
|
139
138
|
"clsx": "^2.1.1",
|
|
139
|
+
"imapflow": "^1.3.3",
|
|
140
140
|
"lucide-react": "^1.14.0",
|
|
141
|
+
"mailparser": "^3.9.8",
|
|
141
142
|
"marked": "^18.0.3",
|
|
142
143
|
"nodemailer": "^9.0.1",
|
|
143
144
|
"qrcode": "^1.5.4",
|
|
144
145
|
"react": "^19.2.6",
|
|
146
|
+
"sharp": "^0.35.3",
|
|
145
147
|
"stripe": "^22.1.1",
|
|
146
148
|
"tailwind-merge": "^3.6.0"
|
|
147
149
|
},
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Proves the full derive-on-first-use loop through a real sharp render: a
|
|
2
|
+
// write-handler calls ctx.derivatives.variant(), the bytes it stores decode
|
|
3
|
+
// as the promised format/size, and a second call with the same spec hits the
|
|
4
|
+
// cache instead of rendering again.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
+
import { createInMemoryFileProvider } from "@cosmicdrift/kumiko-framework/files";
|
|
10
|
+
import {
|
|
11
|
+
createTestUser,
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
testTenantId,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
16
|
+
import {
|
|
17
|
+
buildMultipartBody,
|
|
18
|
+
patchFileInstanceofForBunTest,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
20
|
+
import sharp from "sharp";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { createConfigFeature } from "../../config";
|
|
23
|
+
import { fileDerivativesFeature } from "../../file-derivatives";
|
|
24
|
+
import { fileFoundationFeature } from "../../file-foundation";
|
|
25
|
+
import { createFilesFeature } from "../../files";
|
|
26
|
+
import { derivativesSharpFeature } from "../feature";
|
|
27
|
+
import { imageMetadata } from "../render";
|
|
28
|
+
|
|
29
|
+
const variantTestFeature = defineFeature("derivativessharptest", (r) => {
|
|
30
|
+
r.requires("file-derivatives");
|
|
31
|
+
r.writeHandler(
|
|
32
|
+
"make-thumb",
|
|
33
|
+
z.object({ fileRefId: z.string() }),
|
|
34
|
+
async (event, ctx) => {
|
|
35
|
+
if (!ctx.derivatives || !ctx.files) {
|
|
36
|
+
return writeFailure(
|
|
37
|
+
new InternalError({ message: "no ctx.derivatives/ctx.files on write-handler ctx" }),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const result = await ctx.derivatives.variant(
|
|
41
|
+
event.payload.fileRefId,
|
|
42
|
+
{ maxEdge: 160, fit: "cover", format: "webp" },
|
|
43
|
+
"thumb",
|
|
44
|
+
);
|
|
45
|
+
const bytes = await ctx.files.ref(result.storageKey).read();
|
|
46
|
+
const meta = await imageMetadata(bytes);
|
|
47
|
+
return { isSuccess: true as const, data: { ...result, ...meta } };
|
|
48
|
+
},
|
|
49
|
+
{ access: { openToAll: true } },
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let stack: TestStack;
|
|
54
|
+
const tenantId = testTenantId(1);
|
|
55
|
+
const user = createTestUser({ id: 1, tenantId, roles: ["Admin"] });
|
|
56
|
+
|
|
57
|
+
async function jpegBytes(width: number, height: number): Promise<Uint8Array> {
|
|
58
|
+
return sharp({ create: { width, height, channels: 3, background: { r: 90, g: 140, b: 200 } } })
|
|
59
|
+
.jpeg()
|
|
60
|
+
.toBuffer();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
patchFileInstanceofForBunTest();
|
|
65
|
+
stack = await setupTestStack({
|
|
66
|
+
features: [
|
|
67
|
+
createConfigFeature(),
|
|
68
|
+
fileFoundationFeature,
|
|
69
|
+
createFilesFeature(),
|
|
70
|
+
fileDerivativesFeature,
|
|
71
|
+
derivativesSharpFeature,
|
|
72
|
+
variantTestFeature,
|
|
73
|
+
],
|
|
74
|
+
files: { storageProvider: createInMemoryFileProvider() },
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await stack.cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function uploadJpeg(): Promise<string> {
|
|
83
|
+
const token = await stack.jwt.sign(user);
|
|
84
|
+
const bytes = await jpegBytes(400, 200);
|
|
85
|
+
const fd = new FormData();
|
|
86
|
+
fd.append("file", new File([Buffer.from(bytes)], "photo.jpg", { type: "image/jpeg" }));
|
|
87
|
+
const { body, contentType } = await buildMultipartBody(fd);
|
|
88
|
+
const res = await stack.app.request("/api/files", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
|
|
91
|
+
body,
|
|
92
|
+
});
|
|
93
|
+
expect(res.status).toBe(201);
|
|
94
|
+
const json = (await res.json()) as { id: string };
|
|
95
|
+
return json.id;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("derivatives-sharp — end to end through ctx.derivatives.variant", () => {
|
|
99
|
+
test("first call renders a real webp thumb, second call hits the cache", async () => {
|
|
100
|
+
const fileId = await uploadJpeg();
|
|
101
|
+
|
|
102
|
+
const first = await stack.http.writeOk<{
|
|
103
|
+
storageKey: string;
|
|
104
|
+
mimeType: string;
|
|
105
|
+
rendered: boolean;
|
|
106
|
+
width: number;
|
|
107
|
+
height: number;
|
|
108
|
+
format: string;
|
|
109
|
+
}>("derivativessharptest:write:make-thumb", { fileRefId: fileId }, user);
|
|
110
|
+
|
|
111
|
+
expect(first.rendered).toBe(true);
|
|
112
|
+
expect(first.mimeType).toBe("image/webp");
|
|
113
|
+
expect(first.format).toBe("webp");
|
|
114
|
+
expect(first.width).toBe(160);
|
|
115
|
+
expect(first.height).toBe(160);
|
|
116
|
+
|
|
117
|
+
const second = await stack.http.writeOk<{ rendered: boolean }>(
|
|
118
|
+
"derivativessharptest:write:make-thumb",
|
|
119
|
+
{ fileRefId: fileId },
|
|
120
|
+
user,
|
|
121
|
+
);
|
|
122
|
+
expect(second.rendered).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Real sharp roundtrips — no mocked pipeline. Fixtures are built with
|
|
2
|
+
// `sharp({ create: {...} })` so every case decodes real bytes end-to-end.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import type { VariantSpec } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import { imageMetadata, renderImage } from "../render";
|
|
8
|
+
|
|
9
|
+
async function jpegFixture(
|
|
10
|
+
width: number,
|
|
11
|
+
height: number,
|
|
12
|
+
color = { r: 200, g: 40, b: 40 },
|
|
13
|
+
): Promise<Buffer> {
|
|
14
|
+
return sharp({ create: { width, height, channels: 3, background: color } })
|
|
15
|
+
.jpeg()
|
|
16
|
+
.toBuffer();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function orientedJpegFixture(width: number, height: number, orientation: number) {
|
|
20
|
+
return sharp({ create: { width, height, channels: 3, background: { r: 200, g: 40, b: 40 } } })
|
|
21
|
+
.jpeg()
|
|
22
|
+
.withMetadata({ orientation })
|
|
23
|
+
.toBuffer();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function pngFixture(
|
|
27
|
+
width: number,
|
|
28
|
+
height: number,
|
|
29
|
+
color = { r: 40, g: 120, b: 200 },
|
|
30
|
+
): Promise<Buffer> {
|
|
31
|
+
return sharp({ create: { width, height, channels: 3, background: color } })
|
|
32
|
+
.png()
|
|
33
|
+
.toBuffer();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Minimal well-formed SVG, valid input for sharp/librsvg — used to prove the
|
|
37
|
+
// renderer rejects it by sniffing bytes, not by trusting a declared mimeType.
|
|
38
|
+
const SVG_BYTES = new TextEncoder().encode(
|
|
39
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="red"/></svg>',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Real bytes just need to decode — the test is about the declared
|
|
43
|
+
// "image/bmp" mimeType having no entry in the source-format encoder map, not
|
|
44
|
+
// about the actual pixel format.
|
|
45
|
+
async function unmappedFormatFixture(width: number, height: number): Promise<Buffer> {
|
|
46
|
+
return sharp({
|
|
47
|
+
create: { width, height, channels: 3, background: { r: 100, g: 100, b: 100 } },
|
|
48
|
+
})
|
|
49
|
+
.tiff()
|
|
50
|
+
.toBuffer();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Two flat-colour halves straddled by the blur test's region, so blurring
|
|
54
|
+
// mixes visibly detectable pixels at the boundary without needing synthetic
|
|
55
|
+
// noise texture inside an otherwise-flat fixture.
|
|
56
|
+
async function twoColorPngFixture(width: number, height: number): Promise<Buffer> {
|
|
57
|
+
const left = { r: 220, g: 20, b: 20 };
|
|
58
|
+
const right = { r: 20, g: 20, b: 220 };
|
|
59
|
+
const rightHalf = await sharp({
|
|
60
|
+
create: { width: width / 2, height, channels: 3, background: right },
|
|
61
|
+
})
|
|
62
|
+
.png()
|
|
63
|
+
.toBuffer();
|
|
64
|
+
return sharp({ create: { width, height, channels: 3, background: left } })
|
|
65
|
+
.composite([{ input: rightHalf, left: width / 2, top: 0 }])
|
|
66
|
+
.png()
|
|
67
|
+
.toBuffer();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// composite() promotes the output to include an alpha channel even when
|
|
71
|
+
// every input was opaque RGB — read the channel count back instead of
|
|
72
|
+
// assuming it, or the byte offsets below silently point at the wrong pixel.
|
|
73
|
+
async function rawPixel(buffer: Uint8Array, x: number, y: number) {
|
|
74
|
+
const { data, info } = await sharp(buffer).raw().toBuffer({ resolveWithObject: true });
|
|
75
|
+
const offset = (y * info.width + x) * info.channels;
|
|
76
|
+
return Array.from(data.subarray(offset, offset + info.channels));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("renderImage — resize/fit", () => {
|
|
80
|
+
test("maxEdge + cover produces an exact square (the thumb preset)", async () => {
|
|
81
|
+
const input = await jpegFixture(400, 200);
|
|
82
|
+
const output = await renderImage(input, { maxEdge: 160, fit: "cover" }, "image/jpeg");
|
|
83
|
+
const meta = await sharp(output).metadata();
|
|
84
|
+
expect(meta.width).toBe(160);
|
|
85
|
+
expect(meta.height).toBe(160);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("size + inside preserves aspect ratio inside the box", async () => {
|
|
89
|
+
const input = await jpegFixture(400, 200);
|
|
90
|
+
const output = await renderImage(
|
|
91
|
+
input,
|
|
92
|
+
{ size: { width: 200, height: 200 }, fit: "inside" },
|
|
93
|
+
"image/jpeg",
|
|
94
|
+
);
|
|
95
|
+
const meta = await sharp(output).metadata();
|
|
96
|
+
expect(meta.width).toBe(200);
|
|
97
|
+
expect(meta.height).toBe(100);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("contain pads to fill the exact box", async () => {
|
|
101
|
+
const input = await jpegFixture(400, 200);
|
|
102
|
+
const output = await renderImage(
|
|
103
|
+
input,
|
|
104
|
+
{ size: { width: 100, height: 100 }, fit: "contain" },
|
|
105
|
+
"image/jpeg",
|
|
106
|
+
);
|
|
107
|
+
const meta = await sharp(output).metadata();
|
|
108
|
+
expect(meta.width).toBe(100);
|
|
109
|
+
expect(meta.height).toBe(100);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("inside with a maxEdge larger than the source never enlarges", async () => {
|
|
113
|
+
const input = await jpegFixture(400, 200);
|
|
114
|
+
const output = await renderImage(input, { maxEdge: 4000, fit: "inside" }, "image/jpeg");
|
|
115
|
+
const meta = await sharp(output).metadata();
|
|
116
|
+
expect(meta.width).toBe(400);
|
|
117
|
+
expect(meta.height).toBe(200);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("renderImage — format contract", () => {
|
|
122
|
+
test("without spec.format the source format is preserved", async () => {
|
|
123
|
+
const input = await jpegFixture(400, 200);
|
|
124
|
+
const output = await renderImage(input, { maxEdge: 100 }, "image/jpeg");
|
|
125
|
+
const meta = await sharp(output).metadata();
|
|
126
|
+
expect(meta.format).toBe("jpeg");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("with spec.format the output switches format", async () => {
|
|
130
|
+
const input = await jpegFixture(400, 200);
|
|
131
|
+
const output = await renderImage(input, { maxEdge: 100, format: "webp" }, "image/jpeg");
|
|
132
|
+
const meta = await sharp(output).metadata();
|
|
133
|
+
expect(meta.format).toBe("webp");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("the image/jpg upload alias is accepted without spec.format", async () => {
|
|
137
|
+
const input = await jpegFixture(400, 200);
|
|
138
|
+
const output = await renderImage(input, { maxEdge: 100 }, "image/jpg");
|
|
139
|
+
const meta = await sharp(output).metadata();
|
|
140
|
+
expect(meta.format).toBe("jpeg");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("renderImage — EXIF", () => {
|
|
145
|
+
test("all EXIF is stripped from the output", async () => {
|
|
146
|
+
const input = await sharp({
|
|
147
|
+
create: { width: 100, height: 100, channels: 3, background: { r: 10, g: 10, b: 10 } },
|
|
148
|
+
})
|
|
149
|
+
.jpeg()
|
|
150
|
+
.withExif({ IFD0: { Copyright: "kumiko-test" } })
|
|
151
|
+
.toBuffer();
|
|
152
|
+
|
|
153
|
+
const output = await renderImage(input, { maxEdge: 100 }, "image/jpeg");
|
|
154
|
+
const meta = await sharp(output).metadata();
|
|
155
|
+
expect(meta.exif).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("EXIF orientation is applied (dimensions swap) then normalized away", async () => {
|
|
159
|
+
const input = await orientedJpegFixture(400, 200, 6);
|
|
160
|
+
const output = await renderImage(input, {}, "image/jpeg");
|
|
161
|
+
const meta = await sharp(output).metadata();
|
|
162
|
+
expect(meta.width).toBe(200);
|
|
163
|
+
expect(meta.height).toBe(400);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("renderImage — blurRegions", () => {
|
|
168
|
+
test("burns blur into the region, leaves the rest byte-identical", async () => {
|
|
169
|
+
const input = await twoColorPngFixture(200, 200);
|
|
170
|
+
const baseline = await renderImage(input, {}, "image/png");
|
|
171
|
+
const spec: VariantSpec = {
|
|
172
|
+
blurRegions: [{ x: 0.35, y: 0.25, width: 0.3, height: 0.5 }],
|
|
173
|
+
};
|
|
174
|
+
const blurred = await renderImage(input, spec, "image/png");
|
|
175
|
+
|
|
176
|
+
// Region centre sits on the red/blue boundary (x≈0.5) — blur mixes the
|
|
177
|
+
// two colours there, so it must differ from the untouched baseline.
|
|
178
|
+
const centreBaseline = await rawPixel(baseline, 100, 100);
|
|
179
|
+
const centreBlurred = await rawPixel(blurred, 100, 100);
|
|
180
|
+
expect(centreBlurred).not.toEqual(centreBaseline);
|
|
181
|
+
|
|
182
|
+
// Far outside the region: composite never touched these pixels.
|
|
183
|
+
const farBaseline = await rawPixel(baseline, 10, 10);
|
|
184
|
+
const farBlurred = await rawPixel(blurred, 10, 10);
|
|
185
|
+
expect(farBlurred).toEqual(farBaseline);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("renderImage — validation", () => {
|
|
190
|
+
test("blur sigma below sharp's supported range throws", async () => {
|
|
191
|
+
const input = await jpegFixture(50, 50);
|
|
192
|
+
await expect(renderImage(input, { blur: 0.1 }, "image/jpeg")).rejects.toThrow(/blur sigma/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("quality out of range throws", async () => {
|
|
196
|
+
const input = await jpegFixture(50, 50);
|
|
197
|
+
await expect(renderImage(input, { quality: 0 }, "image/jpeg")).rejects.toThrow(/quality/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("real SVG bytes are rejected even with a spoofed sourceMimeType", async () => {
|
|
201
|
+
await expect(renderImage(SVG_BYTES, {}, "image/jpeg")).rejects.toThrow(/svg/i);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("real PNG bytes with a spoofed sourceMimeType encode as the declared type", async () => {
|
|
205
|
+
const input = await pngFixture(50, 50);
|
|
206
|
+
const output = await renderImage(input, {}, "image/jpeg");
|
|
207
|
+
const meta = await sharp(output).metadata();
|
|
208
|
+
expect(meta.format).toBe("jpeg");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a source mimeType with no encoder mapping throws unless spec.format is set", async () => {
|
|
212
|
+
const input = await unmappedFormatFixture(50, 50);
|
|
213
|
+
await expect(renderImage(input, {}, "image/bmp")).rejects.toThrow(/no output encoder/);
|
|
214
|
+
|
|
215
|
+
const output = await renderImage(input, { format: "webp" }, "image/bmp");
|
|
216
|
+
const meta = await sharp(output).metadata();
|
|
217
|
+
expect(meta.format).toBe("webp");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("imageMetadata", () => {
|
|
222
|
+
test("returns width/height/format", async () => {
|
|
223
|
+
const input = await jpegFixture(320, 240);
|
|
224
|
+
const meta = await imageMetadata(input);
|
|
225
|
+
expect(meta).toEqual({ width: 320, height: 240, format: "jpeg" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("returns auto-oriented dimensions for EXIF orientation 6", async () => {
|
|
229
|
+
const input = await orientedJpegFixture(400, 200, 6);
|
|
230
|
+
const meta = await imageMetadata(input);
|
|
231
|
+
expect(meta.width).toBe(200);
|
|
232
|
+
expect(meta.height).toBe(400);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
|
|
3
|
+
import { defineFeature, EXT_DERIVATIVE_RENDERER } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import type { DerivativeRendererPlugin } from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
5
|
+
import { renderImage } from "./render";
|
|
6
|
+
|
|
7
|
+
const FEATURE_NAME = "derivatives-sharp";
|
|
8
|
+
|
|
9
|
+
export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
10
|
+
r.describe(
|
|
11
|
+
"Registers an `image/*` renderer against the `derivativeRenderer` extension point declared by `file-derivatives`, backed by sharp. Supports resize/fit (cover, inside, contain), format conversion with quality (webp, avif, jpeg), whole-image blur, and blurring individual regions burned in before resize (see BlurRegion). EXIF orientation is applied and then normalized away, and all other EXIF (including GPS) is dropped. Server-only: sharp is a native binding — never import this from client code.",
|
|
12
|
+
);
|
|
13
|
+
r.uiHints({
|
|
14
|
+
displayLabel: "File Derivatives · Sharp",
|
|
15
|
+
category: "storage",
|
|
16
|
+
recommended: false,
|
|
17
|
+
});
|
|
18
|
+
r.requires("file-derivatives");
|
|
19
|
+
|
|
20
|
+
const plugin: DerivativeRendererPlugin = { render: renderImage };
|
|
21
|
+
r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
|
|
22
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// sharp-backed renderer for the `derivativeRenderer` extension point —
|
|
2
|
+
// resize/fit, format+quality re-encode, whole-image blur, and pre-resize
|
|
3
|
+
// region blur (see BlurRegion). Server-only: sharp is a native binding.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
BlurRegion,
|
|
7
|
+
DerivativeRendererPlugin,
|
|
8
|
+
VariantFit,
|
|
9
|
+
VariantSpec,
|
|
10
|
+
} from "@cosmicdrift/kumiko-types/derivatives-types";
|
|
11
|
+
import type { OverlayOptions, ResizeOptions, Sharp } from "sharp";
|
|
12
|
+
import sharp from "sharp";
|
|
13
|
+
|
|
14
|
+
const MIN_SHARP_SIGMA = 0.3;
|
|
15
|
+
const MAX_SHARP_SIGMA = 1000;
|
|
16
|
+
|
|
17
|
+
function normalizeMimeType(mimeType: string): string {
|
|
18
|
+
return mimeType.toLowerCase().split(";")[0]?.trim() ?? "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clampSigma(sigma: number): number {
|
|
22
|
+
return Math.min(MAX_SHARP_SIGMA, Math.max(MIN_SHARP_SIGMA, sigma));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function assertValidRegion(region: BlurRegion): void {
|
|
26
|
+
const values = [region.x, region.y, region.width, region.height];
|
|
27
|
+
if (values.some((v) => !Number.isFinite(v)) || region.width <= 0 || region.height <= 0) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`derivatives-sharp: invalid blurRegion ${JSON.stringify(region)} — x/y/width/height must be finite and width/height must be > 0.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Burns each region into the auto-oriented base image, one extract+blur+
|
|
35
|
+
// composite pass per region. Runs BEFORE resize: the regions are relative to
|
|
36
|
+
// the original pixels, and a `fit: "cover"` crop shifts pixel coordinates in
|
|
37
|
+
// a way a post-resize mapping can't reconstruct.
|
|
38
|
+
async function applyBlurRegions(
|
|
39
|
+
data: Buffer,
|
|
40
|
+
width: number,
|
|
41
|
+
height: number,
|
|
42
|
+
regions: readonly BlurRegion[],
|
|
43
|
+
): Promise<Buffer> {
|
|
44
|
+
const overlays: OverlayOptions[] = [];
|
|
45
|
+
|
|
46
|
+
for (const region of regions) {
|
|
47
|
+
assertValidRegion(region);
|
|
48
|
+
|
|
49
|
+
// AI-detected/user-corrected coordinates can run slightly outside the
|
|
50
|
+
// frame — clamp instead of rejecting.
|
|
51
|
+
const left = Math.min(Math.max(Math.round(region.x * width), 0), width);
|
|
52
|
+
const top = Math.min(Math.max(Math.round(region.y * height), 0), height);
|
|
53
|
+
const rectWidth = Math.min(Math.round(region.width * width), width - left);
|
|
54
|
+
const rectHeight = Math.min(Math.round(region.height * height), height - top);
|
|
55
|
+
if (rectWidth < 1 || rectHeight < 1) continue;
|
|
56
|
+
|
|
57
|
+
// ponytail: fixed size-proportional sigma heuristic (no per-region sigma
|
|
58
|
+
// input) — good enough until a caller needs to tune blur strength itself.
|
|
59
|
+
const sigma = clampSigma(Math.max(4, Math.min(rectWidth, rectHeight) / 6));
|
|
60
|
+
|
|
61
|
+
const patch = await sharp(data)
|
|
62
|
+
.extract({ left, top, width: rectWidth, height: rectHeight })
|
|
63
|
+
.blur(sigma)
|
|
64
|
+
.toBuffer();
|
|
65
|
+
overlays.push({ input: patch, left, top });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (overlays.length === 0) return data;
|
|
69
|
+
return sharp(data).composite(overlays).toBuffer();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildResizeOptions(width: number, height: number, fit: VariantFit): ResizeOptions {
|
|
73
|
+
const options: ResizeOptions = { width, height, fit };
|
|
74
|
+
if (fit === "inside") {
|
|
75
|
+
// Max-box: never enlarge past the source.
|
|
76
|
+
options.withoutEnlargement = true;
|
|
77
|
+
}
|
|
78
|
+
if (fit === "contain") {
|
|
79
|
+
// sharp pads "contain" with opaque black by default — transparent instead
|
|
80
|
+
// so only formats that keep alpha show a border at all (jpeg flattens it).
|
|
81
|
+
options.background = { r: 0, g: 0, b: 0, alpha: 0 };
|
|
82
|
+
}
|
|
83
|
+
return options;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// HARD CONTRACT (see outputMimeType in derivatives-context.ts): with
|
|
87
|
+
// spec.format set, the output MUST be that format; with it unset, the
|
|
88
|
+
// output MUST stay the source format. The caller derives the mimeType from
|
|
89
|
+
// the spec alone and never inspects the bytes, so drifting from this silently
|
|
90
|
+
// mislabels the stored variant. sourceMimeType is a client-declared upload
|
|
91
|
+
// label, not sniffed from bytes, so we can't lean on sharp's "no encoder
|
|
92
|
+
// call" passthrough to honor it — the encoder is always called explicitly
|
|
93
|
+
// from the map below, and an unmapped source mimeType throws instead of
|
|
94
|
+
// silently keeping whatever format sharp actually decoded.
|
|
95
|
+
const SOURCE_FORMAT_ENCODERS: Record<string, (pipeline: Sharp, quality?: number) => Sharp> = {
|
|
96
|
+
"image/jpeg": (pipeline, quality) =>
|
|
97
|
+
pipeline.jpeg(quality !== undefined ? { quality } : undefined),
|
|
98
|
+
// The upload whitelist admits the sloppy-but-common image/jpg alias
|
|
99
|
+
"image/jpg": (pipeline, quality) =>
|
|
100
|
+
pipeline.jpeg(quality !== undefined ? { quality } : undefined),
|
|
101
|
+
"image/png": (pipeline, quality) => pipeline.png(quality !== undefined ? { quality } : undefined),
|
|
102
|
+
"image/webp": (pipeline, quality) =>
|
|
103
|
+
pipeline.webp(quality !== undefined ? { quality } : undefined),
|
|
104
|
+
"image/avif": (pipeline, quality) =>
|
|
105
|
+
pipeline.avif(quality !== undefined ? { quality } : undefined),
|
|
106
|
+
// sharp's GifOptions has no quality knob (palette/dither instead) — ignore
|
|
107
|
+
// the value here rather than pass it through.
|
|
108
|
+
"image/gif": (pipeline) => pipeline.gif(),
|
|
109
|
+
"image/tiff": (pipeline, quality) =>
|
|
110
|
+
pipeline.tiff(quality !== undefined ? { quality } : undefined),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
function applyEncoder(pipeline: Sharp, spec: VariantSpec, sourceMimeType: string): Sharp {
|
|
114
|
+
const quality = spec.quality;
|
|
115
|
+
|
|
116
|
+
if (spec.format) {
|
|
117
|
+
switch (spec.format) {
|
|
118
|
+
case "webp":
|
|
119
|
+
return pipeline.webp(quality !== undefined ? { quality } : undefined);
|
|
120
|
+
case "avif":
|
|
121
|
+
return pipeline.avif(quality !== undefined ? { quality } : undefined);
|
|
122
|
+
case "jpeg":
|
|
123
|
+
return pipeline.jpeg(quality !== undefined ? { quality } : undefined);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const encode = SOURCE_FORMAT_ENCODERS[sourceMimeType];
|
|
128
|
+
if (!encode) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`derivatives-sharp: no output encoder for source mimeType "${sourceMimeType}" — set spec.format to pick an explicit output format.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return encode(pipeline, quality);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const renderImage: DerivativeRendererPlugin["render"] = async (
|
|
137
|
+
input,
|
|
138
|
+
spec,
|
|
139
|
+
sourceMimeType,
|
|
140
|
+
) => {
|
|
141
|
+
const normalizedSource = normalizeMimeType(sourceMimeType);
|
|
142
|
+
|
|
143
|
+
// sourceMimeType is a client-declared upload label, not sniffed — decide
|
|
144
|
+
// the SVG rejection from the actual bytes so a mislabeled upload can't
|
|
145
|
+
// reach libvips as SVG.
|
|
146
|
+
const sniffed = await sharp(input).metadata();
|
|
147
|
+
if (sniffed.format === "svg") {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"derivatives-sharp: SVG is not supported — rendering untrusted SVG through libvips is a trust boundary this renderer doesn't need to cross.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (spec.quality !== undefined && (spec.quality < 1 || spec.quality > 100)) {
|
|
154
|
+
throw new Error(`derivatives-sharp: quality ${spec.quality} is out of range (1–100).`);
|
|
155
|
+
}
|
|
156
|
+
if (spec.blur !== undefined && (spec.blur < MIN_SHARP_SIGMA || spec.blur > MAX_SHARP_SIGMA)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`derivatives-sharp: blur sigma ${spec.blur} is out of sharp's supported range (${MIN_SHARP_SIGMA}–${MAX_SHARP_SIGMA}).`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// rotate() with no argument applies the EXIF orientation and normalizes it
|
|
163
|
+
// away. sharp drops all other metadata by default — we never call
|
|
164
|
+
// .withMetadata()/.keepExif(), which is exactly what strips GPS/EXIF from
|
|
165
|
+
// the output.
|
|
166
|
+
let pipeline = sharp(input).rotate();
|
|
167
|
+
|
|
168
|
+
if (spec.blurRegions && spec.blurRegions.length > 0) {
|
|
169
|
+
const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
|
|
170
|
+
const blurred = await applyBlurRegions(data, info.width, info.height, spec.blurRegions);
|
|
171
|
+
pipeline = sharp(blurred);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const fit = spec.fit ?? "inside";
|
|
175
|
+
if (spec.size) {
|
|
176
|
+
pipeline = pipeline.resize(buildResizeOptions(spec.size.width, spec.size.height, fit));
|
|
177
|
+
} else if (spec.maxEdge) {
|
|
178
|
+
pipeline = pipeline.resize(buildResizeOptions(spec.maxEdge, spec.maxEdge, fit));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (spec.blur !== undefined) {
|
|
182
|
+
pipeline = pipeline.blur(spec.blur);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
pipeline = applyEncoder(pipeline, spec, normalizedSource);
|
|
186
|
+
|
|
187
|
+
const buffer = await pipeline.toBuffer();
|
|
188
|
+
return new Uint8Array(buffer);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export async function imageMetadata(
|
|
192
|
+
input: Uint8Array,
|
|
193
|
+
): Promise<{ width: number; height: number; format: string }> {
|
|
194
|
+
const metadata = await sharp(input).metadata();
|
|
195
|
+
const width = metadata.autoOrient.width;
|
|
196
|
+
const height = metadata.autoOrient.height;
|
|
197
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || !metadata.format) {
|
|
198
|
+
throw new Error("derivatives-sharp: imageMetadata got a response without width/height/format.");
|
|
199
|
+
}
|
|
200
|
+
return { width, height, format: metadata.format };
|
|
201
|
+
}
|