Skip to content

feat(media): convert HEIC images to JPEG with sips on macOS - #3647

Open
RealKai42 wants to merge 1 commit into
mainfrom
feat/heic-macos-sips
Open

feat(media): convert HEIC images to JPEG with sips on macOS#3647
RealKai42 wants to merge 1 commit into
mainfrom
feat/heic-macos-sips

Conversation

@RealKai42

Copy link
Copy Markdown
Collaborator

Problem

HEIC/HEIF is the default photo and screenshot format on iPhone and macOS, but model providers only accept PNG, JPEG, GIF, and WebP. Every ingestion point therefore refused HEIC: ReadMediaFile answered with a "convert it to JPEG first" message, the agent had to run sips itself through Bash (leaving a stray .jpg next to the user's file), @file references and prompt attachments degraded to a text notice, and a HEIC file pasted into the TUI was silently ignored.

What changed

macOS ships sips, which decodes HEIC with Apple's system codecs, so on macOS the conversion now happens in-process before the existing format gate. The converted JPEG then flows through the unchanged compression pipeline (edge cap, byte budget, region crops, persisted originals). Other platforms are untouched and still receive the platform-matched conversion guidance; Windows/Linux support is deliberately out of scope because the only cross-platform decoders are libheif/libde265 wasm builds, which raise LGPL and HEVC patent questions that are being evaluated separately.

  • heic-transcode.ts (agent-core-v2): a shared transcoder that activates only when osKind === 'macOS' and a host process service is available. It runs sips -s format jpeg -s formatOptions 90 <in> --out <tmp>.jpg in a mkdtemp scratch directory with a 15 s timeout, removes the directory on every exit path, and returns null on any failure so each caller keeps its previous behavior. createImageTranscoder binds the host for callers outside DI. Emits an image_transcode telemetry event.
  • ReadMediaFile: HEIC passes the gate on a transcode-capable runtime; the file path is handed straight to sips through the runtime lease that owns the file (process is optional on the lease, so runtimes without it fall back to the guidance). The <system> note keeps the original mime and size and states the conversion. A failed conversion returns the original guidance.
  • AgentMediaResolverService (kimi-file:// references from @file and pasted uploads): injects IHostEnvironment + IHostProcessService, converts HEIC and runs it through compressImageForModel before inlining; the result is memoized per file id like any other image.
  • kap-server resolvePromptMediaFiles: new optional transcodeImage hook used by the inline-base64, path, and uploaded-file branches. A converted image keeps its HEIC original (persisted to media-originals, or the source path) and gets the standard compression caption, so ReadMediaFile region readback keeps working. The prompts and skills routes wire in the macOS transcoder from core.accessor.
  • TUI clipboard (apps/kimi-code): isHeicImage ftyp sniffing; on darwin a copied HEIC file is converted through sips (the same synchronous runCommand the module already uses for osascript) before the usual parseImageMeta path. Elsewhere the paste is still declined.
  • image-originals: .heic/.heif extensions for persisted originals.

Tests: unit tests drive a fake IHostProcessService that writes the --out file; real-sips integration tests are gated with skipIf(process.platform !== 'darwin') and generate their HEIC fixture at test time via sips -s format heic from a PNG, so no binary fixture is committed. Full suites of agent-core-v2, kap-server, and the CLI pass; lint and typecheck are clean.

Changeset: minor for @moonshot-ai/kimi-code.

HEIC/HEIF is the default photo and screenshot format on Apple devices,
but model providers only accept PNG, JPEG, GIF, and WebP, so every
ingestion point refused it with a "convert it first" message.

On macOS the system `sips` tool decodes HEIC with Apple's codecs, so
the engine now converts HEIC to JPEG in-process before the existing
format gate, and the converted image flows through the unchanged
compression pipeline (resize, byte budget, region crops, originals):

- a shared transcoder that only activates on macOS with a host process
  service, runs sips against a scratch directory, and returns null on
  any failure so callers keep their previous behavior
- ReadMediaFile hands the file path straight to sips on the runtime
  that owns the file and notes the conversion for the model
- the daemon-file image resolver (@file references and pasted uploads)
  converts and compresses before inlining
- kap-server prompt attachments (inline base64, path, uploaded file)
  accept an image transcoder; the prompt and skill routes wire the
  macOS one in, keeping the HEIC original next to the compression
  caption
- the TUI clipboard reader converts a copied HEIC file through sips on
  macOS instead of silently ignoring it

Other platforms keep the existing conversion guidance.
@changeset-bot

changeset-bot Bot commented Sep 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5745c0d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-08T09:40:17.689855Z 5745c0d PR opened
🔒 Security Review Completed 2026-09-08T09:38:54.324204Z 5745c0d PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 8, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@5745c0d
npx https://pkg.pr.new/@moonshot-ai/kimi-code@5745c0d

commit: 5745c0d

@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: 5745c0db9a

ℹ️ 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 +219 to +222
const result = run(
'sips',
['-s', 'format', 'jpeg', '-s', 'formatOptions', '90', path, '--out', target],
{ timeoutMs: SIPS_TIMEOUT_MS },

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 Run HEIC conversion without blocking the TUI

When sips is slow or wedged, this calls the default synchronous runCommand implementation, which uses spawnSync, from the explicit paste handler before the attachment placeholder is inserted. The entire Node event loop therefore stops processing input, rendering, and cancellation for as long as the 15-second timeout; run the conversion asynchronously or move it into the existing background image-ingestion work.

Useful? React with 👍 / 👎.

Comment on lines +300 to +303
const heicTranscodable =
fileType.kind === 'image' &&
isHeicMime(fileType.mimeType) &&
canTranscodeHeic({ osKind: env.osKind, process });

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 the hevx brand before gating transcoding

For a valid HEIF/HEIC file whose ftyp major brand is hevx, detectFileType(..., 'media') returns unknown because the core FTYP_IMAGE_BRANDS table omits that brand, so this condition never reaches the new transcoder and ReadMediaFile rejects the same file. The new clipboard detector explicitly recognizes hevx, making behavior inconsistent across paste, upload, and read paths; add it to the shared core sniffer, ideally using one common brand definition.

Useful? React with 👍 / 👎.

Comment on lines +386 to +392
const transcoded = heicTranscodable
? await transcodeHeicToJpeg({ path: safePath }, fileType.mimeType, {
osKind: env.osKind,
process,
telemetry: this.telemetry,
telemetrySource: 'read_media',
})

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 Transcode runtime files through the runtime filesystem

When ReadMediaFile is backed by a runtime whose filesystem is not shared with its process host, this passes the runtime path directly to sips even though the file was read and validated through IHostFileSystem. For example, the ACP runtime delegates filesystem reads over ACP but falls back to the server-local process service for non-shell commands such as sips, so a HEIC visible to the ACP client can fail conversion because the same path does not exist on the server. Read the bytes through fs and use the transcoder's byte-input form instead of assuming both abstractions share paths.

Useful? React with 👍 / 👎.

let mediaType = file.meta.media_type;
mediaType = resolveEffectiveImageMime(mediaType, data);
if (!isModelAcceptedImageMime(mediaType)) {
const transcoded = await options.transcodeImage?.(data, mediaType);

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 Enforce the image decode limit before transcoding uploads

When an uploaded HEIC exceeds MAX_IMAGE_DECODE_BYTES, this branch still buffers the complete upload and invokes sips, even though the server-local path branch rejects images over that limit and /api/v1/files permits files up to Number.MAX_SAFE_INTEGER. Previously an unsupported HEIC was only retained as an attachment; the new path now asks an external decoder to expand it and then buffers the JPEG too, allowing a large upload to consume substantial child-process and server memory. Skip conversion and retain the attachment once file.meta.size exceeds the decode limit.

Useful? React with 👍 / 👎.

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.

1 participant