@frockbot/plugin-image 0.0.0 → 0.1.1

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/src/agent.ts ADDED
@@ -0,0 +1,628 @@
1
+ // The image-generation runtime Contribution: one tool, `generate_image`.
2
+ //
3
+ // It has no authority of its own and holds no credential. It receives a narrow
4
+ // {@link ImageModelV1} the host adapted from a platform binding, and the
5
+ // `WorkspaceFilesV1` the Bot Durable Object already constructs, and it turns
6
+ // one prompt into one durable, attributed file.
7
+ //
8
+ // Three constitutional rules shape everything below.
9
+ //
10
+ // 1. "Record durable execution intent before invoking an external side
11
+ // effect. Only effects an interface declares read-only are exempt."
12
+ // Generating an image is billed and durable, so `image/generate-intent` is
13
+ // appended *and flushed* before the model is called, and `image/generated`
14
+ // after the Workspace write settles. This is the `skill/write-intent` /
15
+ // `skill/written` pattern, verbatim.
16
+ // 2. "Recovery never silently duplicates ... tool calls". The tool is
17
+ // `idempotent: false`, so the registry will never re-run it to settle an
18
+ // open effect; it must answer through `reconcile`, which reads the
19
+ // effect-keyed object out of the Workspace and never calls the model.
20
+ // 3. "Failures are observable through durable state". Every refusal is an
21
+ // `isError: true` result with a stable reason, never a throw.
22
+ //
23
+ // The durable `tool/result` is stable JSON naming the stored object — a path,
24
+ // a generation and a content hash. Image bytes never enter the event log:
25
+ // `SessionEventMap["tool/result"].content` is a string that is replayed into
26
+ // every later model request of the Turn, so a base64 image there would be
27
+ // re-sent on every step and would make the log unreadable. A reader that wants
28
+ // the pixels reads the Workspace file the result names.
29
+ //
30
+ // HIBERNATION. Nothing here reaches the Computer registry or a Computer
31
+ // provider. The Workspace surface is object storage with generations recorded
32
+ // in the Bot's Durable Object, so `generate_image` works with the Computer
33
+ // hibernated, exactly as Skills and Memory do.
34
+ import type {
35
+ Session,
36
+ SessionEvent,
37
+ ToolDefinition,
38
+ ToolEffectReconciliation,
39
+ ToolExecutionContext,
40
+ ToolExecutionResult,
41
+ TurnTypeV1,
42
+ WorkspaceFilesV1,
43
+ WorkspacePathV1,
44
+ WorkspaceWriteRequestV1,
45
+ } from "@frockbot/kernel-contracts";
46
+ import { WORKSPACE_MAX_FILE_BYTES } from "@frockbot/kernel-contracts";
47
+ // Merges the Agent loop's event declarations into the cordis Context type.
48
+ import type {} from "@frockbot/kernel-agent-loop/agent";
49
+ import type { Plugin } from "cordis";
50
+ import {
51
+ decodeImageDimensionsV1,
52
+ sha256HexOfTextV1,
53
+ sha256HexV1,
54
+ type ImageDimensionsV1,
55
+ } from "./bytes.js";
56
+ import {
57
+ resolveImageModelV1,
58
+ type ImageModelV1,
59
+ type ImageModelInputV1,
60
+ } from "./model.js";
61
+ import {
62
+ generatedImagePathV1,
63
+ imageExtensionV1,
64
+ IMAGE_GENERATED_ROOT_ID_V1,
65
+ IMAGE_PACKAGE_ID_V1,
66
+ type ImageOwnerV1,
67
+ } from "./root.js";
68
+
69
+ /**
70
+ * The turn types the manifest's `image-generation` Capability admits, and
71
+ * therefore the durable ceiling this Contribution registers under. Image
72
+ * generation is a work tool on every turn type (`docs/research/
73
+ * grokbot-computer.md` row 47, `buildTurnTools`), and the two must agree: the
74
+ * ceiling here is the manifest's `admission.turnTypes` restated as code the
75
+ * registry can read at mount time.
76
+ */
77
+ export const IMAGE_TOOL_SUBAGENT_ROLES: readonly string[] = ["executor"];
78
+
79
+ export const IMAGE_TOOL_TURN_TYPES: readonly TurnTypeV1[] = [
80
+ "chat",
81
+ "automation",
82
+ "subagent",
83
+ ];
84
+
85
+ /** The longest prompt `generate_image` accepts. */
86
+ export const IMAGE_PROMPT_MAX_LENGTH = 2_000;
87
+ /** The only sizes the tool asks a model for. */
88
+ export const IMAGE_SIZES_V1: readonly number[] = [512, 1024];
89
+ /** The longest a generation may take before the tool gives up on it. */
90
+ export const IMAGE_GENERATION_TIMEOUT_MS = 60_000;
91
+ /**
92
+ * The most image the tool will hold in memory before refusing.
93
+ *
94
+ * The Workspace's own bound is smaller ({@link WORKSPACE_MAX_FILE_BYTES}, one
95
+ * mebibyte), and a file above it is refused by the store with its own reason.
96
+ * This larger cap exists one layer earlier, so a runaway response is dropped
97
+ * before it is hashed and decoded rather than after.
98
+ */
99
+ export const IMAGE_MAX_RESPONSE_BYTES = 4 * 1_048_576;
100
+
101
+ /** The run, Turn and Session a generated image records as its provenance. */
102
+ export interface ImageWriterIdentityV1 {
103
+ sessionId: string;
104
+ turnId: string;
105
+ runId: string;
106
+ }
107
+
108
+ /**
109
+ * The host seam this Package receives, supplied by the Bot Durable Object for
110
+ * one admitted Turn.
111
+ *
112
+ * `model` absent is a supported state, not an error: a deployment with no
113
+ * Workers AI binding mounts the Package with none, and `generate_image`
114
+ * refuses visibly rather than the tool vanishing from the catalog without
115
+ * explanation. `files` absent is the same answer about the Workspace.
116
+ */
117
+ export interface ImageRuntimeHostV1 {
118
+ owner: ImageOwnerV1;
119
+ writer: ImageWriterIdentityV1;
120
+ /** The adapted platform binding. Absent on a host that has none. */
121
+ model?: ImageModelV1;
122
+ /** The Workspace file surface generated images are written through. */
123
+ files?: WorkspaceFilesV1;
124
+ /**
125
+ * The `image.model` Package setting's value. Absent resolves to
126
+ * {@link DEFAULT_IMAGE_MODEL_V1}.
127
+ */
128
+ modelId?: string;
129
+ }
130
+
131
+ const GENERATE_IMAGE_INPUT_SCHEMA = {
132
+ type: "object",
133
+ properties: {
134
+ prompt: {
135
+ type: "string",
136
+ description:
137
+ "What to draw, in words. Describe the subject, the composition and the style; the model sees nothing else.",
138
+ maxLength: IMAGE_PROMPT_MAX_LENGTH,
139
+ },
140
+ width: {
141
+ type: "integer",
142
+ enum: [512, 1024],
143
+ description: "Requested width in pixels. Some models ignore it.",
144
+ },
145
+ height: {
146
+ type: "integer",
147
+ enum: [512, 1024],
148
+ description: "Requested height in pixels. Some models ignore it.",
149
+ },
150
+ n: {
151
+ type: "integer",
152
+ enum: [1],
153
+ description: "How many images to generate. Only one per call.",
154
+ },
155
+ },
156
+ required: ["prompt"],
157
+ additionalProperties: false,
158
+ } as const;
159
+
160
+ interface GenerateImageInputV1 {
161
+ prompt: string;
162
+ width: number;
163
+ height: number;
164
+ }
165
+
166
+ /** C0 controls and DEL: never valid in a prompt the Bot typed. */
167
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
168
+
169
+ export function decodeGenerateImageInputV1(
170
+ input: unknown,
171
+ ): GenerateImageInputV1 {
172
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
173
+ throw new Error("generate_image input must be an object");
174
+ }
175
+ const value = input as Record<string, unknown>;
176
+ const allowed = ["prompt", "width", "height", "n"];
177
+ if (!Object.keys(value).every((key) => allowed.includes(key))) {
178
+ throw new Error("generate_image input has unknown fields");
179
+ }
180
+ const prompt = value.prompt;
181
+ if (
182
+ typeof prompt !== "string" ||
183
+ prompt.trim().length === 0 ||
184
+ prompt.length > IMAGE_PROMPT_MAX_LENGTH ||
185
+ CONTROL_CHARACTERS.test(prompt)
186
+ ) {
187
+ throw new Error(
188
+ `generate_image prompt must be 1 to ${IMAGE_PROMPT_MAX_LENGTH} printable characters`,
189
+ );
190
+ }
191
+ const size = (key: "width" | "height"): number => {
192
+ const candidate = value[key];
193
+ if (candidate === undefined) return 1024;
194
+ if (typeof candidate !== "number" || !IMAGE_SIZES_V1.includes(candidate)) {
195
+ throw new Error(
196
+ `generate_image ${key} must be one of ${IMAGE_SIZES_V1.join(", ")}`,
197
+ );
198
+ }
199
+ return candidate;
200
+ };
201
+ if (value.n !== undefined && value.n !== 1) {
202
+ throw new Error("generate_image generates exactly one image per call");
203
+ }
204
+ return {
205
+ prompt: prompt.trim(),
206
+ width: size("width"),
207
+ height: size("height"),
208
+ };
209
+ }
210
+
211
+ /** The durable `tool/result` payload. Stable JSON, never prose, never bytes. */
212
+ export interface GenerateImageResultV1 {
213
+ path: string;
214
+ root: string;
215
+ generationId: string;
216
+ contentHash: string;
217
+ mimeType: string;
218
+ width: number;
219
+ height: number;
220
+ }
221
+
222
+ /** The root name the result reports, so a reader can find the file. */
223
+ export const IMAGE_RESULT_ROOT_V1 = `package-declared:${IMAGE_PACKAGE_ID_V1}/${IMAGE_GENERATED_ROOT_ID_V1}`;
224
+
225
+ function refusal(reason: string): ToolExecutionResult {
226
+ return { content: `generate_image was refused: ${reason}`, isError: true };
227
+ }
228
+
229
+ function success(result: GenerateImageResultV1): ToolExecutionResult {
230
+ return { content: JSON.stringify(result), isError: false };
231
+ }
232
+
233
+ /** The turn and step an image event belongs to, read from the open step. */
234
+ export function openImageTurnPositionV1(session: Session): {
235
+ turn: number;
236
+ step: number;
237
+ } {
238
+ const started = session.events.findLast(
239
+ (event) => event.type === "step/start",
240
+ );
241
+ if (started?.type !== "step/start") {
242
+ throw new Error("a generated image has no open step to record against");
243
+ }
244
+ return { turn: started.turn, step: started.step };
245
+ }
246
+
247
+ /**
248
+ * The turn and step a *reconciliation* records against: the ones its own
249
+ * intent event named. Reconciliation runs while resuming, when the step that
250
+ * opened the effect may already be closed, so the open-step rule of
251
+ * {@link openImageTurnPositionV1} would refuse a position that plainly exists
252
+ * in the log.
253
+ */
254
+ function recordedIntentPositionV1(
255
+ session: Session,
256
+ effectId: string,
257
+ ): { turn: number; step: number } | undefined {
258
+ const intent = session.events.findLast(
259
+ (event): event is SessionEvent<"image/generate-intent"> =>
260
+ event.type === "image/generate-intent" && event.effectId === effectId,
261
+ );
262
+ return intent ? { turn: intent.turn, step: intent.step } : undefined;
263
+ }
264
+
265
+ function alreadyRecorded(session: Session, effectId: string): boolean {
266
+ return session.events.some(
267
+ (event) => event.type === "image/generated" && event.effectId === effectId,
268
+ );
269
+ }
270
+
271
+ async function withTimeout<T>(
272
+ work: Promise<T>,
273
+ milliseconds: number,
274
+ label: string,
275
+ ): Promise<T> {
276
+ let timer: ReturnType<typeof setTimeout> | undefined;
277
+ try {
278
+ return await Promise.race([
279
+ work,
280
+ new Promise<never>((_resolve, reject) => {
281
+ timer = setTimeout(
282
+ () => reject(new Error(`${label} timed out`)),
283
+ milliseconds,
284
+ );
285
+ }),
286
+ ]);
287
+ } finally {
288
+ if (timer !== undefined) clearTimeout(timer);
289
+ }
290
+ }
291
+
292
+ /** The two containers an effect could have landed as, newest naming first. */
293
+ function candidatePathsV1(
294
+ owner: ImageOwnerV1,
295
+ effectId: string,
296
+ ): WorkspacePathV1[] {
297
+ return ["png", "jpg"].map((extension) =>
298
+ generatedImagePathV1(owner, effectId, extension),
299
+ );
300
+ }
301
+
302
+ function resultFor(
303
+ path: WorkspacePathV1,
304
+ generationId: string,
305
+ contentHash: string,
306
+ dimensions: ImageDimensionsV1,
307
+ ): GenerateImageResultV1 {
308
+ return {
309
+ path: path.path,
310
+ root: IMAGE_RESULT_ROOT_V1,
311
+ generationId,
312
+ contentHash,
313
+ mimeType: dimensions.mimeType,
314
+ width: dimensions.width,
315
+ height: dimensions.height,
316
+ };
317
+ }
318
+
319
+ export function createGenerateImageTool(
320
+ host: ImageRuntimeHostV1,
321
+ sessions: { get(sessionId: string): Session | undefined },
322
+ ): ToolDefinition {
323
+ return {
324
+ name: "generate_image",
325
+ // A general work tool: the full toolset an `executor` subagent gets, and
326
+ // not part of the narrow reach of `browserUse`, `computerUse`, or the two
327
+ // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
328
+ admission: { subagentRoles: ["executor"] },
329
+ description:
330
+ "Generate one image from a text prompt and store it in your Workspace. Answers the file's path, content hash and size — not the image bytes; read the path to see the picture.",
331
+ inputSchema: GENERATE_IMAGE_INPUT_SCHEMA as unknown as Record<
332
+ string,
333
+ unknown
334
+ >,
335
+ // Billed and durable. The registry never retries a non-idempotent effect;
336
+ // it settles this one through `reconcile` below.
337
+ idempotent: false,
338
+ validate: (input: unknown) => {
339
+ try {
340
+ decodeGenerateImageInputV1(input);
341
+ return true;
342
+ } catch {
343
+ return false;
344
+ }
345
+ },
346
+ execute: async (
347
+ input: unknown,
348
+ context: ToolExecutionContext,
349
+ ): Promise<ToolExecutionResult> => {
350
+ let decoded: GenerateImageInputV1;
351
+ try {
352
+ decoded = decodeGenerateImageInputV1(input);
353
+ } catch (error) {
354
+ return refusal(error instanceof Error ? error.message : String(error));
355
+ }
356
+ if (!host.model) {
357
+ return refusal(
358
+ "this deployment has no image model binding, so no image can be generated here",
359
+ );
360
+ }
361
+ if (!host.files) {
362
+ return refusal(
363
+ "the Workspace file surface is unavailable, so a generated image could not be stored",
364
+ );
365
+ }
366
+ let modelId: string;
367
+ try {
368
+ modelId = resolveImageModelV1(host.modelId);
369
+ } catch (error) {
370
+ return refusal(error instanceof Error ? error.message : String(error));
371
+ }
372
+ const session = sessions.get(context.sessionId);
373
+ if (!session) {
374
+ return refusal(
375
+ `session "${context.sessionId}" is unavailable, so the intent cannot be recorded`,
376
+ );
377
+ }
378
+ let position: { turn: number; step: number };
379
+ try {
380
+ position = openImageTurnPositionV1(session);
381
+ } catch (error) {
382
+ return refusal(error instanceof Error ? error.message : String(error));
383
+ }
384
+
385
+ const effectId = context.effectId;
386
+ const promptHash = await sha256HexOfTextV1(decoded.prompt);
387
+ // Intent before effect, durable before the call.
388
+ session.append({
389
+ type: "image/generate-intent",
390
+ ...position,
391
+ effectId,
392
+ model: modelId,
393
+ promptHash,
394
+ width: decoded.width,
395
+ height: decoded.height,
396
+ });
397
+ await session.flush();
398
+
399
+ const request: ImageModelInputV1 = {
400
+ prompt: decoded.prompt,
401
+ width: decoded.width,
402
+ height: decoded.height,
403
+ };
404
+ let buffer: ArrayBuffer;
405
+ try {
406
+ buffer = await withTimeout(
407
+ host.model.run(modelId, request),
408
+ IMAGE_GENERATION_TIMEOUT_MS,
409
+ `image model "${modelId}"`,
410
+ );
411
+ } catch (error) {
412
+ return refusal(
413
+ `the image model failed: ${error instanceof Error ? error.message : String(error)}`,
414
+ );
415
+ }
416
+ const bytes = new Uint8Array(buffer);
417
+ if (bytes.byteLength === 0) {
418
+ return refusal("the image model returned no bytes");
419
+ }
420
+ if (bytes.byteLength > IMAGE_MAX_RESPONSE_BYTES) {
421
+ return refusal(
422
+ `the image model returned ${bytes.byteLength} bytes, over the ${IMAGE_MAX_RESPONSE_BYTES} byte cap`,
423
+ );
424
+ }
425
+ if (bytes.byteLength > WORKSPACE_MAX_FILE_BYTES) {
426
+ return refusal(
427
+ `the generated image is ${bytes.byteLength} bytes and a Workspace file may not exceed ${WORKSPACE_MAX_FILE_BYTES}; ask for a smaller size`,
428
+ );
429
+ }
430
+ const dimensions = decodeImageDimensionsV1(bytes);
431
+ if (!dimensions) {
432
+ return refusal(
433
+ "the image model returned bytes that are neither a PNG nor a JPEG",
434
+ );
435
+ }
436
+
437
+ const contentHash = await sha256HexV1(bytes);
438
+ const path = generatedImagePathV1(
439
+ host.owner,
440
+ effectId,
441
+ imageExtensionV1(dimensions.mimeType),
442
+ );
443
+ const write: WorkspaceWriteRequestV1 = {
444
+ path,
445
+ bytes,
446
+ writer: {
447
+ kind: "bot",
448
+ botId: host.owner.botId,
449
+ sessionId: host.writer.sessionId,
450
+ turnId: host.writer.turnId,
451
+ runId: host.writer.runId,
452
+ },
453
+ // The path is keyed by this effect, so nothing may already hold it. A
454
+ // conflict means a previous attempt at *this* effect already wrote the
455
+ // object, which reconciliation — not a second write — settles.
456
+ expectedGenerationId: null,
457
+ mediaType: dimensions.mimeType,
458
+ };
459
+ const outcome = await host.files.write(write);
460
+ if (outcome.status !== "ok") {
461
+ if (outcome.status === "conflict") {
462
+ const recovered = await readRecordedImage(host, effectId);
463
+ if (recovered) {
464
+ return await recordGenerated(
465
+ session,
466
+ position,
467
+ effectId,
468
+ modelId,
469
+ recovered,
470
+ );
471
+ }
472
+ }
473
+ return refusal(
474
+ `the generated image could not be stored: the write was ${outcome.status} (${outcome.reason})`,
475
+ );
476
+ }
477
+ return await recordGenerated(session, position, effectId, modelId, {
478
+ path,
479
+ generationId: outcome.generation.generationId,
480
+ contentHash,
481
+ dimensions,
482
+ });
483
+ },
484
+ /**
485
+ * Settles an effect an interrupted Turn left open, without generating —
486
+ * and therefore without billing — a second image. The effect-keyed object
487
+ * is the whole answer: present means the generation happened and reached
488
+ * durable storage; absent means it did not, and the Turn is told so rather
489
+ * than being handed a silent retry.
490
+ */
491
+ reconcile: async (
492
+ _input: unknown,
493
+ context: ToolExecutionContext,
494
+ ): Promise<ToolEffectReconciliation> => {
495
+ if (!host.files) {
496
+ return {
497
+ status: "unavailable",
498
+ reason:
499
+ "the Workspace file surface is unavailable, so a generated image cannot be recovered",
500
+ };
501
+ }
502
+ const effectId = context.effectId;
503
+ const recovered = await readRecordedImage(host, effectId);
504
+ if (!recovered) {
505
+ return {
506
+ status: "unavailable",
507
+ reason: `no generated image is stored for effect "${effectId}"`,
508
+ };
509
+ }
510
+ let modelId = host.modelId ?? "";
511
+ try {
512
+ modelId = resolveImageModelV1(host.modelId);
513
+ } catch {
514
+ // The setting drifted since the effect opened. The recovered object is
515
+ // still the effect's outcome; the model name is only a label here.
516
+ }
517
+ const session = sessions.get(context.sessionId);
518
+ const position = session
519
+ ? recordedIntentPositionV1(session, effectId)
520
+ : undefined;
521
+ if (session && position && !alreadyRecorded(session, effectId)) {
522
+ const result = await recordGenerated(
523
+ session,
524
+ position,
525
+ effectId,
526
+ modelId,
527
+ recovered,
528
+ );
529
+ return { status: "recovered", result };
530
+ }
531
+ return {
532
+ status: "recovered",
533
+ result: success(
534
+ resultFor(
535
+ recovered.path,
536
+ recovered.generationId,
537
+ recovered.contentHash,
538
+ recovered.dimensions,
539
+ ),
540
+ ),
541
+ };
542
+ },
543
+ };
544
+ }
545
+
546
+ interface RecordedImageV1 {
547
+ path: WorkspacePathV1;
548
+ generationId: string;
549
+ contentHash: string;
550
+ dimensions: ImageDimensionsV1;
551
+ }
552
+
553
+ /** The object this effect wrote, read back from the Workspace, or nothing. */
554
+ async function readRecordedImage(
555
+ host: ImageRuntimeHostV1,
556
+ effectId: string,
557
+ ): Promise<RecordedImageV1 | undefined> {
558
+ const files = host.files;
559
+ if (!files) return undefined;
560
+ for (const path of candidatePathsV1(host.owner, effectId)) {
561
+ const outcome = await files.read(path);
562
+ if (outcome.status !== "ok") continue;
563
+ const dimensions = decodeImageDimensionsV1(outcome.file.bytes);
564
+ if (!dimensions) continue;
565
+ return {
566
+ path,
567
+ generationId: outcome.file.generation.generationId,
568
+ contentHash: outcome.file.generation.contentHash,
569
+ dimensions,
570
+ };
571
+ }
572
+ return undefined;
573
+ }
574
+
575
+ /** Appends `image/generated`, flushes it, and answers the durable result. */
576
+ async function recordGenerated(
577
+ session: Session,
578
+ position: { turn: number; step: number },
579
+ effectId: string,
580
+ modelId: string,
581
+ recorded: RecordedImageV1,
582
+ ): Promise<ToolExecutionResult> {
583
+ const result = resultFor(
584
+ recorded.path,
585
+ recorded.generationId,
586
+ recorded.contentHash,
587
+ recorded.dimensions,
588
+ );
589
+ session.append({
590
+ type: "image/generated",
591
+ ...position,
592
+ effectId,
593
+ model: modelId,
594
+ path: result.path,
595
+ generationId: result.generationId,
596
+ contentHash: result.contentHash,
597
+ mimeType: result.mimeType,
598
+ width: result.width,
599
+ height: result.height,
600
+ });
601
+ // The model must not be told the image exists before the record is durable.
602
+ await session.flush();
603
+ return success(result);
604
+ }
605
+
606
+ /**
607
+ * The runtime Contribution. Registers `generate_image` on every turn type —
608
+ * it is a work tool, and the parity register puts image generation on
609
+ * automation turns as well as chat ones (`docs/research/grokbot-computer.md`
610
+ * row 47). The manifest's `admission` ceiling is what bounds it durably; the
611
+ * definition declares none, which the kernel reads as "all of them".
612
+ */
613
+ export function createImageRuntimePlugin(
614
+ host: ImageRuntimeHostV1,
615
+ ): Plugin.Function {
616
+ const plugin: Plugin.Function = (ctx) => {
617
+ const dispose = ctx.tools.register(
618
+ createGenerateImageTool(host, ctx.sessions),
619
+ {
620
+ admissionCeiling: IMAGE_TOOL_TURN_TYPES,
621
+ subagentRoleCeiling: IMAGE_TOOL_SUBAGENT_ROLES,
622
+ },
623
+ );
624
+ return () => dispose();
625
+ };
626
+ plugin.inject = ["tools", "sessions"];
627
+ return plugin;
628
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { decodeImageDimensionsV1 } from "./bytes.ts";
3
+ import { fakePngBytesV1 } from "./testing.ts";
4
+
5
+ /** A minimal JPEG: SOI, an APP0 segment, then an SOF0 frame header. */
6
+ function jpegBytes(width: number, height: number): Uint8Array {
7
+ const bytes = new Uint8Array(32);
8
+ const view = new DataView(bytes.buffer);
9
+ bytes.set([0xff, 0xd8], 0);
10
+ // APP0, length 4, two payload bytes.
11
+ bytes.set([0xff, 0xe0, 0x00, 0x04, 0x00, 0x00], 2);
12
+ // SOF0, length 11, precision 8, then height and width.
13
+ bytes.set([0xff, 0xc0, 0x00, 0x0b, 0x08], 8);
14
+ view.setUint16(13, height);
15
+ view.setUint16(15, width);
16
+ return bytes;
17
+ }
18
+
19
+ describe("identifying image bytes", () => {
20
+ test("reads a PNG's IHDR", () => {
21
+ expect(decodeImageDimensionsV1(fakePngBytesV1(1024, 512))).toEqual({
22
+ mimeType: "image/png",
23
+ width: 1024,
24
+ height: 512,
25
+ });
26
+ });
27
+
28
+ test("reads a JPEG's frame header past an APP0 segment", () => {
29
+ expect(decodeImageDimensionsV1(jpegBytes(512, 768))).toEqual({
30
+ mimeType: "image/jpeg",
31
+ width: 512,
32
+ height: 768,
33
+ });
34
+ });
35
+
36
+ test("refuses anything it cannot identify", () => {
37
+ for (const bytes of [
38
+ new Uint8Array(0),
39
+ new Uint8Array([1, 2, 3]),
40
+ new TextEncoder().encode("<svg/>"),
41
+ // A PNG signature whose first chunk is not IHDR.
42
+ (() => {
43
+ const forged = fakePngBytesV1(16, 16);
44
+ forged[12] = 0x74;
45
+ return forged;
46
+ })(),
47
+ // A PNG whose IHDR claims a zero dimension.
48
+ fakePngBytesV1(0, 16),
49
+ ]) {
50
+ expect(decodeImageDimensionsV1(bytes)).toBeUndefined();
51
+ }
52
+ });
53
+ });