Skip to content

fix(server): transcode HEIC attachments instead of failing the turn - #5229

Open
danigilme wants to merge 1 commit into
pingdotgg:mainfrom
danigilme:heic-transcode
Open

fix(server): transcode HEIC attachments instead of failing the turn#5229
danigilme wants to merge 1 commit into
pingdotgg:mainfrom
danigilme:heic-transcode

Conversation

@danigilme

@danigilme danigilme commented Aug 2, 2026

Copy link
Copy Markdown

What Changed

ClaudeAdapter now converts HEIC/HEIF image attachments to JPEG instead of failing the turn with Unsupported Claude image attachment type 'image/heic'.

  • New apps/server/src/imageTranscode.ts exposing isTranscodableImageMimeType and transcodeImageToJpeg.
  • buildUserMessageEffect converts a transcodable attachment after reading its bytes and pushes the JPEG content block. Everything else is untouched.
  • Unit tests covering mime detection plus a real HEIC to JPEG round trip.

No new dependencies, and no package.json change.

Why

Fixes #4579.

HEIC is the default capture format on every recent iPhone, so this fires constantly: attaching a photo from the mobile composer, or pasting an iPhone screenshot on desktop. The attachment pipeline already handles HEIC end to end (imageMime.ts lists it in SAFE_IMAGE_FILE_EXTENSIONS, and apps/mobile/src/lib/composerImages.ts labels it image/heic), so the file is stored correctly and only the provider hand-off rejects it. The issue asks for exactly this: "the image should be converted automagically to a supported format".

Why shelling out rather than a decoder dependency

A HEIC decoder (heic-convert, sharp) would be a new runtime dependency for a narrow edge case. Instead this uses what the host already has: sips on macOS, which is part of the OS and always present, and heif-convert from libheif elsewhere. When neither exists the conversion fails and the caller reports the attachment as unsupported exactly as it does today, so the change cannot regress any current behaviour.

Note on #2829

#4200 was closed in favour of the orchestration-v2 rewrite because it built on apps/server/src/orchestration/** and provider/Layers/*Adapter.ts. This PR keeps all the logic in a standalone module with no orchestration imports, so it survives that move. The adapter call site is a single yield* that can be dropped into wherever the attachment loop lands in v2.

Verification

Run on macOS 15, Node 24:

  • pnpm exec vp test run src/imageTranscode.test.ts src/provider/Layers/ClaudeAdapter.test.ts — 67 passed, including the HEIC to JPEG round trip
  • pnpm exec tsgo --noEmit — clean
  • pnpm exec vp lint — no new findings
  • pnpm exec vp fmt — applied

The round-trip test builds a genuine HEIC fixture with sips, asserts the ftypheic box is present, converts it, and checks the output starts with the JPEG SOI marker. It skips itself on hosts without sips.

UI Changes

None.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes (n/a, no UI change)
  • I included a video for animation/interaction changes (n/a)

Note

Medium Risk
Turn-start path now shells out to host transcoding tools with temp files and a 30s timeout; failure modes are handled but behavior depends on OS tooling (especially Linux without libheif).

Overview
HEIC/HEIF image attachments no longer fail Claude turns with an unsupported MIME error. The adapter now transcodes those formats to JPEG before building the SDK image block.

A new imageTranscode module detects transcodable MIME types (HEIC/HEIF and sequence variants), writes bytes to a temp dir, and runs sips on macOS or heif-convert elsewhere—no new npm HEIC decoder. If conversion fails, the turn still errors with a message that includes the original type.

ClaudeAdapter.buildUserMessageEffect branches after reading attachment bytes: transcodable images go through transcodeImageToJpeg with HostProcessPlatform; others keep the existing supported-type check. Tests cover MIME detection and a real HEIC→JPEG round trip when sips is available.

Reviewed by Cursor Bugbot for commit b43c309. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Transcode HEIC/HEIF image attachments to JPEG before sending to Claude

  • Adds imageTranscode.ts with transcodeImageToJpeg, which writes the input to a temp file, runs sips on macOS or heif-convert elsewhere, and returns the JPEG bytes.
  • Updates ClaudeAdapter.ts to detect HEIC/HEIF mime types via isTranscodableImageMimeType and transcode before building the image content block; throws ProviderAdapterRequestError if transcoding fails.
  • Risk: transcoding depends on sips (macOS) or heif-convert (Linux) being available on the host; missing tools cause the request to error rather than silently skip the attachment.

