diff --git a/CHANGELOG.md b/CHANGELOG.md index cb4e8cb6e..23f6318a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - CLI: reject junk between duration tokens and warn when malformed browser duration flags fall back to defaults. Thanks @devYRPauli! - Browser: run long local Pro consultations in a detached worker while the CLI remains attached to its session log, so unexpected foreground termination cannot stop answer capture; Ctrl-C still cancels the worker. Thanks @Rokurolize! - Browser: recognize ChatGPT's separate Pro effort control as the selected maximum effort for GPT-5.6 Sol when `--browser-thinking-time heavy` is requested. Thanks @Rokurolize! +- Gemini: type `.mp4`, `.mov`, and `.webm` uploads as video so Gemini receives them instead of silently discarding generic binary uploads. Thanks @mkubenka! - Browser: wait for saved conversation turns to hydrate before retrying capture after a reload or reattach, and reject shell-only stop controls as recovery evidence. Thanks @pdurlej! ## 0.16.1 — 2026-07-23 diff --git a/src/gemini-web/client.ts b/src/gemini-web/client.ts index 4c5a31e9d..c400131b9 100644 --- a/src/gemini-web/client.ts +++ b/src/gemini-web/client.ts @@ -49,12 +49,33 @@ const GEMINI_UPLOAD_MIME_TYPES: Record = { ".gif": "image/gif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", + ".mov": "video/quicktime", + ".mp4": "video/mp4", ".pdf": "application/pdf", ".png": "image/png", ".svg": "image/svg+xml", + ".webm": "video/webm", ".webp": "image/webp", }; +/** + * Resolve the MIME type Gemini should be told an upload carries. + * + * Gemini silently discards uploads it cannot type: the run still reports the file as + * attached, but the model never receives it and answers as though nothing was sent. + * Anything falling back to `application/octet-stream` is therefore invisible to the model. + * + * Only formats confirmed to work against the Gemini web upload endpoint are listed. The + * endpoint also gates on the file extension, not just the declared type — an `.m4v` byte + * for byte identical to a working `.mp4`, and declared `video/mp4`, is still dropped — so + * entries here cannot be extrapolated from what the Gemini API documents. + */ +export function resolveGeminiUploadMimeType(filePath: string): string { + return ( + GEMINI_UPLOAD_MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream" + ); +} + function getNestedValue(value: unknown, pathParts: Array, fallback: T): T { let current: unknown = value; for (const part of pathParts) { @@ -187,8 +208,7 @@ async function uploadGeminiFile( const absPath = path.resolve(process.cwd(), filePath); const data = await readFile(absPath); const fileName = path.basename(absPath); - const mimeType = - GEMINI_UPLOAD_MIME_TYPES[path.extname(absPath).toLowerCase()] ?? "application/octet-stream"; + const mimeType = resolveGeminiUploadMimeType(absPath); const form = new FormData(); form.append("file", new Blob([data], { type: mimeType }), fileName); diff --git a/tests/gemini-upload-mime.test.ts b/tests/gemini-upload-mime.test.ts new file mode 100644 index 000000000..69c1ff107 --- /dev/null +++ b/tests/gemini-upload-mime.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { resolveGeminiUploadMimeType } from "../src/gemini-web/client.js"; + +describe("resolveGeminiUploadMimeType", () => { + it("types the video formats Gemini can actually read", () => { + // Untyped uploads are silently discarded: the run reports the file as attached + // while the model answers as though nothing was sent. + expect(resolveGeminiUploadMimeType("/tmp/clip.mp4")).toBe("video/mp4"); + expect(resolveGeminiUploadMimeType("/tmp/clip.mov")).toBe("video/quicktime"); + expect(resolveGeminiUploadMimeType("/tmp/clip.webm")).toBe("video/webm"); + }); + + it("keeps existing image and document types unchanged", () => { + expect(resolveGeminiUploadMimeType("/tmp/shot.png")).toBe("image/png"); + expect(resolveGeminiUploadMimeType("/tmp/shot.jpg")).toBe("image/jpeg"); + expect(resolveGeminiUploadMimeType("/tmp/shot.webp")).toBe("image/webp"); + expect(resolveGeminiUploadMimeType("/tmp/doc.pdf")).toBe("application/pdf"); + }); + + it("is case insensitive", () => { + expect(resolveGeminiUploadMimeType("/tmp/CLIP.MP4")).toBe("video/mp4"); + expect(resolveGeminiUploadMimeType("/tmp/SHOT.PNG")).toBe("image/png"); + }); + + it("leaves formats the endpoint rejects on the octet-stream fallback", () => { + // Audio and plain text are never delivered by this endpoint whatever type is + // declared, and .m4v/.mkv/.3gp are dropped or error out, so mapping them would + // only advertise support that does not exist. + for (const p of [ + "/tmp/a.wav", + "/tmp/a.mp3", + "/tmp/a.m4a", + "/tmp/n.txt", + "/tmp/c.m4v", + "/tmp/c.mkv", + ]) { + expect(resolveGeminiUploadMimeType(p)).toBe("application/octet-stream"); + } + }); + + it("falls back to octet-stream for unknown extensions", () => { + expect(resolveGeminiUploadMimeType("/tmp/archive.xyz")).toBe("application/octet-stream"); + expect(resolveGeminiUploadMimeType("/tmp/noext")).toBe("application/octet-stream"); + }); +});