clanka 0.5.2 → 0.6.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/dist/Acp.d.ts.map +1 -1
- package/dist/Acp.js +195 -16
- package/dist/Acp.js.map +1 -1
- package/dist/AcpImage.test.d.ts +2 -0
- package/dist/AcpImage.test.d.ts.map +1 -0
- package/dist/AcpImage.test.js +470 -0
- package/dist/AcpImage.test.js.map +1 -0
- package/dist/Agent.d.ts +9 -2
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +43 -6
- package/dist/Agent.js.map +1 -1
- package/dist/AgentExecutor.d.ts +27 -5
- package/dist/AgentExecutor.d.ts.map +1 -1
- package/dist/AgentExecutor.js +29 -4
- package/dist/AgentExecutor.js.map +1 -1
- package/dist/AgentExecutorImage.test.d.ts +2 -0
- package/dist/AgentExecutorImage.test.d.ts.map +1 -0
- package/dist/AgentExecutorImage.test.js +63 -0
- package/dist/AgentExecutorImage.test.js.map +1 -0
- package/dist/AgentImage.test.d.ts +2 -0
- package/dist/AgentImage.test.d.ts.map +1 -0
- package/dist/AgentImage.test.js +176 -0
- package/dist/AgentImage.test.js.map +1 -0
- package/dist/AgentTools.d.ts +25 -5
- package/dist/AgentTools.d.ts.map +1 -1
- package/dist/AgentTools.js +40 -4
- package/dist/AgentTools.js.map +1 -1
- package/dist/AgentToolsImage.test.d.ts +2 -0
- package/dist/AgentToolsImage.test.d.ts.map +1 -0
- package/dist/AgentToolsImage.test.js +158 -0
- package/dist/AgentToolsImage.test.js.map +1 -0
- package/dist/Codex.js +6 -1
- package/dist/Codex.js.map +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +16 -1
- package/dist/Compaction.js.map +1 -1
- package/dist/CompactionImage.test.d.ts +2 -0
- package/dist/CompactionImage.test.d.ts.map +1 -0
- package/dist/CompactionImage.test.js +105 -0
- package/dist/CompactionImage.test.js.map +1 -0
- package/dist/Copilot.d.ts.map +1 -1
- package/dist/Copilot.js +5 -1
- package/dist/Copilot.js.map +1 -1
- package/dist/Image.d.ts +192 -0
- package/dist/Image.d.ts.map +1 -0
- package/dist/Image.js +586 -0
- package/dist/Image.js.map +1 -0
- package/dist/Image.test.d.ts +2 -0
- package/dist/Image.test.d.ts.map +1 -0
- package/dist/Image.test.js +142 -0
- package/dist/Image.test.js.map +1 -0
- package/dist/fixtures/TestImages.d.ts +73 -0
- package/dist/fixtures/TestImages.d.ts.map +1 -0
- package/dist/fixtures/TestImages.js +263 -0
- package/dist/fixtures/TestImages.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/Acp.ts +274 -21
- package/src/AcpImage.test.ts +676 -0
- package/src/Agent.ts +69 -7
- package/src/AgentExecutor.ts +51 -3
- package/src/AgentExecutorImage.test.ts +103 -0
- package/src/AgentImage.test.ts +295 -0
- package/src/AgentTools.ts +78 -7
- package/src/AgentToolsImage.test.ts +302 -0
- package/src/Codex.ts +6 -1
- package/src/Compaction.ts +16 -1
- package/src/CompactionImage.test.ts +142 -0
- package/src/Copilot.ts +5 -1
- package/src/Image.test.ts +220 -0
- package/src/Image.ts +704 -0
- package/src/fixtures/TestImages.ts +287 -0
- package/src/index.ts +5 -0
package/src/Image.ts
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image handling shared by ACP prompt ingest and the `readFile` tool.
|
|
3
|
+
*
|
|
4
|
+
* - Media type detection by extension and by magic bytes.
|
|
5
|
+
* - The size limits every image goes through before it reaches a model
|
|
6
|
+
* (OpenCode's numbers): 2000x2000 pixels and 5MB of base64. Images inside
|
|
7
|
+
* the limits pass through untouched; larger ones are resized with Photon
|
|
8
|
+
* (Lanczos3), re-encoded as PNG then JPEG at decreasing quality, shrunk and
|
|
9
|
+
* retried, and rejected when they still do not fit.
|
|
10
|
+
* - Prompt helpers for models that cannot take images: strip the parts and
|
|
11
|
+
* leave an explicit `[image: <name> omitted]` note.
|
|
12
|
+
*
|
|
13
|
+
* @since 1.0.0
|
|
14
|
+
*/
|
|
15
|
+
import * as Photon from "@silvia-odwyer/photon-node"
|
|
16
|
+
import * as Effect from "effect/Effect"
|
|
17
|
+
import * as Encoding from "effect/Encoding"
|
|
18
|
+
import type * as FileSystem from "effect/FileSystem"
|
|
19
|
+
import * as Option from "effect/Option"
|
|
20
|
+
import * as Schema from "effect/Schema"
|
|
21
|
+
import * as Stream from "effect/Stream"
|
|
22
|
+
import type * as AiError from "effect/unstable/ai/AiError"
|
|
23
|
+
import * as Prompt from "effect/unstable/ai/Prompt"
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @since 1.0.0
|
|
27
|
+
* @category Models
|
|
28
|
+
*/
|
|
29
|
+
export const ImageMediaType = Schema.Literals([
|
|
30
|
+
"image/png",
|
|
31
|
+
"image/jpeg",
|
|
32
|
+
"image/gif",
|
|
33
|
+
"image/webp",
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @since 1.0.0
|
|
38
|
+
* @category Models
|
|
39
|
+
*/
|
|
40
|
+
export type ImageMediaType = typeof ImageMediaType.Type
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @since 1.0.0
|
|
44
|
+
* @category Models
|
|
45
|
+
*/
|
|
46
|
+
export interface ImageData {
|
|
47
|
+
readonly data: Uint8Array
|
|
48
|
+
readonly mediaType: ImageMediaType
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @since 1.0.0
|
|
53
|
+
* @category Models
|
|
54
|
+
*/
|
|
55
|
+
export interface Limits {
|
|
56
|
+
readonly maxDimension: number
|
|
57
|
+
readonly maxBytes: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @since 1.0.0
|
|
62
|
+
* @category Errors
|
|
63
|
+
*/
|
|
64
|
+
export class ImageError extends Schema.TaggedError<ImageError>()("ImageError", {
|
|
65
|
+
reason: Schema.Literals(["TooLarge", "Decode"]),
|
|
66
|
+
message: Schema.String,
|
|
67
|
+
}) {}
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// Limits
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Maximum width and height, in pixels.
|
|
75
|
+
*
|
|
76
|
+
* @since 1.0.0
|
|
77
|
+
* @category Limits
|
|
78
|
+
*/
|
|
79
|
+
export const maxDimension = 2000
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Maximum size of the base64-encoded image, in bytes.
|
|
83
|
+
*
|
|
84
|
+
* @since 1.0.0
|
|
85
|
+
* @category Limits
|
|
86
|
+
*/
|
|
87
|
+
export const maxBytes = 5 * 1024 * 1024
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Maximum encoded input size before decoding.
|
|
91
|
+
* @since 1.0.0
|
|
92
|
+
* @category Limits
|
|
93
|
+
*/
|
|
94
|
+
export const maxInputBytes = 20 * 1024 * 1024
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Maximum declared canvas or frame area before allocating decoder buffers.
|
|
98
|
+
* @since 1.0.0
|
|
99
|
+
* @category Limits
|
|
100
|
+
*/
|
|
101
|
+
export const maxInputPixels = 50_000_000
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Stat first, then bound the actual read independently of file growth.
|
|
105
|
+
* @since 1.0.0
|
|
106
|
+
* @category Input
|
|
107
|
+
*/
|
|
108
|
+
export const readBoundedFile = Effect.fnUntraced(function* (
|
|
109
|
+
fs: FileSystem.FileSystem,
|
|
110
|
+
path: string,
|
|
111
|
+
) {
|
|
112
|
+
const tooLarge = () =>
|
|
113
|
+
new ImageError({
|
|
114
|
+
reason: "TooLarge",
|
|
115
|
+
message: `Image ${path} exceeds the ${maxInputBytes} byte input limit`,
|
|
116
|
+
})
|
|
117
|
+
const stat = yield* fs.stat(path)
|
|
118
|
+
if (stat.size > maxInputBytes) return yield* tooLarge()
|
|
119
|
+
// The extra byte detects growth past the ceiling without reading the rest.
|
|
120
|
+
return yield* collectInput(
|
|
121
|
+
fs.stream(path, {
|
|
122
|
+
chunkSize: 64 * 1024,
|
|
123
|
+
bytesToRead: maxInputBytes + 1,
|
|
124
|
+
}),
|
|
125
|
+
tooLarge,
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Collect image input, rejecting overflow before retaining the next chunk.
|
|
131
|
+
* @since 1.0.0
|
|
132
|
+
* @category Input
|
|
133
|
+
*/
|
|
134
|
+
export const collectInput = Effect.fnUntraced(function* <E, R, E2>(
|
|
135
|
+
stream: Stream.Stream<Uint8Array, E, R>,
|
|
136
|
+
tooLarge: () => E2,
|
|
137
|
+
) {
|
|
138
|
+
const chunks: Array<Uint8Array> = []
|
|
139
|
+
let size = 0
|
|
140
|
+
yield* Stream.runForEach(stream, (chunk) => {
|
|
141
|
+
if (chunk.length > maxInputBytes - size) return Effect.fail(tooLarge())
|
|
142
|
+
size += chunk.length
|
|
143
|
+
chunks.push(chunk)
|
|
144
|
+
return Effect.void
|
|
145
|
+
})
|
|
146
|
+
const bytes = new Uint8Array(size)
|
|
147
|
+
let offset = 0
|
|
148
|
+
for (const chunk of chunks) {
|
|
149
|
+
bytes.set(chunk, offset)
|
|
150
|
+
offset += chunk.length
|
|
151
|
+
}
|
|
152
|
+
return bytes
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const defaultLimits: Limits = { maxDimension, maxBytes }
|
|
156
|
+
|
|
157
|
+
/** JPEG qualities tried, in order, when the PNG re-encode is too large. */
|
|
158
|
+
const jpegQualities = [85, 70, 55, 40]
|
|
159
|
+
|
|
160
|
+
/** Scale applied on every shrink-and-retry round. */
|
|
161
|
+
const shrinkFactor = 0.75
|
|
162
|
+
|
|
163
|
+
/** Shrink rounds attempted before giving up. */
|
|
164
|
+
const maxShrinkRounds = 6
|
|
165
|
+
|
|
166
|
+
// =============================================================================
|
|
167
|
+
// Media type detection
|
|
168
|
+
// =============================================================================
|
|
169
|
+
|
|
170
|
+
const extensions: Record<string, ImageMediaType> = {
|
|
171
|
+
png: "image/png",
|
|
172
|
+
jpg: "image/jpeg",
|
|
173
|
+
jpeg: "image/jpeg",
|
|
174
|
+
gif: "image/gif",
|
|
175
|
+
webp: "image/webp",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Media type from a file extension. SVG and every non-raster extension is
|
|
180
|
+
* `None`, so those files keep being read as text.
|
|
181
|
+
*
|
|
182
|
+
* @since 1.0.0
|
|
183
|
+
* @category Detection
|
|
184
|
+
*/
|
|
185
|
+
export const mediaTypeFromPath = (
|
|
186
|
+
path: string,
|
|
187
|
+
): Option.Option<ImageMediaType> => {
|
|
188
|
+
const base = path.slice(
|
|
189
|
+
Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1,
|
|
190
|
+
)
|
|
191
|
+
const dot = base.lastIndexOf(".")
|
|
192
|
+
if (dot <= 0) return Option.none()
|
|
193
|
+
return Option.fromNullishOr(extensions[base.slice(dot + 1).toLowerCase()])
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const startsWith = (
|
|
197
|
+
bytes: Uint8Array,
|
|
198
|
+
prefix: ReadonlyArray<number>,
|
|
199
|
+
offset = 0,
|
|
200
|
+
) =>
|
|
201
|
+
bytes.length >= offset + prefix.length &&
|
|
202
|
+
prefix.every((byte, i) => bytes[offset + i] === byte)
|
|
203
|
+
|
|
204
|
+
const ascii = (text: string): ReadonlyArray<number> =>
|
|
205
|
+
Array.from(text, (char) => char.charCodeAt(0))
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Media type from the magic bytes at the start of the data.
|
|
209
|
+
*
|
|
210
|
+
* @since 1.0.0
|
|
211
|
+
* @category Detection
|
|
212
|
+
*/
|
|
213
|
+
export const mediaTypeFromBytes = (
|
|
214
|
+
bytes: Uint8Array,
|
|
215
|
+
): Option.Option<ImageMediaType> => {
|
|
216
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
|
217
|
+
return Option.some("image/png")
|
|
218
|
+
}
|
|
219
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff])) {
|
|
220
|
+
return Option.some("image/jpeg")
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
startsWith(bytes, ascii("GIF87a")) ||
|
|
224
|
+
startsWith(bytes, ascii("GIF89a"))
|
|
225
|
+
) {
|
|
226
|
+
return Option.some("image/gif")
|
|
227
|
+
}
|
|
228
|
+
if (startsWith(bytes, ascii("RIFF")) && startsWith(bytes, ascii("WEBP"), 8)) {
|
|
229
|
+
return Option.some("image/webp")
|
|
230
|
+
}
|
|
231
|
+
return Option.none()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const validDimensions = (width: number, height: number) =>
|
|
235
|
+
width > 0 && height > 0 ? Option.some({ width, height }) : Option.none()
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read conservative dimension bounds without decoding pixels, including inner
|
|
239
|
+
* frames in container formats. Truncated or malformed headers are rejected
|
|
240
|
+
* before they can reach Photon; over-budget dimensions stop parsing early.
|
|
241
|
+
* @since 1.0.0
|
|
242
|
+
* @category Detection
|
|
243
|
+
*/
|
|
244
|
+
export const dimensions = (
|
|
245
|
+
bytes: Uint8Array,
|
|
246
|
+
): Option.Option<{ width: number; height: number }> => {
|
|
247
|
+
const type = mediaTypeFromBytes(bytes)
|
|
248
|
+
if (Option.isNone(type)) return Option.none()
|
|
249
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
250
|
+
switch (type.value) {
|
|
251
|
+
case "image/png":
|
|
252
|
+
if (
|
|
253
|
+
bytes.length < 33 ||
|
|
254
|
+
view.getUint32(8) !== 13 ||
|
|
255
|
+
!startsWith(bytes, ascii("IHDR"), 12)
|
|
256
|
+
)
|
|
257
|
+
return Option.none()
|
|
258
|
+
return validDimensions(view.getUint32(16), view.getUint32(20))
|
|
259
|
+
case "image/gif": {
|
|
260
|
+
if (bytes.length < 13) return Option.none()
|
|
261
|
+
let width = view.getUint16(6, true)
|
|
262
|
+
let height = view.getUint16(8, true)
|
|
263
|
+
if (width === 0 || height === 0) return Option.none()
|
|
264
|
+
if (width * height > maxInputPixels) return validDimensions(width, height)
|
|
265
|
+
let offset = 13
|
|
266
|
+
const skipColorTable = (packed: number) => {
|
|
267
|
+
if (packed & 0x80) offset += 3 * (1 << ((packed & 7) + 1))
|
|
268
|
+
return offset <= bytes.length
|
|
269
|
+
}
|
|
270
|
+
const skipSubBlocks = () => {
|
|
271
|
+
while (offset < bytes.length) {
|
|
272
|
+
const length = view.getUint8(offset++)
|
|
273
|
+
if (length === 0) return true
|
|
274
|
+
if (length > bytes.length - offset) return false
|
|
275
|
+
offset += length
|
|
276
|
+
}
|
|
277
|
+
return false
|
|
278
|
+
}
|
|
279
|
+
if (!skipColorTable(view.getUint8(10))) return Option.none()
|
|
280
|
+
let hasFrame = false
|
|
281
|
+
while (offset < bytes.length) {
|
|
282
|
+
const tag = view.getUint8(offset++)
|
|
283
|
+
if (tag === 0x3b) {
|
|
284
|
+
return hasFrame ? validDimensions(width, height) : Option.none()
|
|
285
|
+
}
|
|
286
|
+
if (tag === 0x21) {
|
|
287
|
+
// Extension label followed by length-prefixed sub-blocks.
|
|
288
|
+
if (offset >= bytes.length) return Option.none()
|
|
289
|
+
offset++
|
|
290
|
+
if (!skipSubBlocks()) return Option.none()
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
if (tag !== 0x2c || bytes.length - offset < 9) return Option.none()
|
|
294
|
+
const frameWidth = view.getUint16(offset + 4, true)
|
|
295
|
+
const frameHeight = view.getUint16(offset + 6, true)
|
|
296
|
+
if (frameWidth === 0 || frameHeight === 0) return Option.none()
|
|
297
|
+
// The decoder allocates the frame independently of the logical screen.
|
|
298
|
+
width = Math.max(width, frameWidth)
|
|
299
|
+
height = Math.max(height, frameHeight)
|
|
300
|
+
if (width * height > maxInputPixels)
|
|
301
|
+
return validDimensions(width, height)
|
|
302
|
+
const packed = view.getUint8(offset + 8)
|
|
303
|
+
offset += 9
|
|
304
|
+
if (!skipColorTable(packed) || offset >= bytes.length)
|
|
305
|
+
return Option.none()
|
|
306
|
+
offset++ // LZW minimum code size; pixel decoding validates its value.
|
|
307
|
+
if (!skipSubBlocks()) return Option.none()
|
|
308
|
+
hasFrame = true
|
|
309
|
+
}
|
|
310
|
+
return Option.none()
|
|
311
|
+
}
|
|
312
|
+
case "image/jpeg": {
|
|
313
|
+
let offset = 2
|
|
314
|
+
while (offset < bytes.length) {
|
|
315
|
+
if (view.getUint8(offset++) !== 0xff) return Option.none()
|
|
316
|
+
// JPEG permits padding FF bytes before a marker.
|
|
317
|
+
while (offset < bytes.length && view.getUint8(offset) === 0xff) offset++
|
|
318
|
+
if (offset >= bytes.length) return Option.none()
|
|
319
|
+
const marker = view.getUint8(offset++)
|
|
320
|
+
if (
|
|
321
|
+
marker === 0xda ||
|
|
322
|
+
marker === 0xd9 ||
|
|
323
|
+
marker === 0 ||
|
|
324
|
+
marker === 0xd8
|
|
325
|
+
) {
|
|
326
|
+
return Option.none()
|
|
327
|
+
}
|
|
328
|
+
if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue
|
|
329
|
+
if (offset + 2 > bytes.length) return Option.none()
|
|
330
|
+
const length = view.getUint16(offset)
|
|
331
|
+
if (length < 2 || length > bytes.length - offset) return Option.none()
|
|
332
|
+
if (
|
|
333
|
+
marker >= 0xc0 &&
|
|
334
|
+
marker <= 0xcf &&
|
|
335
|
+
marker !== 0xc4 &&
|
|
336
|
+
marker !== 0xc8 &&
|
|
337
|
+
marker !== 0xcc
|
|
338
|
+
) {
|
|
339
|
+
if (
|
|
340
|
+
length < 8 ||
|
|
341
|
+
view.getUint8(offset + 7) === 0 ||
|
|
342
|
+
length !== 8 + 3 * view.getUint8(offset + 7)
|
|
343
|
+
)
|
|
344
|
+
return Option.none()
|
|
345
|
+
return validDimensions(
|
|
346
|
+
view.getUint16(offset + 5),
|
|
347
|
+
view.getUint16(offset + 3),
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
offset += length
|
|
351
|
+
}
|
|
352
|
+
return Option.none()
|
|
353
|
+
}
|
|
354
|
+
case "image/webp": {
|
|
355
|
+
if (bytes.length < 20) return Option.none()
|
|
356
|
+
const end = view.getUint32(4, true) + 8
|
|
357
|
+
if (end < 20 || end > bytes.length) return Option.none()
|
|
358
|
+
const u24 = (offset: number) =>
|
|
359
|
+
view.getUint8(offset) |
|
|
360
|
+
(view.getUint8(offset + 1) << 8) |
|
|
361
|
+
(view.getUint8(offset + 2) << 16)
|
|
362
|
+
let width = 0
|
|
363
|
+
let height = 0
|
|
364
|
+
let hasFrame = false
|
|
365
|
+
const include = (w: number, h: number) => {
|
|
366
|
+
if (w === 0 || h === 0) return false
|
|
367
|
+
width = Math.max(width, w)
|
|
368
|
+
height = Math.max(height, h)
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
// Only one level of nesting is legal: ANMF contains frame chunks, not ANMF.
|
|
372
|
+
const scan = (
|
|
373
|
+
start: number,
|
|
374
|
+
limit: number,
|
|
375
|
+
inFrame: boolean,
|
|
376
|
+
): boolean => {
|
|
377
|
+
let offset = start
|
|
378
|
+
while (offset < limit) {
|
|
379
|
+
if (limit - offset < 8) return false
|
|
380
|
+
const length = view.getUint32(offset + 4, true)
|
|
381
|
+
const data = offset + 8
|
|
382
|
+
const paddedLength = length + (length & 1)
|
|
383
|
+
if (paddedLength > limit - data) return false
|
|
384
|
+
if (startsWith(bytes, ascii("VP8X"), offset)) {
|
|
385
|
+
if (inFrame || offset !== 12 || length !== 10) return false
|
|
386
|
+
include(u24(data + 4) + 1, u24(data + 7) + 1)
|
|
387
|
+
} else if (startsWith(bytes, ascii("VP8L"), offset)) {
|
|
388
|
+
if (
|
|
389
|
+
length < 5 ||
|
|
390
|
+
view.getUint8(data) !== 0x2f ||
|
|
391
|
+
view.getUint8(data + 4) >> 5 !== 0
|
|
392
|
+
)
|
|
393
|
+
return false
|
|
394
|
+
include(
|
|
395
|
+
(view.getUint8(data + 1) |
|
|
396
|
+
((view.getUint8(data + 2) & 0x3f) << 8)) +
|
|
397
|
+
1,
|
|
398
|
+
((view.getUint8(data + 2) >> 6) |
|
|
399
|
+
(view.getUint8(data + 3) << 2) |
|
|
400
|
+
((view.getUint8(data + 4) & 0x0f) << 10)) +
|
|
401
|
+
1,
|
|
402
|
+
)
|
|
403
|
+
hasFrame = true
|
|
404
|
+
} else if (startsWith(bytes, ascii("VP8 "), offset)) {
|
|
405
|
+
if (
|
|
406
|
+
length < 10 ||
|
|
407
|
+
(view.getUint8(data) & 1) !== 0 ||
|
|
408
|
+
!startsWith(bytes, [0x9d, 0x01, 0x2a], data + 3) ||
|
|
409
|
+
!include(
|
|
410
|
+
view.getUint16(data + 6, true) & 0x3fff,
|
|
411
|
+
view.getUint16(data + 8, true) & 0x3fff,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
return false
|
|
415
|
+
hasFrame = true
|
|
416
|
+
} else if (startsWith(bytes, ascii("ANMF"), offset)) {
|
|
417
|
+
if (inFrame || length < 16) return false
|
|
418
|
+
include(u24(data + 6) + 1, u24(data + 9) + 1)
|
|
419
|
+
if (width * height > maxInputPixels) return true
|
|
420
|
+
if (!scan(data + 16, data + length, true)) return false
|
|
421
|
+
}
|
|
422
|
+
// Reject over-budget headers even if the pixel payload is incomplete.
|
|
423
|
+
if (width * height > maxInputPixels) return true
|
|
424
|
+
offset = data + paddedLength
|
|
425
|
+
}
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
428
|
+
if (!scan(12, end, false)) return Option.none()
|
|
429
|
+
return hasFrame || width * height > maxInputPixels
|
|
430
|
+
? validDimensions(width, height)
|
|
431
|
+
: Option.none()
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Whether a declared mime type is one of the supported image types.
|
|
438
|
+
*
|
|
439
|
+
* @since 1.0.0
|
|
440
|
+
* @category Detection
|
|
441
|
+
*/
|
|
442
|
+
export const isImageMediaType = Schema.is(ImageMediaType)
|
|
443
|
+
|
|
444
|
+
// =============================================================================
|
|
445
|
+
// Resize
|
|
446
|
+
// =============================================================================
|
|
447
|
+
|
|
448
|
+
const base64Length = (byteLength: number) => Math.ceil(byteLength / 3) * 4
|
|
449
|
+
|
|
450
|
+
const fits = (bytes: Uint8Array, limits: Limits) =>
|
|
451
|
+
base64Length(bytes.length) <= limits.maxBytes
|
|
452
|
+
|
|
453
|
+
const decode = (
|
|
454
|
+
data: Uint8Array,
|
|
455
|
+
): Effect.Effect<Photon.PhotonImage, ImageError> =>
|
|
456
|
+
Effect.try({
|
|
457
|
+
try: () => {
|
|
458
|
+
const image = Photon.PhotonImage.new_from_byteslice(data)
|
|
459
|
+
// Photon returns an empty image rather than throwing for some inputs.
|
|
460
|
+
if (image.get_width() === 0 || image.get_height() === 0) {
|
|
461
|
+
image.free()
|
|
462
|
+
throw new Error("Image has no pixels")
|
|
463
|
+
}
|
|
464
|
+
return image
|
|
465
|
+
},
|
|
466
|
+
catch: (cause) =>
|
|
467
|
+
new ImageError({
|
|
468
|
+
reason: "Decode",
|
|
469
|
+
message: `Could not decode image: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
470
|
+
}),
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
/** Encode as PNG, then JPEG at decreasing quality; `None` if nothing fits. */
|
|
474
|
+
const encodeWithin = (
|
|
475
|
+
image: Photon.PhotonImage,
|
|
476
|
+
limits: Limits,
|
|
477
|
+
): Option.Option<ImageData> => {
|
|
478
|
+
const png = image.get_bytes()
|
|
479
|
+
if (fits(png, limits))
|
|
480
|
+
return Option.some({ data: png, mediaType: "image/png" })
|
|
481
|
+
for (const quality of jpegQualities) {
|
|
482
|
+
const jpeg = image.get_bytes_jpeg(quality)
|
|
483
|
+
if (fits(jpeg, limits)) {
|
|
484
|
+
return Option.some({ data: jpeg, mediaType: "image/jpeg" })
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return Option.none()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Bring an image inside the limits.
|
|
492
|
+
*
|
|
493
|
+
* Images already within `limits` are returned unchanged, byte for byte.
|
|
494
|
+
* Otherwise the image is decoded, scaled down to fit the dimension cap with
|
|
495
|
+
* Lanczos3, and re-encoded as PNG, then as JPEG at decreasing quality. If it
|
|
496
|
+
* is still over the byte cap it is shrunk by 25% and the encoding is retried,
|
|
497
|
+
* up to a fixed number of rounds, after which it is rejected.
|
|
498
|
+
*
|
|
499
|
+
* `limits` exists so tests can exercise the reject path with a small fixture.
|
|
500
|
+
* It is not a user-facing setting.
|
|
501
|
+
*
|
|
502
|
+
* @since 1.0.0
|
|
503
|
+
* @category Resize
|
|
504
|
+
*/
|
|
505
|
+
export const prepare = Effect.fnUntraced(function* (options: {
|
|
506
|
+
readonly data: Uint8Array
|
|
507
|
+
readonly mediaType: ImageMediaType
|
|
508
|
+
readonly limits?: Limits | undefined
|
|
509
|
+
}): Effect.fn.Return<ImageData, ImageError> {
|
|
510
|
+
const limits = options.limits ?? defaultLimits
|
|
511
|
+
if (options.data.length > maxInputBytes) {
|
|
512
|
+
return yield* new ImageError({
|
|
513
|
+
reason: "TooLarge",
|
|
514
|
+
message: `Image exceeds the ${maxInputBytes} byte input limit`,
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
const size = dimensions(options.data)
|
|
518
|
+
if (Option.isNone(size)) {
|
|
519
|
+
return yield* new ImageError({
|
|
520
|
+
reason: "Decode",
|
|
521
|
+
message: "Invalid or truncated image header",
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
if (size.value.width * size.value.height > maxInputPixels) {
|
|
525
|
+
return yield* new ImageError({
|
|
526
|
+
reason: "TooLarge",
|
|
527
|
+
message: `Image exceeds the ${maxInputPixels} pixel input limit`,
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
const image = yield* decode(options.data)
|
|
531
|
+
let current = image
|
|
532
|
+
try {
|
|
533
|
+
const width = image.get_width()
|
|
534
|
+
const height = image.get_height()
|
|
535
|
+
const withinDimensions =
|
|
536
|
+
width <= limits.maxDimension && height <= limits.maxDimension
|
|
537
|
+
if (withinDimensions && fits(options.data, limits)) {
|
|
538
|
+
return { data: options.data, mediaType: options.mediaType }
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
let scale = Math.min(
|
|
542
|
+
1,
|
|
543
|
+
limits.maxDimension / width,
|
|
544
|
+
limits.maxDimension / height,
|
|
545
|
+
)
|
|
546
|
+
for (let round = 0; round <= maxShrinkRounds; round++) {
|
|
547
|
+
const targetWidth = Math.max(1, Math.round(width * scale))
|
|
548
|
+
const targetHeight = Math.max(1, Math.round(height * scale))
|
|
549
|
+
if (
|
|
550
|
+
targetWidth !== current.get_width() ||
|
|
551
|
+
targetHeight !== current.get_height()
|
|
552
|
+
) {
|
|
553
|
+
const resized = Photon.resize(
|
|
554
|
+
image,
|
|
555
|
+
targetWidth,
|
|
556
|
+
targetHeight,
|
|
557
|
+
Photon.SamplingFilter.Lanczos3,
|
|
558
|
+
)
|
|
559
|
+
if (current !== image) current.free()
|
|
560
|
+
current = resized
|
|
561
|
+
}
|
|
562
|
+
const encoded = encodeWithin(current, limits)
|
|
563
|
+
if (Option.isSome(encoded)) return encoded.value
|
|
564
|
+
scale *= shrinkFactor
|
|
565
|
+
}
|
|
566
|
+
return yield* new ImageError({
|
|
567
|
+
reason: "TooLarge",
|
|
568
|
+
message: `Image is still over ${limits.maxBytes} bytes of base64 after resizing`,
|
|
569
|
+
})
|
|
570
|
+
} finally {
|
|
571
|
+
if (current !== image) current.free()
|
|
572
|
+
image.free()
|
|
573
|
+
}
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
// =============================================================================
|
|
577
|
+
// Prompt helpers
|
|
578
|
+
// =============================================================================
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The text left in place of a stripped image part.
|
|
582
|
+
*
|
|
583
|
+
* @since 1.0.0
|
|
584
|
+
* @category Prompt
|
|
585
|
+
*/
|
|
586
|
+
export const omittedText = (fileName: string | undefined): string =>
|
|
587
|
+
`[image: ${fileName ?? "unnamed"} omitted]`
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Whether a prompt part is an image `file` part.
|
|
591
|
+
*
|
|
592
|
+
* @since 1.0.0
|
|
593
|
+
* @category Prompt
|
|
594
|
+
*/
|
|
595
|
+
export const isImagePart = (part: Prompt.Part): part is Prompt.FilePart =>
|
|
596
|
+
part.type === "file" && part.mediaType.startsWith("image/")
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Whether any user message in the prompt carries an image part.
|
|
600
|
+
*
|
|
601
|
+
* @since 1.0.0
|
|
602
|
+
* @category Prompt
|
|
603
|
+
*/
|
|
604
|
+
export const hasImages = (prompt: Prompt.Prompt): boolean =>
|
|
605
|
+
prompt.content.some(
|
|
606
|
+
(message) => message.role === "user" && message.content.some(isImagePart),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Replace every image part with an `omittedText` text part, so a model that
|
|
611
|
+
* cannot take images still sees that one was there.
|
|
612
|
+
*
|
|
613
|
+
* @since 1.0.0
|
|
614
|
+
* @category Prompt
|
|
615
|
+
*/
|
|
616
|
+
export const stripImages = (prompt: Prompt.Prompt): Prompt.Prompt => {
|
|
617
|
+
if (!hasImages(prompt)) return prompt
|
|
618
|
+
return Prompt.fromMessages(
|
|
619
|
+
prompt.content.map((message) => {
|
|
620
|
+
if (message.role !== "user" || !message.content.some(isImagePart)) {
|
|
621
|
+
return message
|
|
622
|
+
}
|
|
623
|
+
return Prompt.makeMessage("user", {
|
|
624
|
+
content: message.content.map((part) =>
|
|
625
|
+
isImagePart(part)
|
|
626
|
+
? Prompt.makePart("text", { text: omittedText(part.fileName) })
|
|
627
|
+
: part,
|
|
628
|
+
),
|
|
629
|
+
options: message.options,
|
|
630
|
+
})
|
|
631
|
+
}),
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Build a user message carrying image parts.
|
|
637
|
+
*
|
|
638
|
+
* @since 1.0.0
|
|
639
|
+
* @category Prompt
|
|
640
|
+
*/
|
|
641
|
+
export const userMessage = (
|
|
642
|
+
images: ReadonlyArray<{
|
|
643
|
+
readonly fileName: string
|
|
644
|
+
readonly mediaType: ImageMediaType
|
|
645
|
+
readonly data: Uint8Array
|
|
646
|
+
}>,
|
|
647
|
+
): Prompt.UserMessage =>
|
|
648
|
+
Prompt.makeMessage("user", {
|
|
649
|
+
content: images.map((image) =>
|
|
650
|
+
Prompt.makePart("file", {
|
|
651
|
+
mediaType: image.mediaType,
|
|
652
|
+
fileName: image.fileName,
|
|
653
|
+
data: image.data,
|
|
654
|
+
}),
|
|
655
|
+
),
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Bytes of a `file` part, whichever representation it carries. Byte arrays
|
|
660
|
+
* come back from persistence as base64 strings.
|
|
661
|
+
*
|
|
662
|
+
* @since 1.0.0
|
|
663
|
+
* @category Prompt
|
|
664
|
+
*/
|
|
665
|
+
export const partBytes = (part: Prompt.FilePart): Option.Option<Uint8Array> => {
|
|
666
|
+
if (part.data instanceof Uint8Array) return Option.some(part.data)
|
|
667
|
+
if (part.data instanceof URL) return Option.none()
|
|
668
|
+
const base64 = part.data.startsWith("data:")
|
|
669
|
+
? part.data.slice(part.data.indexOf(",") + 1)
|
|
670
|
+
: part.data
|
|
671
|
+
const decoded = Encoding.decodeBase64(base64)
|
|
672
|
+
return decoded._tag === "Success"
|
|
673
|
+
? Option.some(decoded.success)
|
|
674
|
+
: Option.none()
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// =============================================================================
|
|
678
|
+
// Provider errors
|
|
679
|
+
// =============================================================================
|
|
680
|
+
|
|
681
|
+
const unsupportedImagePattern =
|
|
682
|
+
/image[^.]{0,80}(only supported|not supported|unsupported|does not support|cannot|can't|invalid)|(only supported|not supported|unsupported|does not support|cannot|can't|invalid)[^.]{0,80}image/i
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Whether a provider error looks like a rejection of image input, so the
|
|
686
|
+
* turn can be retried once without the images.
|
|
687
|
+
*
|
|
688
|
+
* @since 1.0.0
|
|
689
|
+
* @category Provider errors
|
|
690
|
+
*/
|
|
691
|
+
export const isUnsupportedImageError = (error: AiError.AiError): boolean => {
|
|
692
|
+
const reason = error.reason
|
|
693
|
+
switch (reason._tag) {
|
|
694
|
+
case "InvalidRequestError":
|
|
695
|
+
case "UnknownError":
|
|
696
|
+
case "InternalProviderError":
|
|
697
|
+
return (
|
|
698
|
+
reason.description !== undefined &&
|
|
699
|
+
unsupportedImagePattern.test(reason.description)
|
|
700
|
+
)
|
|
701
|
+
default:
|
|
702
|
+
return false
|
|
703
|
+
}
|
|
704
|
+
}
|