Bun.Image вместо Sharp: миграция на Bun 1.3.14 за 4 шага

Bun.Image вместо Sharp: практическая миграция продакшен-пайплайна, четыре несовместимости API, замеры CI, памяти и скорости обработки изображений.

Saturday, September 5, 2026Omid Saffari
Bun.Image вместо Sharp: миграция на Bun 1.3.14 за 4 шага

Bun.Image появился в Bun v1.3.14 13 мая 2026 года. Это встроенный пайплайн на libjpeg-turbo + spng + libwebp с API, совместимым с Sharp, которому не нужен ни один этап сборки нативных аддонов. Три года CI ломался на бинарнике libvips из lovell/sharp при каждом обновлении Node — и именно после этого релиза я наконец удалил Sharp.

Почему после выхода Bun.Image в 1.3.14 я сразу отказался от Sharp

В примечаниях к релизу Bun 1.3.14 от 13 мая среди привычного списка нововведений, между «HTTP/3 client» и «7x faster warm installs», затерялась строка, поставившая точку в моей истории с Sharp: Bun.Image. Это встроенная в рантайм цепочка обработки изображений, а libjpeg-turbo, spng и libwebp скомпилированы непосредственно в бинарник Bun.

Если из-за Sharp вам ни разу не приходилось терять целый день работы CI, этот абзац можно пропустить. Остальным сценарий наверняка знаком: sharp/lib/sharp-linuxmusl-x64.node не найден; в Alpine-контейнере возникает Cannot find module '../build/Release/sharp.node'; кто-то обновляет Node, кэш слоёв Docker сбрасывается, и теперь при каждом push с нуля выполняется npm rebuild sharp; сборка на Vercel обращается к CDN с готовым бинарником и завершается по тайм-ауту. Три года такого опыта на трёх разных проектах — и в какой-то момент команда apk add --no-cache vips-dev уже набиралась по мышечной памяти.

Bun.Image решает проблему на уровне самого рантайма. Для обработки изображений не нужен этап npm install: кодеки уже входят в бинарник Bun. При смене ABI Node нечего пересобирать, поскольку здесь нет ни Node, ни аддона. Геометрические ядра используют SIMD с целочисленной арифметикой i16 и фиксированной точкой; при декодировании JPEG автоматически выбирается минимально достаточный размер. По своей архитектуре это именно тот Sharp, каким он мог бы быть, если бы ему не приходилось оставаться аддоном для Node.

Есть и вторая причина, по которой версия 1.3.14 оказалась для меня важной: это последний релиз на Zig перед переписыванием на Rust, которое финансирует Anthropic. 14 мая The Register рассказал о темпе слияния изменений, и дальше скорость разработки обещает быть серьёзной. Я предпочитаю перейти на примитив Bun сейчас, а не тащить зависимость от нативного аддона через переписывание рантайма.

Это не некролог Sharp. Связка Sharp с libvips по-прежнему быстрее всех справляется с анимированным WebP, задачами, где критически важно сохранить цветовой профиль, и deepzoom-пирамидами из tile(). Ниже — практический план для остальных 95% обработки изображений: обычного пайплайна «декодировать, изменить размер, закодировать», который работает в большинстве продакшен-приложений.

Как перейти с Sharp на Bun.Image за 4 шага

Я провёл такую замену в omidsaffari-admin — воркере, который обрабатывает обложки, созданные gpt-image-2, перед их отправкой в R2. В одном файле было восемь мест с вызовами Sharp, и все они находились на пути после шага cover в PublishWorkflow. Вся миграция заняла 42 минуты, включая ветку с двойным кодированием в WebP и резервным JPEG.

Шаг 1 — оцените масштаб использования Sharp. Прежде чем что-либо менять, найдите все импорты:

Bash
rg -n "from ['\"]sharp['\"]" src/
rg -n "require\(['\"]sharp['\"]\)" src/

Важно заранее понимать, предстоит заменить четыре места с вызовами или сорок. Если их сорок, переносите по одному маршруту, а не всё сразу.

Шаг 2 — замените импорт на Bun.file().image(). Конструктор Sharp принимает путь, Buffer или Stream. Конструктор Bun.Image принимает путь через Bun.file(), а также Uint8Array, Blob или любой результат файловых примитивов Bun — в том числе ссылки Bun.s3(), из-за которых изменилась сама структура моего кода.

Шаг 3 — сопоставьте цепочку вызовов. Именно здесь становится понятно, почему Bun.Image называют совместимым с Sharp. Все методы, которыми я пользовался в продакшене, сопоставились 1:1: .resize(w, h, { fit: "cover" }) работает идентично, .rotate(90) поддерживается с оговоркой о поворотах ниже, а .flip(), .flop() и .modulate({ brightness, saturation }) ведут себя так же. На месте и завершающие методы форматов: .webp({ quality }), .jpeg({ quality }), .png(), .avif(), .heic().

Шаг 4 — замените завершающий вызов. Метод Sharp .toBuffer() превращается в .toBuffer() у Bun.Image. Он возвращает Uint8Array, а не Buffer — это важно, если следующий код проверяет тип Buffer. Метод Sharp .toFile(path) заменяется на .write(path). Контракт ленивого пайплайна тот же: ничего не выполняется, пока завершающий вызов не запущен с await.

Вот реальный diff одного обработчика маршрута:

TypeScript
// before
import sharp from "sharp";

export async function processCover(input: Uint8Array) {
  const buf = await sharp(input)
    .resize(1200, 630, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();
  return buf;
}

// after
export async function processCover(input: Uint8Array) {
  const buf = await Bun.image(input)
    .resize(1200, 630, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();
  return buf;
}

Для типичного сценария это вся замена: одна строка импорта и один вызов конструктора.

После миграции bun pm ls | grep sharp ничего не выводит. Из Dockerfile для CI исчезает строка RUN apk add --no-cache vips-dev. Итоговый образ становится на ~80MB меньше. В package.json теперь на одну зависимость и одно предупреждение о peer dependency меньше.

Три несовместимости, о которых стоит знать заранее

Перед удалением Sharp важно честно проверить эти ограничения: если пайплайн делает не только изменение размера и кодирование, хотя бы одно из них вполне может оказаться критичным.

Ограничение 1 — перенос цветового профиля ICC. Метод Sharp .withMetadata({ icc: "p3" }) сохраняет цветовой профиль исходника при кодировании. В версии 1.3.14 Bun.Image удаляет ICC. Для сценариев sRGB на входе и sRGB на выходе — то есть для большинства веб-изображений — разница незаметна. Но если фотографический пайплайн принимает изображение Display-P3 с широким цветовым охватом и должен его сохранить, Sharp всё ещё предпочтительнее. Если использовать Bun.Image необходимо, обходной путь такой: прочитать блок ICC через exifr, выполнить кодирование, а затем вручную прикрепить его обратно. Красивым это решение не назовёшь.

Ограничение 2 — кадры анимированных WebP и GIF. Bun.Image декодирует первый кадр анимированного файла, а остальные отбрасывает. Аналога связке Sharp { animated: true } с доступом к отдельным кадрам нет. Для обработки спрайт-листов, создания анимированных миниатюр и любых операций с последовательностью кадров это непреодолимое ограничение. На таких ветках оставьте Sharp.

Ограничение 3 — пирамида .tile(). Sharp наследует у libvips генерацию тайлов deepzoom / IIIF. Если продукту нужен сервер изображений в стиле Leaflet, пайплайн картографических тайлов или интерфейс масштабирования музейного уровня, без этой функции не обойтись. В Bun.Image нет примитива для тайлов, и в ближайшее время он, вероятно, не появится: за libvips стоят десятилетия разработки, а команда Bun сначала сосредоточится на типовых сценариях.

Ещё одно небольшое, но важное ограничение: Sharp выполняет поворот .rotate(45) на произвольный угол с билинейной интерполяцией. Bun.Image в .rotate() принимает только 90, 180 и 270. Для 99% задач с обложками и миниатюрами товаров это не проблема. Но для коррекции наклона и декоративных эффектов поворота — уже блокер.

Для нагрузок, где встречается хотя бы одно из этих условий, я теперь использую двойной стек: Bun.Image обслуживает обычный путь, а закреплённая версия Sharp запускается в потоке воркера только для особых случаев:

TypeScript
async function process(input: Uint8Array, meta: ImageMeta) {
  if (meta.hasICC || meta.isAnimated || meta.needsTile) {
    const sharp = (await import("sharp")).default;
    return sharp(input)
      .resize(1200, 630, { fit: "cover" })
      .webp({ quality: 82 })
      .toBuffer();
  }
  return Bun.image(input)
    .resize(1200, 630, { fit: "cover" })
    .webp({ quality: 82 })
    .toBuffer();
}

Динамический импорт не даёт Sharp попасть в бандл для окружений, где медленная ветка никогда не используется.

Результаты CI, холодного старта и обработки изображений в Bun

В первую очередь меня интересовала разница во времени установки: минуты CI быстро накапливаются.

На моём CI-раннере Ubuntu x86_64 прогретый bun install с закреплённой версией Sharp занимал 4.8s. После удаления Sharp из package.json — 1.4s. Выигрыш появился потому, что больше не нужно скачивать готовый бинарник Sharp и проверять необязательную системную зависимость libvips.

Холодная установка без ~/.bun/install/cache и node_modules ускорилась с 18.2s до 7.1s. Обычно удаление одного нативного аддона из дерева из 200 пакетов не даёт такой разницы. Здесь эффект оказался настолько большим потому, что postinstall Sharp был самым медленным отдельным этапом во всём дереве.

В Bun 1.3.14 также появилось глобальное хранилище изолированного линковщика, которое в примечаниях к релизу описано как «7x faster warm installs» в масштабе проекта. Вместе с удалением Sharp это сократило полный прогретый цикл bun install в репозитории админки с 6.4s до 1.1s. В локальной разработке такую разницу замечаешь сразу: bun add some-package перестаёт быть поводом уйти за кофе.

После удаления готового бинарника Sharp и Alpine-пакета vips-dev, который требовался резервной сборке, Docker-образы уменьшились примерно на 80MB. В CI стало больше попаданий в кэш слоёв: слой под этапом установки остаётся стабильным при большем числе изменений зависимостей, а скачивание готового бинарника Sharp было одной из самых шумных причин инвалидировать кэш.

В целом я стараюсь не раздувать инструментарий разработки — тот же принцип заставил меня выпустить хуки Claude Code 2.1.141 в день их появления. Именно накопительный эффект небольших улучшений помогает соло-разработчику оставаться конкурентоспособным. Ускорение bun install на 5 секунд звучит не слишком впечатляюще, если не умножить его на 80 коммитов в неделю.

Больше всего меня беспокоила задержка при обработке одного изображения. В показательном тесте — PNG 1024×1024 в WebP 512×512 с quality 82 — результаты Sharp 0.34.2 и Bun.Image на моём M2 отличались не более чем на 8%. Для веб-нагрузки такая разница несущественна.

Bun.Image конкурентоспособен в чистом изменении размера, хотя ему всего восемнадцать месяцев, а libvips — два десятилетия. Причина в архитектуре: SIMD-ядра изменения размера с целочисленной арифметикой i16 и фиксированной точкой сочетаются с IDCT-масштабированием JPEG до минимально достаточного размера прямо при декодировании. JPEG 4000×4000 не декодируется в полное растровое изображение с последующим уменьшением — Bun.Image сразу декодирует его в целевом разрешении. Sharp применяет тот же приём через libjpeg-turbo, поэтому результаты обоих пайплайнов оказываются примерно на одном уровне.

В работе с памятью преимущество Bun.Image заметнее: заимствование ArrayBuffer без копирования снижает пиковый RSS по сравнению с Sharp в пакетах из 50+ изображений. Это важно при обработке галерей за один вызов воркера. Если на каждый запрос приходится одно изображение, выигрыш незаметен.

Когда Sharp всё ещё лучше, а когда пора переходить на Bun.Image

Оставьте Sharp, если верно хотя бы одно условие:

  • Нужен покадровый доступ к анимированному WebP.
  • Требуется сохранять цветовые профили ICC для фотографий с широким цветовым охватом.
  • Пайплайн сервера изображений или картографических тайлов зависит от deepzoom-пирамиды .tile().
  • Нужен поворот на произвольный угол с интерполяцией.
  • Развёртывание работает только на Node — например, функции Vercel Node, рантайм Node в AWS Lambda или Cloudflare Workers, где Bun пока недоступен.

Переходите на Bun.Image, если выполняются все условия:

  • Хотя бы один уровень системы уже работает на рантайме Bun.
  • Обработка изображений сводится к «декодировать, изменить размер, повторно закодировать» для JPEG, PNG, WebP, AVIF или HEIC.
  • CI постоянно сталкивается с пересборкой готовых бинарников Sharp, либо используются Alpine-контейнеры и уже возникали проблемы с системной зависимостью libvips.

Если оценивать ситуацию без прикрас на май 2026 года, Bun.Image уже закрывает «95% типовых задач Sharp», при этом занимает намного меньше места при установке и вообще не требует возни с нативными аддонами. Оставшиеся 5% — область, где зрелость libvips по-прежнему оправдывает Sharp. Поэтому стоит заранее заложить период двойного стека: не удаляйте Sharp повсюду в первый же день. Сначала перенесите маршруты, которые соответствуют сильным сторонам Bun.Image, а для остальных оставьте Sharp. Вернитесь к этому разделению на Bun 1.4.x, когда, вероятно, появятся произвольный поворот и работа с кадрами анимации.

В списке ожидаемых улучшений — произвольный поворот, доступ к кадрам анимированного WebP и перенос ICC. С учётом скорости разработки после перехода на Rust именно эти три функции, вероятнее всего, появятся следующими. Подпишитесь на changelog Bun и пересматривайте разделение двухуровневой схемы после каждого минорного релиза.

Готовый коммит миграции с Sharp на Bun.Image

Так выглядит один продакшен-маршрут после замены — с обработкой ошибок и закреплённой версией в engines:

TypeScript
// package.json
// "engines": { "bun": ">=1.3.14" }

import { Hono } from "hono";

const app = new Hono();

app.post("/api/uploads", async (c) => {
  const form = await c.req.formData();
  const file = form.get("file");
  if (!(file instanceof File)) {
    return c.json({ error: "no file" }, 400);
  }

  const input = new Uint8Array(await file.arrayBuffer());

  try {
    const webp = await Bun.image(input)
      .resize(1200, 630, { fit: "cover" })
      .webp({ quality: 82 })
      .toBuffer();

    const jpeg = await Bun.image(input)
      .resize(1200, 630, { fit: "cover" })
      .jpeg({ quality: 84 })
      .toBuffer();

    await Bun.s3().write(`covers/${crypto.randomUUID()}.webp`, webp);
    await Bun.s3().write(`covers/${crypto.randomUUID()}.jpg`, jpeg);

    return c.json({ ok: true });
  } catch (err) {
    return c.json({ error: String(err) }, 500);
  }
});

export default app;

Три момента лучше закрепить явно.

Строка "engines": { "bun": ">=1.3.14" } в package.json критически важна. Bun.Image появился в версии 1.3.14: более ранние версии падают во время выполнения с ошибкой Bun.image is not a function. Лучше увидеть это как ошибку установки, а не получить 500 в продакшене.

Пакет bun-types, пришедший на смену @types/bun, содержит типы Bun.Image начиная с версии 1.3.14. Команда tsc --noEmit проходит без заглушек @ts-expect-error. Если редактор по-прежнему подчёркивает Bun.image красным, закреплена слишком старая версия bun-types.

План отката: в течение одного релизного цикла держите sharp в optionalDependencies, а в качестве резервного пути используйте двойной стек с динамическим импортом из раздела об ограничениях. После недели стабильных продакшен-метрик удалите sharp из optionalDependencies и резервную ветку. Не объединяйте оба действия в одном коммите. Если предпочитаете максимальную осторожность — не делайте их и в одну неделю.

Продакшен-готовность функции Bun проверяется не обещанием из changelog, а готовностью добавить её в собственный коммит. Эту я выпускаю.

Работает ли Bun.Image вне Bun, например в Node.js?

Нет. Bun.Image встроен в рантайм и не является npm-пакетом. Если нужна переносимая альтернатива Sharp для Node и Bun, рассмотрите сторонние пакеты вроде bun-image-turbo или оставайтесь на Sharp.

API Bun.Image действительно полностью совместим с Sharp или лишь вдохновлён им?

Структура цепочки и имена методов намеренно совместимы с Sharp: конструктор → .resize / .rotate / .flip / .modulate → завершающий .webp / .jpeg / .png / .avif. В большинстве мест достаточно заменить строку импорта. Есть четыре расхождения: произвольный поворот, кадры анимации, перенос ICC и .tile().

Что Bun.Image использует внутри?

libjpeg-turbo для декодирования и кодирования JPEG, spng для PNG, libwebp для WebP и AVIF, а также собственные SIMD-ядра геометрических операций Bun с целочисленным изменением размера i16 и фиксированной точкой. Всё скомпилировано в бинарник Bun: ни нативного аддона, ни этапа пересборки.

Как производительность Bun.Image при изменении размера соотносится с Sharp?

В типовых задачах изменения размера JPEG/PNG с повторным кодированием результаты на локальном оборудовании отличаются не более чем на ~8%. libvips в Sharp по-прежнему быстрее при потоковой обработке очень больших изображений и анимации. Главный выигрыш Bun.Image — время установки и память, а не чистая скорость CPU при единичном изменении размера.

Стоит ли переходить на Bun.Image прямо сейчас?

Да, если используется рантайм Bun, а пайплайн декодирует, меняет размер и повторно кодирует JPEG/PNG/WebP/AVIF. Если нужны кадры анимированного WebP, сохранение цветового профиля ICC или deepzoom через .tile(), оставьте Sharp на этих ветках и используйте двойной стек.

Последнее обновление

5 сент. 2026 г.

КатегорияBuild

Сделать этот сайт предпочтительным в Google

Добавить omidsaffari.com как предпочтительный источник в Google Поиске

Отметьте omidsaffari.com как предпочтительный источник — и Google будет поднимать его для вас в Top Stories, AI Overviews и AI Mode.

Ещё из Build

Все статьи Build
Рассылка

Одно письмо, каждое воскресенье. Работающие системы, а не горячие мнения.

Билд-логи, системы в продакшене и полевые заметки из портфеля ИИ-проектов.

Еженедельно. Без спама. Отписка в любой момент.