Skip to content

feat(upload): accept MP4 so video-capable models can read a clip - #975

Open
octo-patch wants to merge 1 commit into
miurla:mainfrom
octo-patch:octo/20260818-input-capability-recvsf3DK82Mw2
Open

feat(upload): accept MP4 so video-capable models can read a clip#975
octo-patch wants to merge 1 commit into
miurla:mainfrom
octo-patch:octo/20260818-input-capability-recvsf3DK82Mw2

Conversation

@octo-patch

Copy link
Copy Markdown

Reason: Uploaded attachments are forwarded to the selected model, but the upload path accepts only JPEG, PNG and PDF, so a clip cannot reach a model that reads video.

Problem

Attachments become file parts and are handed to whichever model the chat is using, with no per-format branching on the way out. The formats that may enter, though, are fixed at the upload boundary: JPEG, PNG and PDF. A model that accepts video therefore cannot be given a clip — the file is refused before it is ever stored, so the capability is unreachable no matter which model is selected.

MP4 was not deliberately excluded. It was simply absent from the allowlist, and the content check added in #952 rejected the ISO container as a consequence.

Changes

  • lib/storage/file-signature.ts detects MP4 from the ISO base media ftyp box: the box type at bytes 4..8, then the brand at bytes 8..12 checked against the MP4-compatible brands. The brand check is what keeps the shared container from over-matching — QuickTime (qt ) and M4A audio ride in the same box, and accepting the box alone would move their failure from the upload to the provider request. video/mp4 is ordered ahead of PDF because PDF is the one format matched by scanning a window rather than a fixed offset.
  • video/mp4 is added to the declared-type gate (shared with the signature module) and to the three client allowlists that guard the attachment menu, the standalone picker and the drag-and-drop zone. The 400 message that enumerates the readable formats is updated to match.
  • lib/utils/attachment-tokens.ts estimates a clip from a frame-sampled fixed cost, the same reasoning already applied to images, instead of falling through to the byte-length branch. Byte length is the wrong model for video and not merely imprecise: a clip at the upload ceiling estimates over a million tokens, which exceeds the whole 200k attachment budget from feat(streaming): bound replayed attachments by weight, not only by count #951 and would evict every other attachment replayed to the model, and would trip context-window truncation on the turn that carries it. The estimate is deliberately above a single image's.

The existing signature test asserted that the production ISO container is rejected; it now asserts the container is detected as video, which keeps the original regression covered — a clip named image.jpg is still not sent to the model as a broken image. The route test's "unsupported content" fixture was that same container, so it is replaced with bytes that open no supported format.

Scope

The 5MB upload ceiling is unchanged, so this admits short clips rather than arbitrary video. Raising that limit is a storage and cost decision, not part of making the format reachable, and it is left alone deliberately. Attachment previews already render a non-image as a labelled chip and a link, so a clip shows up as MP4 without new UI.

Verification

  • bun run test — 542 passed, 3 skipped, 61 files. New coverage: MP4 detection, the production container now detected as video, a non-MP4 brand and a truncated brand both rejected, a clip stored under its detected type across the object write and the library row, and the fixed video token estimate.
  • bun lint, bun typecheck, bun run format:check, bun run build all pass.
  • Detection was also exercised against the ftyp boxes real encoders write (isomiso2avc1mp41, mp42mp41isomavc1, dashiso6avc1mp41), all detected as video/mp4, with a QuickTime and an M4A header rejected and JPEG/PNG detection unchanged.

Attachments are forwarded to the selected model as file parts, but the
upload path accepted only JPEG, PNG and PDF, so a clip could not reach a
model that reads video.

- Detect MP4 from the ISO base media `ftyp` box, restricted to MP4 brands
  so QuickTime and M4A audio stay rejected rather than failing at the
  provider.
- Add `video/mp4` to the declared-type gate and to the three client
  allowlists that guard the picker, the drop zone and the attachment menu.
- Estimate a clip's tokens from a frame-sampled fixed cost instead of its
  byte length, which would otherwise price one clip above the whole
  attachment budget and evict every other file replayed to the model.
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

@octo-patch is attempting to deploy a commit to the morphic Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
morphic Ready Ready Preview Aug 19, 2026 12:50am

Request Review

@miurla

