From a85261932e89aa93db3e45694fb94b3754b95176 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 7 May 2026 14:28:27 +0100 Subject: [PATCH] fix(cli): fall back to sips for HEIC infer inputs --- CHANGELOG.md | 1 + src/media/image-ops.input-guard.test.ts | 40 ++++++++++++++++++++++++- src/media/image-ops.ts | 9 +++++- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c59f714f14a..971b47bc722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,6 +179,7 @@ Docs: https://docs.openclaw.ai - Anthropic: reject uppercase provider-prefixed forward-compat model ids locally instead of sending malformed dynamic ids upstream. Fixes #73715. - OpenAI/embeddings: pass configured output dimensionality through single and batched embedding requests so memory embedding indexes can request smaller vectors. Fixes #55126. - CLI/infer: normalize HEIC/HEIF image files to JPEG before model-run requests, avoiding providers that reject Apple image container formats. Fixes #50081. +- CLI/infer: fall back to macOS `sips` when optional image tooling cannot decode HEIC/HEIF input files before model-run requests. Refs #50081. - OpenRouter: keep the default `openrouter/auto` model ref canonical while preventing TUI and Control UI catalog pickers from displaying or submitting `openrouter/openrouter/auto`. Fixes #62655. - Status/Claude CLI: show `oauth (claude-cli)` for working Claude CLI OAuth runtime sessions instead of `unknown` when no local auth profile exists. Fixes #78632. Thanks @gorkem2020. - Memory search: preserve keyword-only hybrid FTS matches when vector scoring is unavailable or below the configured minimum score, so exact lexical hits are not dropped by weighted min-score filtering. diff --git a/src/media/image-ops.input-guard.test.ts b/src/media/image-ops.input-guard.test.ts index fde3462b07a..5754a10f46f 100644 --- a/src/media/image-ops.input-guard.test.ts +++ b/src/media/image-ops.input-guard.test.ts @@ -1,7 +1,19 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; -import { getImageMetadata, MAX_IMAGE_INPUT_PIXELS, resizeToJpeg } from "./image-ops.js"; +import { + convertHeicToJpeg, + getImageMetadata, + MAX_IMAGE_INPUT_PIXELS, + resizeToJpeg, +} from "./image-ops.js"; import { createPngBufferWithDimensions } from "./test-helpers.js"; +const PNG_1X1_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADUlEQVR4nGP8z8BQDwAFgwJ/lH3vWQAAAABJRU5ErkJggg=="; + describe("image input pixel guard", () => { const oversizedPng = createPngBufferWithDimensions({ width: 8_000, height: 4_000 }); const overflowedPng = createPngBufferWithDimensions({ @@ -53,4 +65,30 @@ describe("image input pixel guard", () => { } } }); + + const itIfMac = process.platform === "darwin" ? it : it.skip; + + itIfMac("converts macOS-generated HEIC images to JPEG", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-heic-convert-")); + try { + const pngPath = path.join(tempDir, "input.png"); + const heicPath = path.join(tempDir, "input.heic"); + await fs.writeFile(pngPath, Buffer.from(PNG_1X1_BASE64, "base64")); + const result = spawnSync( + "/usr/bin/sips", + ["-s", "format", "heic", pngPath, "--out", heicPath], + { + encoding: "utf8", + }, + ); + expect(result.status, result.stderr || result.stdout).toBe(0); + + const jpeg = await convertHeicToJpeg(await fs.readFile(heicPath)); + + expect(jpeg[0]).toBe(0xff); + expect(jpeg[1]).toBe(0xd8); + } finally { + await fs.rm(tempDir, { force: true, recursive: true }); + } + }); }); diff --git a/src/media/image-ops.ts b/src/media/image-ops.ts index 4d08f04f9aa..ebe5d297dd7 100644 --- a/src/media/image-ops.ts +++ b/src/media/image-ops.ts @@ -572,7 +572,14 @@ export async function convertHeicToJpeg(buffer: Buffer): Promise { return await sipsConvertToJpeg(buffer); } const ops = await loadMediaAttachmentImageOps(); - return await ops.convertHeicToJpeg(buffer); + try { + return await ops.convertHeicToJpeg(buffer); + } catch (error) { + if (process.platform !== "darwin") { + throw error; + } + return await sipsConvertToJpeg(buffer); + } } /**