@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/frockbot.json +43 -0
- package/package.json +33 -6
- package/src/agent.test.ts +422 -0
- package/src/agent.ts +628 -0
- package/src/bytes.test.ts +53 -0
- package/src/bytes.ts +125 -0
- package/src/index.ts +5 -0
- package/src/manifest.ts +3 -0
- package/src/model.ts +63 -0
- package/src/root.ts +84 -0
- package/src/testing.ts +145 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/bytes.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// What the bytes an image model returned actually are.
|
|
2
|
+
//
|
|
3
|
+
// The tool records `mimeType`, `width` and `height` in its durable
|
|
4
|
+
// `tool/result`, and those must describe the stored object rather than the
|
|
5
|
+
// request: Workers AI's `flux-1-schnell` accepts no size at all, so echoing
|
|
6
|
+
// the requested width back would put a number in the durable log that no file
|
|
7
|
+
// on disk agrees with. Reading the container header is the only honest source.
|
|
8
|
+
//
|
|
9
|
+
// Pure, total, and the Package's whole image-format surface. A byte string
|
|
10
|
+
// this cannot identify is not written: an unidentifiable blob under a durable
|
|
11
|
+
// root is data nothing can render and nothing can attribute a format to.
|
|
12
|
+
|
|
13
|
+
/** A decoded image container. */
|
|
14
|
+
export interface ImageDimensionsV1 {
|
|
15
|
+
mimeType: string;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
|
|
21
|
+
|
|
22
|
+
function readUint32BE(bytes: Uint8Array, offset: number): number {
|
|
23
|
+
return (
|
|
24
|
+
((bytes[offset] ?? 0) << 24) +
|
|
25
|
+
((bytes[offset + 1] ?? 0) << 16) +
|
|
26
|
+
((bytes[offset + 2] ?? 0) << 8) +
|
|
27
|
+
(bytes[offset + 3] ?? 0)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readUint16BE(bytes: Uint8Array, offset: number): number {
|
|
32
|
+
return ((bytes[offset] ?? 0) << 8) + (bytes[offset + 1] ?? 0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodePng(bytes: Uint8Array): ImageDimensionsV1 | undefined {
|
|
36
|
+
if (bytes.byteLength < 24) return undefined;
|
|
37
|
+
if (PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
// The first chunk of a PNG is always IHDR, and its payload starts at 16.
|
|
41
|
+
if (
|
|
42
|
+
bytes[12] !== 0x49 ||
|
|
43
|
+
bytes[13] !== 0x48 ||
|
|
44
|
+
bytes[14] !== 0x44 ||
|
|
45
|
+
bytes[15] !== 0x52
|
|
46
|
+
) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const width = readUint32BE(bytes, 16);
|
|
50
|
+
const height = readUint32BE(bytes, 20);
|
|
51
|
+
if (width < 1 || height < 1) return undefined;
|
|
52
|
+
return { mimeType: "image/png", width, height };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** SOF markers carrying a frame header. SOF4/8/12 are not frame starts. */
|
|
56
|
+
function isStartOfFrame(marker: number): boolean {
|
|
57
|
+
return (
|
|
58
|
+
marker >= 0xc0 &&
|
|
59
|
+
marker <= 0xcf &&
|
|
60
|
+
marker !== 0xc4 &&
|
|
61
|
+
marker !== 0xc8 &&
|
|
62
|
+
marker !== 0xcc
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function decodeJpeg(bytes: Uint8Array): ImageDimensionsV1 | undefined {
|
|
67
|
+
if (bytes.byteLength < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
let offset = 2;
|
|
71
|
+
while (offset + 3 < bytes.byteLength) {
|
|
72
|
+
if (bytes[offset] !== 0xff) {
|
|
73
|
+
offset += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const marker = bytes[offset + 1] ?? 0;
|
|
77
|
+
// Padding fill bytes, and the standalone markers that carry no length.
|
|
78
|
+
if (marker === 0xff) {
|
|
79
|
+
offset += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd9)) {
|
|
83
|
+
offset += 2;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const length = readUint16BE(bytes, offset + 2);
|
|
87
|
+
if (length < 2) return undefined;
|
|
88
|
+
if (isStartOfFrame(marker)) {
|
|
89
|
+
if (offset + 9 >= bytes.byteLength) return undefined;
|
|
90
|
+
const height = readUint16BE(bytes, offset + 5);
|
|
91
|
+
const width = readUint16BE(bytes, offset + 7);
|
|
92
|
+
if (width < 1 || height < 1) return undefined;
|
|
93
|
+
return { mimeType: "image/jpeg", width, height };
|
|
94
|
+
}
|
|
95
|
+
offset += 2 + length;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The container these bytes are, or `undefined` when they are neither a PNG
|
|
102
|
+
* nor a JPEG. Those two are what every Workers AI text-to-image model returns
|
|
103
|
+
* today; anything else is refused rather than guessed at.
|
|
104
|
+
*/
|
|
105
|
+
export function decodeImageDimensionsV1(
|
|
106
|
+
bytes: Uint8Array,
|
|
107
|
+
): ImageDimensionsV1 | undefined {
|
|
108
|
+
return decodePng(bytes) ?? decodeJpeg(bytes);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The sha-256 content address of some bytes, hex encoded. */
|
|
112
|
+
export async function sha256HexV1(bytes: Uint8Array): Promise<string> {
|
|
113
|
+
const digest = await crypto.subtle.digest(
|
|
114
|
+
"SHA-256",
|
|
115
|
+
bytes.slice().buffer as ArrayBuffer,
|
|
116
|
+
);
|
|
117
|
+
return [...new Uint8Array(digest)]
|
|
118
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
119
|
+
.join("");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The sha-256 of a string, for the prompt hash the intent event records. */
|
|
123
|
+
export function sha256HexOfTextV1(text: string): Promise<string> {
|
|
124
|
+
return sha256HexV1(new TextEncoder().encode(text));
|
|
125
|
+
}
|
package/src/index.ts
ADDED
package/src/manifest.ts
ADDED
package/src/model.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The narrow image-model interface this Package consumes, and the models it
|
|
2
|
+
// knows how to ask for.
|
|
3
|
+
//
|
|
4
|
+
// "The kernel declares the narrow interfaces it consumes ... and owns no
|
|
5
|
+
// implementation of them." The same discipline applies one level down: this
|
|
6
|
+
// Package declares what it needs from an image model and implements none of
|
|
7
|
+
// it. The host — `apps/cloudflare` through `plugin-shell` — adapts Workers AI's
|
|
8
|
+
// `AI` binding to this shape, so no Cloudflare type and no provider response
|
|
9
|
+
// envelope reaches the tool.
|
|
10
|
+
//
|
|
11
|
+
// `run` answers raw image bytes, never base64 and never a provider envelope.
|
|
12
|
+
// Workers AI's text-to-image models disagree about which they return
|
|
13
|
+
// (`flux-1-schnell` answers `{ image: "<base64>" }`, the Stable Diffusion
|
|
14
|
+
// models answer a binary stream), and normalizing that is exactly the
|
|
15
|
+
// adapter's job.
|
|
16
|
+
|
|
17
|
+
/** The image model seam. One method, one direction, no provider vocabulary. */
|
|
18
|
+
export interface ImageModelV1 {
|
|
19
|
+
run(model: string, input: ImageModelInputV1): Promise<ArrayBuffer>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What the tool asks for. `width` and `height` are a *request*: not every
|
|
24
|
+
* Workers AI text-to-image model accepts them (`flux-1-schnell` does not), so
|
|
25
|
+
* the tool reports the dimensions it decodes from the returned bytes rather
|
|
26
|
+
* than echoing these back as if they were honoured.
|
|
27
|
+
*/
|
|
28
|
+
export interface ImageModelInputV1 {
|
|
29
|
+
prompt: string;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The default model. Cloudflare's own catalog calls FLUX.1 [schnell] the
|
|
36
|
+
* fastest text-to-image model on Workers AI, and it is the one the parity
|
|
37
|
+
* slice was specified against (`docs/plans/` slice O, §2). Overridable through
|
|
38
|
+
* the `image.model` Package setting.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_IMAGE_MODEL_V1 = "@cf/black-forest-labs/flux-1-schnell";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The models the `image.model` setting accepts, mirroring the manifest's
|
|
44
|
+
* `enum`. A model outside this list is refused rather than passed through: the
|
|
45
|
+
* host binding would happily run an image-to-image or a text model and answer
|
|
46
|
+
* something this tool cannot store.
|
|
47
|
+
*/
|
|
48
|
+
export const IMAGE_MODELS_V1: readonly string[] = [
|
|
49
|
+
"@cf/black-forest-labs/flux-1-schnell",
|
|
50
|
+
"@cf/black-forest-labs/flux-2-klein-4b",
|
|
51
|
+
"@cf/stabilityai/stable-diffusion-xl-base-1.0",
|
|
52
|
+
"@cf/bytedance/stable-diffusion-xl-lightning",
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** The setting's value, or the default; an unknown model is refused. */
|
|
56
|
+
export function resolveImageModelV1(configured?: string): string {
|
|
57
|
+
const named = configured?.trim();
|
|
58
|
+
if (!named) return DEFAULT_IMAGE_MODEL_V1;
|
|
59
|
+
if (!IMAGE_MODELS_V1.includes(named)) {
|
|
60
|
+
throw new Error(`image model "${named}" is not one this Package offers`);
|
|
61
|
+
}
|
|
62
|
+
return named;
|
|
63
|
+
}
|
package/src/root.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Where a generated image lives, and why there.
|
|
2
|
+
//
|
|
3
|
+
// The durable Workspace, under a root this Package's manifest declares:
|
|
4
|
+
// `{kind: "package-declared", userId, packageId: "image", rootId: "generated"}`
|
|
5
|
+
// (`kernel-contracts/src/workspace.ts`). Object storage backs it, every write
|
|
6
|
+
// records its writer and produces a generation, and the durable-root sync
|
|
7
|
+
// (ADR 0013) presents it on the Computer as a real file the Bot can open with
|
|
8
|
+
// ordinary file tools — none of which is true of a data URL in the event log,
|
|
9
|
+
// the Durable Object's own storage, or a Memory root (single-writer,
|
|
10
|
+
// Markdown-only, and never written through the kernel file surface).
|
|
11
|
+
//
|
|
12
|
+
// `package-declared` roots are User-scoped, so one User's Bots share the root
|
|
13
|
+
// and each Bot's images sit under its own directory. That is the constitution's
|
|
14
|
+
// own rule for a Computer: "Bots of one User may read each other's Workspace
|
|
15
|
+
// files"; separation between them is organizational.
|
|
16
|
+
//
|
|
17
|
+
// The object is named by the *effect*, not by the prompt or a random id. That
|
|
18
|
+
// is the reconciliation fence: an interrupted Turn asks the Workspace whether
|
|
19
|
+
// the object for its effect exists, and the answer settles the effect without
|
|
20
|
+
// running — and therefore without billing — the model a second time.
|
|
21
|
+
import {
|
|
22
|
+
normalizeWorkspaceRelativePathV1,
|
|
23
|
+
type WorkspacePathV1,
|
|
24
|
+
type WorkspaceRootV1,
|
|
25
|
+
} from "@frockbot/kernel-contracts";
|
|
26
|
+
|
|
27
|
+
/** The Package id the declared root belongs to. Matches `frockbot.json`. */
|
|
28
|
+
export const IMAGE_PACKAGE_ID_V1 = "image";
|
|
29
|
+
/** The declared root generated images are written under. */
|
|
30
|
+
export const IMAGE_GENERATED_ROOT_ID_V1 = "generated";
|
|
31
|
+
|
|
32
|
+
/** The Bot and User a generated image is attributed to. */
|
|
33
|
+
export interface ImageOwnerV1 {
|
|
34
|
+
userId: string;
|
|
35
|
+
botId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Package-declared Workspace root this Package writes. */
|
|
39
|
+
export function generatedImageRootV1(userId: string): WorkspaceRootV1 {
|
|
40
|
+
return {
|
|
41
|
+
kind: "package-declared",
|
|
42
|
+
userId,
|
|
43
|
+
packageId: IMAGE_PACKAGE_ID_V1,
|
|
44
|
+
rootId: IMAGE_GENERATED_ROOT_ID_V1,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One path segment made safe. An `effectId` is minted by the Agent loop as
|
|
50
|
+
* `tool:<turn>:<step>:<index>`; a Bot id is arbitrary durable text. Neither is
|
|
51
|
+
* a filename, so both are folded to a conservative alphabet rather than
|
|
52
|
+
* trusted — the Workspace would accept a colon, but a Computer's filesystem is
|
|
53
|
+
* where these land and there is no reason to find out which ones it dislikes.
|
|
54
|
+
*/
|
|
55
|
+
export function imagePathSegmentV1(value: string): string {
|
|
56
|
+
const folded = value
|
|
57
|
+
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
58
|
+
.replace(/^-+|-+$/g, "");
|
|
59
|
+
if (!folded) throw new Error("image path segment is empty");
|
|
60
|
+
return folded.slice(0, 128);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The object one effect writes. Effect-keyed, so reconciliation reads exactly
|
|
65
|
+
* the object the interrupted attempt would have written.
|
|
66
|
+
*/
|
|
67
|
+
export function generatedImagePathV1(
|
|
68
|
+
owner: ImageOwnerV1,
|
|
69
|
+
effectId: string,
|
|
70
|
+
extension: string,
|
|
71
|
+
): WorkspacePathV1 {
|
|
72
|
+
return {
|
|
73
|
+
root: generatedImageRootV1(owner.userId),
|
|
74
|
+
path: normalizeWorkspaceRelativePathV1(
|
|
75
|
+
`${imagePathSegmentV1(owner.botId)}/${imagePathSegmentV1(effectId)}.${imagePathSegmentV1(extension)}`,
|
|
76
|
+
"generated image path",
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The file extension for a container this Package writes. */
|
|
82
|
+
export function imageExtensionV1(mimeType: string): string {
|
|
83
|
+
return mimeType === "image/jpeg" ? "jpg" : "png";
|
|
84
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Test doubles for this Package, and nothing production imports.
|
|
2
|
+
//
|
|
3
|
+
// The Workspace fake is local rather than borrowed from `plugin-skills`: that
|
|
4
|
+
// one seeds text, and everything here is bytes. It records every call so a
|
|
5
|
+
// test can prove reconciliation read the store and never the model.
|
|
6
|
+
import {
|
|
7
|
+
workspaceRootKeyV1,
|
|
8
|
+
type WorkspaceDeleteRequestV1,
|
|
9
|
+
type WorkspaceEntryV1,
|
|
10
|
+
type WorkspaceFilesV1,
|
|
11
|
+
type WorkspaceGenerationV1,
|
|
12
|
+
type WorkspaceListOutcomeV1,
|
|
13
|
+
type WorkspaceListRequestV1,
|
|
14
|
+
type WorkspacePathV1,
|
|
15
|
+
type WorkspaceReadOutcomeV1,
|
|
16
|
+
type WorkspaceStatOutcomeV1,
|
|
17
|
+
type WorkspaceWriteOutcomeV1,
|
|
18
|
+
type WorkspaceWriteRequestV1,
|
|
19
|
+
} from "@frockbot/kernel-contracts";
|
|
20
|
+
import { sha256HexV1 } from "./bytes.js";
|
|
21
|
+
import type { ImageModelInputV1, ImageModelV1 } from "./model.js";
|
|
22
|
+
|
|
23
|
+
interface StoredFile {
|
|
24
|
+
entry: WorkspaceEntryV1;
|
|
25
|
+
bytes: Uint8Array;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An in-memory `WorkspaceFilesV1` over bytes. Records every call it served. */
|
|
29
|
+
export class FakeImageWorkspace implements WorkspaceFilesV1 {
|
|
30
|
+
readonly calls: string[] = [];
|
|
31
|
+
#files = new Map<string, StoredFile>();
|
|
32
|
+
#sequence = 0;
|
|
33
|
+
/** Set to make the next `write` answer this outcome instead of storing. */
|
|
34
|
+
nextWriteOutcome?: WorkspaceWriteOutcomeV1;
|
|
35
|
+
|
|
36
|
+
#key(path: WorkspacePathV1): string {
|
|
37
|
+
return `${workspaceRootKeyV1(path.root)}::${path.path}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#nextGenerationId(): string {
|
|
41
|
+
this.#sequence += 1;
|
|
42
|
+
return `gen-${String(this.#sequence).padStart(4, "0")}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
read(path: WorkspacePathV1): Promise<WorkspaceReadOutcomeV1> {
|
|
46
|
+
this.calls.push(`read:${path.path}`);
|
|
47
|
+
const stored = this.#files.get(this.#key(path));
|
|
48
|
+
if (!stored) {
|
|
49
|
+
return Promise.resolve({ status: "not-found", reason: "no such file" });
|
|
50
|
+
}
|
|
51
|
+
return Promise.resolve({
|
|
52
|
+
status: "ok",
|
|
53
|
+
file: { ...stored.entry, bytes: stored.bytes },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stat(path: WorkspacePathV1): Promise<WorkspaceStatOutcomeV1> {
|
|
58
|
+
this.calls.push(`stat:${path.path}`);
|
|
59
|
+
const stored = this.#files.get(this.#key(path));
|
|
60
|
+
if (!stored) {
|
|
61
|
+
return Promise.resolve({ status: "not-found", reason: "no such file" });
|
|
62
|
+
}
|
|
63
|
+
return Promise.resolve({ status: "ok", entry: stored.entry });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
list(request: WorkspaceListRequestV1): Promise<WorkspaceListOutcomeV1> {
|
|
67
|
+
this.calls.push(`list:${workspaceRootKeyV1(request.root)}`);
|
|
68
|
+
const key = workspaceRootKeyV1(request.root);
|
|
69
|
+
const entries = [...this.#files.values()]
|
|
70
|
+
.filter((stored) => workspaceRootKeyV1(stored.entry.path.root) === key)
|
|
71
|
+
.map((stored) => stored.entry);
|
|
72
|
+
return Promise.resolve({ status: "ok", entries });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async write(
|
|
76
|
+
request: WorkspaceWriteRequestV1,
|
|
77
|
+
): Promise<WorkspaceWriteOutcomeV1> {
|
|
78
|
+
this.calls.push(`write:${request.path.path}`);
|
|
79
|
+
const scripted = this.nextWriteOutcome;
|
|
80
|
+
if (scripted) {
|
|
81
|
+
this.nextWriteOutcome = undefined;
|
|
82
|
+
return scripted;
|
|
83
|
+
}
|
|
84
|
+
const key = this.#key(request.path);
|
|
85
|
+
const existing = this.#files.get(key);
|
|
86
|
+
const seen = existing?.entry.generation.generationId ?? null;
|
|
87
|
+
if (seen !== request.expectedGenerationId) {
|
|
88
|
+
return { status: "conflict", reason: "unexpected generation" };
|
|
89
|
+
}
|
|
90
|
+
const generation: WorkspaceGenerationV1 = {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
generationId: this.#nextGenerationId(),
|
|
93
|
+
contentHash: await sha256HexV1(request.bytes),
|
|
94
|
+
size: request.bytes.byteLength,
|
|
95
|
+
writer: request.writer,
|
|
96
|
+
writtenAt: new Date(0).toISOString(),
|
|
97
|
+
};
|
|
98
|
+
this.#files.set(key, {
|
|
99
|
+
entry: { path: request.path, generation },
|
|
100
|
+
bytes: request.bytes,
|
|
101
|
+
});
|
|
102
|
+
return { status: "ok", generation };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
delete(request: WorkspaceDeleteRequestV1): Promise<WorkspaceWriteOutcomeV1> {
|
|
106
|
+
this.calls.push(`delete:${request.path.path}`);
|
|
107
|
+
this.#files.delete(this.#key(request.path));
|
|
108
|
+
return Promise.resolve({
|
|
109
|
+
status: "refused",
|
|
110
|
+
reason: "the fake does not tombstone",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The smallest valid PNG header this Package can decode, at a given size. */
|
|
116
|
+
export function fakePngBytesV1(width: number, height: number): Uint8Array {
|
|
117
|
+
const bytes = new Uint8Array(64);
|
|
118
|
+
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
|
|
119
|
+
// IHDR length (13) and type.
|
|
120
|
+
bytes.set([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52], 8);
|
|
121
|
+
const view = new DataView(bytes.buffer);
|
|
122
|
+
view.setUint32(16, width);
|
|
123
|
+
view.setUint32(20, height);
|
|
124
|
+
bytes[24] = 8;
|
|
125
|
+
bytes[25] = 6;
|
|
126
|
+
return bytes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An image model that answers fixed bytes and counts every call. */
|
|
130
|
+
export class FakeImageModel implements ImageModelV1 {
|
|
131
|
+
readonly calls: Array<{ model: string; input: ImageModelInputV1 }> = [];
|
|
132
|
+
#bytes: Uint8Array;
|
|
133
|
+
/** Set to make the next `run` reject with this message. */
|
|
134
|
+
failure?: string;
|
|
135
|
+
|
|
136
|
+
constructor(bytes: Uint8Array = fakePngBytesV1(1024, 1024)) {
|
|
137
|
+
this.#bytes = bytes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
run(model: string, input: ImageModelInputV1): Promise<ArrayBuffer> {
|
|
141
|
+
this.calls.push({ model, input });
|
|
142
|
+
if (this.failure) return Promise.reject(new Error(this.failure));
|
|
143
|
+
return Promise.resolve(this.#bytes.slice().buffer as ArrayBuffer);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"resolveJsonModule": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"lib": ["ES2023", "DOM"],
|
|
12
|
+
"types": ["bun"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*.ts"]
|
|
15
|
+
}
|
package/README.md
DELETED