miurla commented Aug 19, 2026

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ae1ac0d0a4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +45 to +47
'video/mp4': buffer =>
buffer.subarray(4, 8).equals(Buffer.from('ftyp', 'ascii')) &&
MP4_BRANDS.has(buffer.subarray(8, 12).toString('ascii')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize compatible MP4 brands

Valid MP4 files are not limited to these major brands: for example, CMAF clips can use cmfc, and an ISO-BMFF file can advertise MP4 through a compatible brand rather than its major brand. Because detection checks only bytes 8–12 against this small set, such files pass the browser's video/mp4 check but receive Unsupported file content from the upload route. Inspect the compatible-brand entries as well and accept the complete set of supported MP4 brands.

Useful? React with 👍 / 👎.

Comment on lines +2 to +5
// A clip is sampled into frames, so it costs a small multiple of a single
// image rather than what its bytes expand into. The upload size ceiling bounds
// how long a clip can be, which is what keeps a fixed estimate usable here.
export const VIDEO_ATTACHMENT_TOKENS = 5 * IMAGE_ATTACHMENT_TOKENS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for video duration in the token estimate

For low-bitrate or highly compressible clips, the 5 MB upload ceiling does not meaningfully bound duration, while frame-sampled model input grows with duration. Returning 50,000 tokens for every video therefore lets capHistoricalAttachments and truncateMessages retain long clips whose actual input can greatly exceed their token budget or model context window, causing provider failures or unexpectedly costly requests. Derive the estimate from duration/frame sampling, or use a conservative bound when duration is unavailable.

Useful? React with 👍 / 👎.

@miurla

miurla commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Reviewed this with model routing in mind, and the finding changes the framing of the PR, so I would rather check the premise with you than hand you a fix list.

Only one provider in this repo can carry a video part today

Verified against the versions in this tree. Each of these throws during prompt conversion, before any network call:

openai(responses): AI_UnsupportedFunctionalityError :: 'file part media type video/mp4' functionality not supported.
anthropic:         AI_UnsupportedFunctionalityError :: 'media type: video/mp4' functionality not supported.
openai-compatible: AI_UnsupportedFunctionalityError :: 'file part media type video/mp4' functionality not supported.
google:            reaches the API (request is built)

Ollama is image-only upstream. The gateway provider does forward the part, but the AI Gateway models API still does not expose a video-input tag (vercel/ai#9417 is open), so capability cannot be read from metadata either.

Worth noting for the "which model reads video" question: the open models that do read video (Qwen3-VL, GLM-4.6V) are not reachable through this stack either. vLLM serves them with a video_url / input_video content type, which is an extension of the OpenAI schema, and openai-compatible throws before the request is built.

So the capability this PR makes reachable is reachable on google: only.

What that means for the current shape

The allowlist is global and nothing gates MP4 on the receiving model:

  • Cloud pins the model to gpt-5.6-luna (config/models/cloud.json) and hides the selector, so on cloud every video upload fails.
  • Self-host defaults to gpt-5.4-mini (lib/config/default-model.ts), so it fails there too unless the user has explicitly selected Gemini.
  • The failure is not confined to the turn that carries the clip. The user message is persisted before the model call (lib/streaming/helpers/prepare-messages.ts:133, and the optimistic save at :102 for a new chat), attachments are replayed every turn, and the replay cap keeps the newest 10. So the throw repeats on every later turn in that chat.

That is the shape of the incident #952 closed. Today the same clip is refused at upload with a 400 that names the problem. After this change it is accepted, stored, and then breaks the conversation with a generic error.

Question

Given that video is reachable only on Google today: do you still want to land this, or should it be closed?

If yes, the gate belongs on the receiving model's provider rather than on the upload boundary:

  • Reaching video is a property of the provider, not of the deployment. openai, anthropic, openai-compatible and ollama cannot carry a video part at all, whatever an operator configures, so a per-deployment opt-in flag would mostly let someone switch on a state that still fails. Allow video/mp4 only when the selected model's provider can read it, which today means google alone.
  • Keep that decision in one place, something like canReadMediaType(providerId, mediaType) in lib/config, so the day an openai-compatible video path exists (a video_url conversion for a vLLM-served Qwen3-VL) it is one edit instead of four allowlists.
  • The composer can already see the selection: selectedModel is a readable cookie (lib/config/model-selection-cookie.ts), and chat-panel.tsx already reads a cookie this way for search mode. Drop video/mp4 from accept and from the drop-zone validation when the selected model cannot read it, and leave it out entirely when Google is not an enabled provider (isProviderEnabled('google')).
  • This needs no cloud-specific branch. Cloud is fixed to gpt-5.6-luna, so under this rule it simply never offers video.
  • Keep the signature detection either way, so a clip named image.jpg is still identified rather than sent to the provider as a broken image.
  • Add a degrade on the chat side, since the selection can change mid-thread: replace a file part the selected model cannot read with the text placeholder capHistoricalAttachments already uses (describeAttachment). That is what stops a model switch from reproducing this throw against an attachment already stored in the history.

If no, closing this PR is all that is needed. An MP4 cannot be uploaded today, so nothing else has to change.

Minor

components/file-upload-button.tsx is not imported anywhere in the tree, so that part of this change is dead code. With it counted, the allowlist now lives in four places.

The two P2s from codex above (compatible brands, duration-independent token estimate) I agree with. They apply in the "yes" branch.

UI is not a blocker: video falls back to the same generic rendering PDF gets today, an extension chip in the composer, a link in the message, a generic icon in Library. Worth its own PR rather than this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants