Skip to content

Commit c54677b

Browse files
authored
fix(core): tell HEIC, QuickTime and M4A apart from MP4 (#58887)
1 parent 421e44e commit c54677b

4 files changed

Lines changed: 66 additions & 8 deletions

File tree

‎packages/core/src/mime.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,26 @@ export function detect(bytes: Uint8Array) {
88
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
99
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
1010
return "image/webp"
11-
if (
12-
startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) &&
13-
(startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) || startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73]))
14-
)
15-
return "image/avif"
11+
if (bytes.length >= 12 && ascii(bytes, 4, 8) === "ftyp") {
12+
// AVIF, HEIC, QuickTime and M4A share MP4's container; the major brand tells them apart, and generic HEIF brands
13+
// list `avif`/`avis` among the compatible brands that follow the minor version.
14+
const brand = ascii(bytes, 8, 12)
15+
const boxEnd = Math.min(new DataView(bytes.buffer, bytes.byteOffset).getUint32(0), bytes.length)
16+
const compatible = Array.from({ length: Math.floor((boxEnd - 16) / 4) }, (_, index) =>
17+
ascii(bytes, 16 + index * 4, 20 + index * 4),
18+
)
19+
const generic = brand === "mif1" || brand === "msf1"
20+
if (
21+
brand === "avif" ||
22+
brand === "avis" ||
23+
(generic && (compatible.includes("avif") || compatible.includes("avis")))
24+
)
25+
return "image/avif"
26+
if (generic || ["heic", "heix", "hevc", "hevx"].includes(brand)) return "image/heic"
27+
if (brand === "qt ") return "video/quicktime"
28+
if (brand === "M4A ") return "audio/mp4"
29+
return "video/mp4"
30+
}
1631
return isText(bytes) ? "text/plain" : "application/octet-stream"
1732
}
1833

@@ -26,6 +41,10 @@ export const isTextual = (mime: string) =>
2641
mime === "application/javascript" ||
2742
mime === "application/x-javascript"
2843

44+
function ascii(bytes: Uint8Array, start: number, end: number) {
45+
return String.fromCharCode(...bytes.subarray(start, end))
46+
}
47+
2948
function startsWith(bytes: Uint8Array, prefix: number[]) {
3049
return prefix.every((value, index) => bytes[index] === value)
3150
}

‎packages/core/src/session/prompt.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import { SessionBlob } from "./blob.js"
1919
import { AttachmentError, SkillNotFoundError } from "./error.js"
2020
import { SessionStored } from "./stored.js"
2121

22+
// Image types the resizer can decode; others such as HEIC and AVIF go to the provider unchanged.
23+
const RESIZABLE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"])
24+
2225
export type Input = {
2326
text: string
2427
files?: PromptInput.Prompt["files"]
@@ -146,7 +149,7 @@ const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachme
146149
data: string,
147150
mime: string,
148151
) {
149-
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
152+
if (!RESIZABLE_IMAGE_MIMES.has(mime)) return { data: Base64.make(data), mime }
150153
const image = yield* Image.Service
151154
const label = attachmentLabel(input)
152155
const content = { uri: label, content: data, encoding: "base64" as const, mime }

‎packages/core/test/mime.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { Mime } from "@opencode/core/mime"
3+
4+
const ftyp = (hex: string) => Uint8Array.from(Buffer.from(hex, "hex"))
5+
6+
describe("Mime.detect", () => {
7+
test("tells ISO-BMFF brands apart", () => {
8+
expect(Mime.detect(ftyp("000000206674797061766966000000004d695072617669666d6961666d696631"))).toBe("image/avif")
9+
expect(Mime.detect(ftyp("000000186674797061766973000000006d73663161766973"))).toBe("image/avif")
10+
expect(Mime.detect(ftyp("00000018667479706d69663100000000617669666d69663100000000"))).toBe("image/avif")
11+
expect(Mime.detect(ftyp("000000246674797068656963000000006d6966314d6950726d6961664d694842"))).toBe("image/heic")
12+
expect(Mime.detect(ftyp("00000018667479706d69663100000000686569636d69663100000000"))).toBe("image/heic")
13+
expect(Mime.detect(ftyp("000000206674797069736f6d0000020069736f6d69736f32617663316d703431"))).toBe("video/mp4")
14+
expect(Mime.detect(ftyp("00000014667479707174202000000200717420200000000877696465"))).toBe("video/quicktime")
15+
expect(Mime.detect(ftyp("0000001c667479704d344120000002004d34412069736f6d69736f3200000008"))).toBe("audio/mp4")
16+
})
17+
})

‎packages/core/test/session-prompt.test.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,10 @@ const locations = makeGlobalNode({
9494
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
9595
}),
9696
Layer.mock(Image.Service, {
97-
normalize: (_resource, content) =>
98-
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
97+
normalize: (resource, content) =>
98+
content.mime === "image/heic"
99+
? Effect.fail(new Image.DecodeError({ resource }))
100+
: Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
99101
}),
100102
Layer.mock(Snapshot.Service, {
101103
capture: () => Effect.undefined,
@@ -520,6 +522,23 @@ describe("Session.prompt", () => {
520522
}),
521523
)
522524

525+
it.effect("passes images the resizer cannot decode through unchanged", () =>
526+
Effect.gen(function* () {
527+
yield* setup
528+
const session = yield* Session.Service
529+
const data = Buffer.from("000000186674797068656963000000006d69663168656963", "hex").toString("base64")
530+
531+
const message = yield* session.prompt({
532+
sessionID,
533+
text: "Inspect this photo",
534+
files: [{ uri: `data:image/heic;base64,${data}` }],
535+
resume: false,
536+
})
537+
538+
expect(message.payload.files).toEqual([{ data, mime: "image/heic", source: { type: "inline" } }])
539+
}),
540+
)
541+
523542
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
524543
Effect.gen(function* () {
525544
yield* setup

0 commit comments

Comments
 (0)