diff --git a/chat-ui/src/components/chat-input.tsx b/chat-ui/src/components/chat-input.tsx
index b4106f8..19347a3 100644
--- a/chat-ui/src/components/chat-input.tsx
+++ b/chat-ui/src/components/chat-input.tsx
@@ -163,7 +163,7 @@ export function ChatInput({
multiple
className="hidden"
onChange={handleFileSelect}
- accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,text/*,.js,.ts,.tsx,.jsx,.py,.go,.rs,.rb,.java,.kt,.swift,.c,.cpp,.h,.hpp,.sh,.bash,.zsh,.toml,.ini,.sql,.json,.md,.csv,.html,.xml,.yaml,.yml"
+ accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/avi,video/x-msvideo,video/mov,video/quicktime,video/x-matroska,.mp4,.avi,.mov,.mkv,application/pdf,text/*,.js,.ts,.tsx,.jsx,.py,.go,.rs,.rb,.java,.kt,.swift,.c,.cpp,.h,.hpp,.sh,.bash,.zsh,.toml,.ini,.sql,.json,.md,.csv,.html,.xml,.yaml,.yml"
/>
diff --git a/chat-ui/src/components/drop-overlay.tsx b/chat-ui/src/components/drop-overlay.tsx
index 4de76db..66f614c 100644
--- a/chat-ui/src/components/drop-overlay.tsx
+++ b/chat-ui/src/components/drop-overlay.tsx
@@ -15,7 +15,7 @@ export function DropOverlay({ visible }: { visible: boolean }) {
Drop files here
- Images, PDFs, and text files are supported
+ Images, videos, PDFs, and text files are supported
diff --git a/chat-ui/src/hooks/use-attachments.ts b/chat-ui/src/hooks/use-attachments.ts
index 5bdd5fe..1500d12 100644
--- a/chat-ui/src/hooks/use-attachments.ts
+++ b/chat-ui/src/hooks/use-attachments.ts
@@ -7,7 +7,10 @@ import { toast } from "sonner";
const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const MAX_FILES = 10;
-function getMaxSizeForType(mimeType: string): { limit: number; label: string } {
+function getMaxSizeForType(mimeType: string, filename: string): { limit: number; label: string } {
+ if (mimeType.startsWith("video/") || /\.(mp4|avi|mov|mkv)$/i.test(filename)) {
+ return { limit: 50 * 1024 * 1024, label: "Videos can be up to 50 MB" };
+ }
if (mimeType === "application/pdf") return { limit: 32 * 1024 * 1024, label: "PDFs can be up to 32 MB" };
if (mimeType.startsWith("image/")) return { limit: 10 * 1024 * 1024, label: "Images can be up to 10 MB" };
return { limit: 1 * 1024 * 1024, label: "Text files can be up to 1 MB" };
@@ -73,7 +76,7 @@ export function useAttachments(): {
toast.error("iOS HEIC photos are not supported. Please choose JPEG export from the Photos app.");
continue;
}
- const sizeInfo = getMaxSizeForType(file.type);
+ const sizeInfo = getMaxSizeForType(file.type, file.name);
if (file.size > sizeInfo.limit) {
toast.error(`"${file.name}" is too large. ${sizeInfo.label}.`);
continue;
diff --git a/src/chat/__tests__/message-builder.test.ts b/src/chat/__tests__/message-builder.test.ts
index 6c38f39..22044bf 100644
--- a/src/chat/__tests__/message-builder.test.ts
+++ b/src/chat/__tests__/message-builder.test.ts
@@ -69,6 +69,33 @@ describe("buildUserMessageParam", () => {
expect(textBlock?.text).toBe("describe this");
});
+ test("text + video attachment produces a base64 video block", async () => {
+ const videoPath = join(tmpDir, "clip.mp4");
+ writeFileSync(videoPath, Buffer.from("fake-video-data"));
+
+ attachmentStore.create({
+ sessionId: "test-session-mb",
+ kind: "video",
+ filename: "clip.mp4",
+ mimeType: "video/mp4",
+ sizeBytes: 15,
+ storagePath: videoPath,
+ });
+ const rows = attachmentStore.getBySession("test-session-mb");
+ const attId = rows[0]?.id ?? "";
+
+ const msg = await buildUserMessageParam("describe this clip", [attId], attachmentStore);
+ const content = msg.content as unknown as Array>;
+ expect(content.length).toBe(2);
+
+ const videoBlock = content[0];
+ expect(videoBlock?.type).toBe("video");
+ const source = videoBlock?.source as Record;
+ expect(source?.type).toBe("base64");
+ expect(source?.media_type).toBe("video/mp4");
+ expect(source?.data).toBe(Buffer.from("fake-video-data").toString("base64"));
+ });
+
test("text + PDF attachment produces DocumentBlockParam with base64", async () => {
const pdfPath = join(tmpDir, "doc.pdf");
writeFileSync(pdfPath, Buffer.from("fake-pdf-data"));
diff --git a/src/chat/__tests__/upload.test.ts b/src/chat/__tests__/upload.test.ts
index e5f7cb5..ba77d1f 100644
--- a/src/chat/__tests__/upload.test.ts
+++ b/src/chat/__tests__/upload.test.ts
@@ -31,6 +31,16 @@ describe("validators", () => {
expect(result.ok).toBe(true);
});
+ test("accepts MP4 video", () => {
+ const result = validateFile("video/mp4", 5 * 1024 * 1024, "clip.mp4");
+ expect(result.ok).toBe(true);
+ });
+
+ test("accepts MOV video with browser MIME type", () => {
+ const result = validateFile("video/quicktime", 5 * 1024 * 1024, "clip.mov");
+ expect(result.ok).toBe(true);
+ });
+
test("accepts PDF", () => {
const result = validateFile("application/pdf", 5 * 1024 * 1024, "document.pdf");
expect(result.ok).toBe(true);
@@ -107,6 +117,14 @@ describe("validators", () => {
}
});
+ test("rejects oversized video (> 50MB)", () => {
+ const result = validateFile("video/mp4", 51 * 1024 * 1024, "huge.mp4");
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.reason).toBe("video_too_large");
+ }
+ });
+
test("rejects oversized PDF (> 32MB)", () => {
const result = validateFile("application/pdf", 33 * 1024 * 1024, "huge.pdf");
expect(result.ok).toBe(false);
@@ -167,8 +185,8 @@ describe("validators", () => {
expect(result.ok).toBe(true);
});
- test("rejects oversized request (> 40MB)", () => {
- const result = validateRequestSize(41 * 1024 * 1024);
+ test("rejects oversized request (> 64MB)", () => {
+ const result = validateRequestSize(65 * 1024 * 1024);
expect(result.ok).toBe(false);
});
@@ -212,6 +230,10 @@ describe("validators", () => {
expect(isAllowedMimeType("application/pdf", "doc.pdf")).toBe(true);
});
+ test("allows video/quicktime for MOV files", () => {
+ expect(isAllowedMimeType("video/quicktime", "clip.mov")).toBe(true);
+ });
+
test("allows text/plain", () => {
expect(isAllowedMimeType("text/plain", "file.txt")).toBe(true);
});
@@ -238,6 +260,13 @@ describe("validators", () => {
expect(guessMimeFromName("doc.pdf")).toBe("application/pdf");
});
+ test("guesses video MIME types", () => {
+ expect(guessMimeFromName("clip.mp4")).toBe("video/mp4");
+ expect(guessMimeFromName("clip.avi")).toBe("video/avi");
+ expect(guessMimeFromName("clip.mov")).toBe("video/mov");
+ expect(guessMimeFromName("clip.mkv")).toBe("video/x-matroska");
+ });
+
test("guesses text/plain for .py", () => {
expect(guessMimeFromName("script.py")).toBe("text/plain");
});
@@ -260,6 +289,10 @@ describe("validators", () => {
expect(pickExtension("image/jpeg", "image")).toBe("jpg");
});
+ test("falls back to mime for mp4", () => {
+ expect(pickExtension("video/mp4", "video")).toBe("mp4");
+ });
+
test("falls back to bin for unknown", () => {
expect(pickExtension("application/octet-stream", "blob")).toBe("bin");
});
diff --git a/src/chat/message-builder.ts b/src/chat/message-builder.ts
index 50f40be..e3673f1 100644
--- a/src/chat/message-builder.ts
+++ b/src/chat/message-builder.ts
@@ -1,6 +1,6 @@
// Builds SDK-native MessageParam from user text + attachments.
-// Attachments are converted to ImageBlockParam, DocumentBlockParam, or
-// TextBlockParam depending on type. Text block goes last.
+// Attachments are converted to image, video, or document content blocks.
+// The user's text block goes last.
import type { SDKUserMessage } from "../agent/agent-sdk.ts";
@@ -8,7 +8,7 @@ type MessageParam = SDKUserMessage["message"];
import type { ChatAttachment, ChatAttachmentStore } from "./attachment-store.ts";
import { readAttachmentFileBase64, readAttachmentFileText } from "./storage.ts";
-import { IMAGE_MIMES, PDF_MIME } from "./validators.ts";
+import { IMAGE_MIMES, PDF_MIME, VIDEO_MIMES, normalizeMimeType } from "./validators.ts";
type ContentBlock = {
type: string;
@@ -125,10 +125,16 @@ async function buildMessageParamFromAttachments(text: string, attachments: ChatA
const content: ContentBlock[] = [];
- // Images first, then documents, then text - matches Anthropic's recommended ordering
+ // Media first, then documents, then text.
const images = attachments.filter((a) => IMAGE_MIMES.has(a.mime_type ?? ""));
+ const videos = attachments.filter((a) => VIDEO_MIMES.has(normalizeMimeType(a.mime_type ?? "", a.filename ?? "")));
const pdfs = attachments.filter((a) => a.mime_type === PDF_MIME);
- const textFiles = attachments.filter((a) => !IMAGE_MIMES.has(a.mime_type ?? "") && a.mime_type !== PDF_MIME);
+ const textFiles = attachments.filter(
+ (a) =>
+ !IMAGE_MIMES.has(a.mime_type ?? "") &&
+ !VIDEO_MIMES.has(normalizeMimeType(a.mime_type ?? "", a.filename ?? "")) &&
+ a.mime_type !== PDF_MIME,
+ );
for (const att of images) {
const data = await readAttachmentFileBase64(att.storage_path);
@@ -142,6 +148,18 @@ async function buildMessageParamFromAttachments(text: string, attachments: ChatA
});
}
+ for (const att of videos) {
+ const data = await readAttachmentFileBase64(att.storage_path);
+ content.push({
+ type: "video",
+ source: {
+ type: "base64",
+ media_type: normalizeMimeType(att.mime_type ?? "", att.filename ?? ""),
+ data,
+ },
+ });
+ }
+
for (const att of pdfs) {
const data = await readAttachmentFileBase64(att.storage_path);
content.push({
diff --git a/src/chat/upload.ts b/src/chat/upload.ts
index ff63ea5..0057a29 100644
--- a/src/chat/upload.ts
+++ b/src/chat/upload.ts
@@ -6,7 +6,7 @@ import type { ChatSessionStore } from "./session-store.ts";
import { writeAttachmentFile } from "./storage.ts";
import {
MAX_FILES_PER_REQUEST,
- guessMimeFromName,
+ normalizeMimeType,
pickExtension,
sanitizeFilename,
validateFile,
@@ -109,7 +109,7 @@ async function processFiles(
for (const item of files) {
const { file } = item;
- const mime = file.type || guessMimeFromName(file.name) || "";
+ const mime = normalizeMimeType(file.type, file.name);
const validation = validateFile(mime, file.size, file.name);
if (!validation.ok) {
@@ -130,7 +130,13 @@ async function processFiles(
const buffer = Buffer.from(await file.arrayBuffer());
const storagePath = await writeAttachmentFile(sessionId, id, ext, buffer);
- const kind = mime.startsWith("image/") ? "image" : mime === "application/pdf" ? "pdf" : "text";
+ const kind = mime.startsWith("image/")
+ ? "image"
+ : mime.startsWith("video/")
+ ? "video"
+ : mime === "application/pdf"
+ ? "pdf"
+ : "text";
deps.attachmentStore.create({
id,
diff --git a/src/chat/validators.ts b/src/chat/validators.ts
index b5ae7e7..73310d9 100644
--- a/src/chat/validators.ts
+++ b/src/chat/validators.ts
@@ -3,6 +3,8 @@
export const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
+export const VIDEO_MIMES = new Set(["video/mp4", "video/avi", "video/x-msvideo", "video/mov", "video/x-matroska"]);
+
export const PDF_MIME = "application/pdf";
export const TEXT_MIMES = new Set([
@@ -53,10 +55,11 @@ export const TEXT_EXTENSIONS = new Set([
]);
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
+const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
const MAX_PDF_BYTES = 32 * 1024 * 1024;
const MAX_TEXT_BYTES = 1 * 1024 * 1024;
export const MAX_FILES_PER_REQUEST = 10;
-const MAX_REQUEST_BYTES = 40 * 1024 * 1024;
+const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
export type ValidationResult = { ok: true } | { ok: false; reason: string; message: string };
@@ -77,15 +80,27 @@ export function guessMimeFromName(filename: string): string | null {
if (ext === ".png") return "image/png";
if (ext === ".gif") return "image/gif";
if (ext === ".webp") return "image/webp";
+ if (ext === ".mp4") return "video/mp4";
+ if (ext === ".avi") return "video/avi";
+ if (ext === ".mov") return "video/mov";
+ if (ext === ".mkv") return "video/x-matroska";
if (ext === ".pdf") return "application/pdf";
if (TEXT_EXTENSIONS.has(ext)) return "text/plain";
return null;
}
+export function normalizeMimeType(mimeType: string, filename: string): string {
+ const mime = mimeType || guessMimeFromName(filename) || "";
+ if (mime === "video/quicktime" && getExtension(filename) === ".mov") return "video/mov";
+ return mime;
+}
+
export function isAllowedMimeType(mimeType: string, filename: string): boolean {
- if (IMAGE_MIMES.has(mimeType)) return true;
- if (mimeType === PDF_MIME) return true;
- if (TEXT_MIMES.has(mimeType)) return true;
+ const mime = normalizeMimeType(mimeType, filename);
+ if (IMAGE_MIMES.has(mime)) return true;
+ if (VIDEO_MIMES.has(mime)) return true;
+ if (mime === PDF_MIME) return true;
+ if (TEXT_MIMES.has(mime)) return true;
if (hasTextExtension(filename)) return true;
return false;
}
@@ -93,7 +108,7 @@ export function isAllowedMimeType(mimeType: string, filename: string): boolean {
export function validateFile(mimeType: string, sizeBytes: number, filename: string): ValidationResult {
if (sizeBytes === 0) return { ok: false, reason: "empty", message: "File is empty." };
- const mime = mimeType || guessMimeFromName(filename);
+ const mime = normalizeMimeType(mimeType, filename);
if (!mime) return { ok: false, reason: "unknown_type", message: "Unknown file type." };
if (mime === "image/heic" || mime === "image/heif") {
@@ -118,6 +133,20 @@ export function validateFile(mimeType: string, sizeBytes: number, filename: stri
return { ok: true };
}
+ if (mime.startsWith("video/")) {
+ if (!VIDEO_MIMES.has(mime)) {
+ return {
+ ok: false,
+ reason: "unsupported_video_format",
+ message: "This video format is not supported. Convert to MP4, AVI, MOV, or MKV.",
+ };
+ }
+ if (sizeBytes > MAX_VIDEO_BYTES) {
+ return { ok: false, reason: "video_too_large", message: "Video is too large. Max 50 MB." };
+ }
+ return { ok: true };
+ }
+
if (mime === PDF_MIME) {
if (sizeBytes > MAX_PDF_BYTES) {
return { ok: false, reason: "pdf_too_large", message: "PDF is too large. Max 32 MB." };
@@ -137,7 +166,7 @@ export function validateFile(mimeType: string, sizeBytes: number, filename: stri
export function validateRequestSize(contentLength: number | null): ValidationResult {
if (contentLength !== null && contentLength > MAX_REQUEST_BYTES) {
- return { ok: false, reason: "request_too_large", message: "Total upload too large. Max 40 MB." };
+ return { ok: false, reason: "request_too_large", message: "Total upload too large. Max 64 MB." };
}
return { ok: true };
}
@@ -156,6 +185,10 @@ export function pickExtension(mimeType: string, filename: string): string {
if (mimeType === "image/png") return "png";
if (mimeType === "image/gif") return "gif";
if (mimeType === "image/webp") return "webp";
+ if (mimeType === "video/mp4") return "mp4";
+ if (mimeType === "video/avi" || mimeType === "video/x-msvideo") return "avi";
+ if (mimeType === "video/mov" || mimeType === "video/quicktime") return "mov";
+ if (mimeType === "video/x-matroska") return "mkv";
if (mimeType === "application/pdf") return "pdf";
return "bin";
}