Skip to content

Commit 5596640

Browse files
authored
feat(files): let the agent read HEIC photos (#6346)
* feat(files): let the agent read HEIC photos iPhone photos reach the model as HEIC, which no vision model accepts - the Claude Messages API takes JPEG, PNG, GIF and WebP only - so the agent saw nothing. 75 HEIC files are already in production, 64 of them in one workspace uploaded over the last two days. sharp cannot cover this: its prebuilt libvips ships libheif with AV1 but not HEVC (sharp.format.heif.input.fileSuffix is ['.avif']), so a real iPhone photo fails with 'Security limit exceeded'. Verified against both a HEVC-coded sample (sharp fails, heic-convert decodes 2.99MB to a 3992x2992 JPEG in ~950ms) and an AV1-coded mif1 sample (sharp decodes it natively). Decoder selection is capability-based, not brand-based: sharp is always tried first and the WebAssembly decoder runs only on bytes it could not read. The container brand cannot identify the codec anyway - mif1 carries either - so choosing from it would push AV1 files down the slow path. This mirrors how PhotoPrism layers libvips over libheif. Also route the image path on the effective MIME type, since a phone upload commonly stores as application/octet-stream and would otherwise be read as a binary the model never sees, and stop reporting an undecodable image as 'too large'. * refactor(files): gate every vision passthrough on model-supported media types Review found two passthroughs that still handed the model bytes it cannot decode. The sharp-load-failure branch returned raw HEIF, and the already-small-enough branch returned raw AVIF, TIFF, BMP or ICO — all of which isImageFileType accepts and no vision model does. Gating all three on the existing MODEL_SUPPORTED_IMAGE_MIME_TYPES subsumes the ad-hoc isHeifContainer re-sniff, and re-encoding an unsupported format falls out of the resize ladder that was already there. Also drop two constants that were pure indirection (a one-use alias for 'image/jpeg', and a quality value identical to heic-convert's default), trim the oversized comments, log successful transcodes so the ratio is visible in prod, and replace a detection test that could not fail. * fix(files): read HEIF compatible brands, not just the major brand A standards-valid HEIF may carry a generic major brand such as isom and declare heic, heix or mif1 only among the compatible brands that follow the minor_version at offset 12. Reading bytes 8-11 alone classified those as non-HEIF, skipping the fallback decode and leaving a small undecodable file to reach the model as raw bytes.
1 parent 85a4cb0 commit 5596640

5 files changed

Lines changed: 241 additions & 23 deletions

File tree

apps/sim/lib/copilot/vfs/file-reader.ts

Lines changed: 63 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ import { recordFileRead } from '@/lib/copilot/request/metrics'
1414
import { markSpanForError } from '@/lib/copilot/request/otel'
1515
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1616
import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
17-
import { isImageFileType } from '@/lib/uploads/utils/file-utils'
17+
import { isHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic'
18+
import {
19+
isImageFileType,
20+
MODEL_SUPPORTED_IMAGE_MIME_TYPES,
21+
resolveEffectiveMimeType,
22+
} from '@/lib/uploads/utils/file-utils'
1823

1924
// Lazy tracer (same pattern as lib/copilot/request/otel.ts).
2025
function getVfsTracer() {
@@ -91,54 +96,82 @@ interface PreparedVisionImage {
9196
* dimension/quality chosen.
9297
*/
9398
async function prepareImageForVision(
94-
buffer: Buffer,
99+
sourceBuffer: Buffer,
95100
claimedType: string
96101
): Promise<PreparedVisionImage | null> {
97102
return getVfsTracer().startActiveSpan(
98103
TraceSpan.CopilotVfsPrepareImage,
99104
{
100105
attributes: {
101-
[TraceAttr.CopilotVfsInputBytes]: buffer.length,
106+
[TraceAttr.CopilotVfsInputBytes]: sourceBuffer.length,
102107
[TraceAttr.CopilotVfsInputMediaTypeClaimed]: claimedType,
103108
},
104109
},
105110
async (span) => {
106111
try {
107-
const mediaType = detectImageMime(buffer, claimedType)
108-
span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, mediaType)
112+
const detectedType = detectImageMime(sourceBuffer, claimedType)
113+
span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, detectedType)
109114

110115
let sharpModule: SharpConstructor
111116
try {
112117
sharpModule = (await import('sharp')).default
113118
} catch (err) {
114119
logger.warn('Failed to load sharp for image preparation', {
115-
mediaType,
120+
mediaType: detectedType,
116121
error: toError(err).message,
117122
})
118123
span.setAttribute(TraceAttr.CopilotVfsSharpLoadFailed, true)
119-
const fitsWithoutSharp = buffer.length <= MAX_IMAGE_READ_BYTES
124+
const fitsWithoutSharp =
125+
MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(detectedType) &&
126+
sourceBuffer.length <= MAX_IMAGE_READ_BYTES
120127
span.setAttribute(
121128
TraceAttr.CopilotVfsOutcome,
122129
fitsWithoutSharp ? 'passthrough_no_sharp' : 'rejected_no_sharp'
123130
)
124-
return fitsWithoutSharp ? { buffer, mediaType, resized: false } : null
131+
return fitsWithoutSharp
132+
? { buffer: sourceBuffer, mediaType: detectedType, resized: false }
133+
: null
125134
}
126135

127-
let metadata: Awaited<ReturnType<ReturnType<typeof sharpModule>['metadata']>>
128-
try {
129-
metadata = await sharpModule(buffer, { limitInputPixels: false }).metadata()
130-
} catch (err) {
131-
logger.warn('Failed to read image metadata for VFS read', {
132-
mediaType,
133-
error: toError(err).message,
134-
})
136+
const readMetadata = (candidate: Buffer) =>
137+
sharpModule(candidate, { limitInputPixels: false })
138+
.metadata()
139+
.catch((err: unknown) => {
140+
logger.warn('Failed to read image metadata for VFS read', {
141+
mediaType: detectedType,
142+
error: toError(err).message,
143+
})
144+
return null
145+
})
146+
147+
// sharp first: its libvips reads everything we accept except HEVC-coded
148+
// HEIF, and it is ~10x faster than the WASM decoder. Capability-based
149+
// rather than brand-based, so AV1-coded `mif1` — which sharp handles
150+
// natively — does not get sent down the slow path.
151+
let buffer = sourceBuffer
152+
let mediaType = detectedType
153+
let metadata = await readMetadata(sourceBuffer)
154+
155+
if (!metadata && isHeifContainer(sourceBuffer)) {
156+
const transcoded = await transcodeHeicToJpeg(sourceBuffer)
157+
if (transcoded) {
158+
buffer = transcoded
159+
mediaType = 'image/jpeg'
160+
metadata = await readMetadata(transcoded)
161+
}
162+
}
163+
164+
if (!metadata) {
135165
span.setAttribute(TraceAttr.CopilotVfsMetadataFailed, true)
136-
const fitsWithoutSharp = buffer.length <= MAX_IMAGE_READ_BYTES
166+
// Bytes the model cannot decode are worse than no image: it describes
167+
// them as empty rather than reporting them as broken.
168+
const passthroughViable =
169+
MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mediaType) && buffer.length <= MAX_IMAGE_READ_BYTES
137170
span.setAttribute(
138171
TraceAttr.CopilotVfsOutcome,
139-
fitsWithoutSharp ? 'passthrough_no_metadata' : 'rejected_no_metadata'
172+
passthroughViable ? 'passthrough_no_metadata' : 'rejected_no_metadata'
140173
)
141-
return fitsWithoutSharp ? { buffer, mediaType, resized: false } : null
174+
return passthroughViable ? { buffer, mediaType, resized: false } : null
142175
}
143176

144177
const width = metadata.width ?? 0
@@ -148,11 +181,15 @@ async function prepareImageForVision(
148181
[TraceAttr.CopilotVfsInputHeight]: height,
149182
})
150183

151-
const needsResize =
184+
// A format the model cannot decode has to be re-encoded even when it is
185+
// already small enough — the ladder below emits JPEG or WebP, both of
186+
// which it accepts.
187+
const needsReencode =
188+
!MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mediaType) ||
152189
buffer.length > MAX_IMAGE_READ_BYTES ||
153190
width > MAX_IMAGE_DIMENSION ||
154191
height > MAX_IMAGE_DIMENSION
155-
if (!needsResize) {
192+
if (!needsReencode) {
156193
span.setAttributes({
157194
[TraceAttr.CopilotVfsResized]: false,
158195
[TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.PassthroughFitsBudget,
@@ -300,14 +337,17 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise<FileR
300337
},
301338
async (span) => {
302339
try {
303-
if (isImageFileType(record.type)) {
340+
// Resolve against the filename: a phone upload commonly stores as
341+
// `application/octet-stream`, and matching the raw type would route a real
342+
// image down the binary path where the model never sees it.
343+
if (isImageFileType(resolveEffectiveMimeType(record.type, record.name))) {
304344
span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image)
305345
const originalBuffer = await fetchWorkspaceFileBuffer(record)
306346
const prepared = await prepareImageForVision(originalBuffer, record.type)
307347
if (!prepared) {
308348
span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge)
309349
return {
310-
content: `[Image too large: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, limit 5MB after resize/compression)]`,
350+
content: `[Image unavailable: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB). It could not be decoded, or still exceeded the 5MB vision limit after resizing.]`,
311351
totalLines: 1,
312352
}
313353
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { isHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic'
6+
7+
/**
8+
* An ISO-BMFF `ftyp` box: 4-byte size, the `ftyp` marker, the major brand, a
9+
* 4-byte minor version, then any compatible brands.
10+
*/
11+
function ftypHeader(brand: string, compatible: string[] = []): Buffer {
12+
const size = 16 + compatible.length * 4
13+
const header = Buffer.alloc(size)
14+
header.writeUInt32BE(size, 0)
15+
header.write('ftyp', 4, 'ascii')
16+
header.write(brand, 8, 'ascii')
17+
compatible.forEach((entry, index) => header.write(entry, 16 + index * 4, 'ascii'))
18+
return header
19+
}
20+
21+
describe('isHeifContainer', () => {
22+
it.each(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1'])(
23+
'detects the %s brand',
24+
(brand) => {
25+
expect(isHeifContainer(ftypHeader(brand))).toBe(true)
26+
}
27+
)
28+
29+
it.each(['avif', 'avis'])(
30+
'also claims the %s brand — the question is "is this HEIF", not "which codec"',
31+
(brand) => {
32+
expect(isHeifContainer(ftypHeader(brand))).toBe(true)
33+
}
34+
)
35+
36+
it('rejects other image formats', () => {
37+
expect(isHeifContainer(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0, 0, 0, 0, 0]))).toBe(
38+
false
39+
)
40+
expect(isHeifContainer(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0, 0, 0, 0, 0, 0, 0, 0]))).toBe(
41+
false
42+
)
43+
})
44+
45+
it('rejects a HEIF brand that is not behind an ftyp box', () => {
46+
const riff = Buffer.alloc(16)
47+
riff.write('RIFF', 0, 'ascii')
48+
riff.write('heic', 8, 'ascii')
49+
expect(isHeifContainer(riff)).toBe(false)
50+
})
51+
52+
it('rejects an unknown brand in a well-formed ftyp box', () => {
53+
expect(isHeifContainer(ftypHeader('qt '))).toBe(false)
54+
})
55+
56+
it('detects a HEIF brand declared only among the compatible brands', () => {
57+
// Standards-valid: a generic major brand with the HEIF brand listed after it.
58+
expect(isHeifContainer(ftypHeader('isom', ['iso2', 'heic', 'mif1']))).toBe(true)
59+
expect(isHeifContainer(ftypHeader('mp42', ['heix']))).toBe(true)
60+
})
61+
62+
it('rejects a box whose compatible brands are all non-HEIF', () => {
63+
expect(isHeifContainer(ftypHeader('isom', ['iso2', 'mp41', 'mp42']))).toBe(false)
64+
})
65+
66+
it('does not read compatible brands past the declared box size', () => {
67+
const truncated = ftypHeader('isom', ['heic'])
68+
truncated.writeUInt32BE(16, 0)
69+
expect(isHeifContainer(truncated)).toBe(false)
70+
})
71+
72+
it('rejects buffers too short to carry a brand', () => {
73+
expect(isHeifContainer(Buffer.alloc(0))).toBe(false)
74+
expect(isHeifContainer(ftypHeader('heic').subarray(0, 11))).toBe(false)
75+
})
76+
})
77+
78+
describe('transcodeHeicToJpeg', () => {
79+
it('returns null for bytes libheif cannot decode', async () => {
80+
// Also proves the dynamic `heic-convert` import resolves at runtime, which no
81+
// amount of type-checking establishes for a lazily loaded WebAssembly module.
82+
expect(await transcodeHeicToJpeg(ftypHeader('heic'))).toBeNull()
83+
})
84+
})
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
4+
const logger = createLogger('HeicTranscode')
5+
6+
/**
7+
* ISO-BMFF major brands in the HEIF family. The brand occupies bytes 8-11,
8+
* immediately after the `ftyp` box marker at 4-7.
9+
*
10+
* The list is deliberately broad, `avif` included. It answers "are these bytes
11+
* worth handing to a HEIF decoder", not "which codec is inside" — the brand cannot
12+
* answer the latter anyway, since `mif1` is generic and carries either HEVC or AV1.
13+
*/
14+
const HEIF_BRANDS = new Set([
15+
'heic',
16+
'heix',
17+
'heim',
18+
'heis',
19+
'hevc',
20+
'hevx',
21+
'mif1',
22+
'msf1',
23+
'avif',
24+
'avis',
25+
])
26+
27+
/**
28+
* Whether these bytes are an ISO-BMFF container in the HEIF family.
29+
*
30+
* Sniffed rather than read off the declared type because the common case is a
31+
* `.heic` stored as `application/octet-stream`, where the declared type says
32+
* nothing at all.
33+
*/
34+
export function isHeifContainer(buffer: Buffer): boolean {
35+
if (buffer.length < 12) return false
36+
if (buffer.toString('ascii', 4, 8) !== 'ftyp') return false
37+
if (HEIF_BRANDS.has(buffer.toString('ascii', 8, 12))) return true
38+
39+
// A standards-valid HEIF may carry a generic major brand such as `isom` and name
40+
// the HEIF brand only among the compatible brands, which follow the 4-byte
41+
// minor_version at offset 12 and run to the end of the box. A declared size of 0
42+
// or 1 (the ISO-BMFF size escapes, which `ftyp` does not use) leaves `end` below
43+
// the loop's start, so those simply do not scan.
44+
const end = Math.min(buffer.readUInt32BE(0), buffer.length)
45+
for (let offset = 16; offset + 4 <= end; offset += 4) {
46+
if (HEIF_BRANDS.has(buffer.toString('ascii', offset, offset + 4))) return true
47+
}
48+
return false
49+
}
50+
51+
/**
52+
* Transcode a HEVC-coded HEIF still to JPEG.
53+
*
54+
* Two reasons, neither with a workaround: no vision model accepts HEIC (the Claude
55+
* Messages API takes JPEG, PNG, GIF, and WebP only), and sharp's prebuilt libvips
56+
* ships libheif with AV1 but not HEVC — it decodes AVIF and rejects an iPhone photo.
57+
*
58+
* Returns `null` when the bytes cannot be decoded; never a partial image.
59+
*/
60+
export async function transcodeHeicToJpeg(buffer: Buffer): Promise<Buffer | null> {
61+
try {
62+
const convert = (await import('heic-convert')).default
63+
const jpeg = await convert({ buffer, format: 'JPEG' })
64+
logger.info('Transcoded HEIC image', {
65+
inputBytes: buffer.length,
66+
outputBytes: jpeg.length,
67+
})
68+
return Buffer.from(jpeg)
69+
} catch (error) {
70+
logger.warn('Failed to transcode HEIC image', {
71+
bytes: buffer.length,
72+
brand: buffer.toString('ascii', 8, 12),
73+
error: getErrorMessage(error),
74+
})
75+
return null
76+
}
77+
}

apps/sim/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@
168168
"google-auth-library": "10.5.0",
169169
"gray-matter": "^4.0.3",
170170
"groq-sdk": "^0.15.0",
171+
"heic-convert": "2.1.0",
171172
"html-to-text": "^9.0.5",
172173
"http-proxy-agent": "7.0.2",
173174
"https-proxy-agent": "7.0.6",
@@ -248,6 +249,7 @@
248249
"@types/archiver": "8.0.0",
249250
"@types/busboy": "1.5.4",
250251
"@types/fluent-ffmpeg": "2.1.28",
252+
"@types/heic-convert": "2.1.1",
251253
"@types/html-to-text": "9.0.4",
252254
"@types/js-yaml": "4.0.9",
253255
"@types/jsdom": "21.1.7",

0 commit comments

Comments
 (0)