Macroscope summarized b43c309.

Attaching or pasting a photo straight from an Apple device sends
`image/heic`, which the Claude adapter rejects with "Unsupported Claude
image attachment type". HEIC is the default capture format on iOS, so
this fires constantly from the mobile composer and from pasting an
iPhone screenshot on macOS.

The attachment itself is stored fine (`imageMime.ts` already lists HEIC
as a safe type) and the mobile composer already labels it `image/heic`,
so the only thing missing was converting it before handing it to the
provider.

`transcodeImageToJpeg` shells out to a tool that ships with the host
rather than adding a HEIC decoder dependency: `sips` on macOS, which is
always present, and `heif-convert` from libheif elsewhere. If neither is
available the conversion fails and the caller reports the attachment as
unsupported exactly like before, so the change is strictly additive.

The helper lives in its own module with no orchestration imports, so it
survives the orchestration-v2 move; the adapter call site is a single
`yield*`.

Fixes pingdotgg#4579

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 28fdd545-fdca-4e87-b8dd-21721ce21489

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 2, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One convention finding: the new HEIC transcode path performs subprocess and filesystem work with raw Node APIs behind a Promise, and that imperative helper becomes a dependency of the ClaudeAdapter Effect service. See the inline comments.

Posted via Macroscope — Effect Service Conventions

Comment on lines +996 to +998
if (needsTranscode) {
const hostPlatform = yield* HostProcessPlatform;
const converted = yield* Effect.tryPromise({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the service boundary where the imperative helper is pulled in. Once transcodeImageToJpeg is an Effect over ProcessRunner/FileSystem, this becomes a plain yield* and the wrapper keeps a structured tagged failure as cause rather than an opaque rejection, with the requirement visible in the adapter's layer types.

Posted via Macroscope — Effect Service Conventions

Comment on lines +59 to +77
function runTranscoder(input: {
readonly transcoder: Transcoder;
readonly inputPath: string;
readonly outputPath: string;
}): Promise<void> {
return new Promise((resolve, reject) => {
NodeChildProcess.execFile(
input.transcoder.command,
input.transcoder.args({
inputPath: input.inputPath,
outputPath: input.outputPath,
}),
// A photo is a bounded workload; the cap only guards against a wedged
// helper process holding the turn open forever.
{ timeout: 30_000, maxBuffer: 1024 * 1024 },
(error) => (error === null ? resolve() : reject(error)),
);
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This module spawns a child process and does temp-file I/O with raw Node APIs, and it is then consumed from inside the ClaudeAdapter Effect service via Effect.tryPromise. Per the dependency-acquisition convention, an imperative Promise adapter shouldn't become a dependency of another Effect service — runtime-backed dependencies should be acquired from the environment.

The server already models both of these as services: ProcessRunner (apps/server/src/processRunner.ts, with run, timeouts, and tagged ProcessSpawnError/ProcessTimeoutError/… failures) and FileSystem.FileSystem (makeTempDirectoryScoped, used elsewhere for exactly this scratch-dir pattern, e.g. apps/server/src/atomicWrite.ts).

Suggested shape: make transcodeImageToJpeg an Effect that does yield* ProcessRunner.ProcessRunner and yield* FileSystem.FileSystem, and fail with a Schema.TaggedErrorClass (e.g. ImageTranscodeError carrying the transcoder command and the underlying cause) instead of throw new Error(...)/throw lastError. The platform parameter can stay as-is — that's pure configuration, not service injection. That also lets the nodeBuiltinImport:off suppression at the top of the file be dropped.

Posted via Macroscope — Effect Service Conventions

@macroscopeapp

macroscopeapp Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

This PR introduces new functionality (HEIC-to-JPEG transcoding via external process calls) rather than a simple bug fix. The change modifies attachment processing behavior in ClaudeAdapter and spawns external processes, warranting human review for this new capability.

You can customize Macroscope's approvability policy. Learn more.

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

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: image/heic error for Claude Code crashes the turn

1 participant