6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
160 lines
6.6 KiB
TypeScript
160 lines
6.6 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { getErrorMessage } from '@sim/utils/errors'
|
|
|
|
const logger = createLogger('HeicTranscode')
|
|
|
|
/**
|
|
* ISO-BMFF brands that name HEVC as the coded format outright. Every browser and
|
|
* every vision model rejects these, so they are exactly the set worth transcoding
|
|
* before anything else has been tried.
|
|
*/
|
|
const HEVC_HEIF_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx'])
|
|
|
|
/**
|
|
* Every ISO-BMFF brand in the HEIF family. Broader than {@link HEVC_HEIF_BRANDS}:
|
|
* it answers "are these bytes worth handing to a HEIF decoder", not "which codec is
|
|
* inside". `mif1`/`msf1` are generic and carry either HEVC or AV1, and `avif`/`avis`
|
|
* are included because a decoder that already failed on them has nothing to lose.
|
|
*/
|
|
const HEIF_BRANDS = new Set([...HEVC_HEIF_BRANDS, 'mif1', 'msf1', 'avif', 'avis'])
|
|
|
|
/**
|
|
* Byte ceiling for a fallback decode. Uploads allow 100MB, so without this a tenant
|
|
* could push an arbitrarily large HEIF through a single-threaded WebAssembly decode.
|
|
* 20MB leaves generous headroom over any phone photo — a 12MP iPhone HEIC is 1-4MB —
|
|
* while bounding what one read can cost.
|
|
*
|
|
* This bounds file size only; {@link MAX_TRANSCODE_INPUT_PIXELS} bounds the raster,
|
|
* which a small file can still declare to be enormous.
|
|
*/
|
|
export const MAX_TRANSCODE_INPUT_BYTES = 20 * 1024 * 1024
|
|
|
|
/**
|
|
* Pixel ceiling for the fallback decode, checked against the container's declared
|
|
* dimensions before any raster exists.
|
|
*
|
|
* Needed because the decoder allocates `width * height * 4` up front — the size is
|
|
* taken straight from the `ispe` box and the buffer is built before the codec is
|
|
* asked for anything, so a malformed file never has to decode to cost the memory.
|
|
* libheif's own default ceiling is ~1.07e9 pixels (~4.3GB as RGBA), which is far too
|
|
* loose to be the only guard.
|
|
*
|
|
* 100MP caps that allocation near 400MB and clears every phone camera — a 48MP
|
|
* iPhone still is 8064x6048.
|
|
*/
|
|
const MAX_TRANSCODE_INPUT_PIXELS = 100_000_000
|
|
|
|
/** A real `ftyp` box holds a handful of brands; anything larger is malformed or hostile. */
|
|
const MAX_FTYP_BOX_BYTES = 512
|
|
|
|
/**
|
|
* Whether an ISO-BMFF `ftyp` box names any of `brands`, as either the major brand
|
|
* or a compatible brand.
|
|
*
|
|
* Sniffed rather than read off the declared type because the common case is a
|
|
* `.heic` stored as `application/octet-stream`, where the declared type says
|
|
* nothing at all.
|
|
*/
|
|
function declaresBrand(buffer: Buffer, brands: ReadonlySet<string>): boolean {
|
|
if (buffer.length < 12) return false
|
|
if (buffer.toString('ascii', 4, 8) !== 'ftyp') return false
|
|
if (brands.has(buffer.toString('ascii', 8, 12))) return true
|
|
|
|
// A standards-valid HEIF may carry a generic major brand such as `isom` and name
|
|
// the HEIF brand only among the compatible brands, which follow the 4-byte
|
|
// minor_version at offset 12 and run to the end of the box. A declared size of 0
|
|
// or 1 (the ISO-BMFF size escapes, which `ftyp` does not use) leaves `end` below
|
|
// the loop's start, so those simply do not scan. The size is attacker-controlled
|
|
// and this runs on every preview, so cap it rather than trusting the declaration.
|
|
const end = Math.min(buffer.readUInt32BE(0), buffer.length, MAX_FTYP_BOX_BYTES)
|
|
for (let offset = 16; offset + 4 <= end; offset += 4) {
|
|
if (brands.has(buffer.toString('ascii', offset, offset + 4))) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Whether these bytes are an ISO-BMFF container in the HEIF family, whatever codec
|
|
* they carry. Use where a decode has already been attempted and failed — the extra
|
|
* breadth costs nothing there, and it catches the generic `mif1` brand.
|
|
*/
|
|
export function isHeifContainer(buffer: Buffer): boolean {
|
|
return declaresBrand(buffer, HEIF_BRANDS)
|
|
}
|
|
|
|
/**
|
|
* Whether these bytes declare HEVC-coded HEIF. Use where the decode has *not* been
|
|
* attempted yet and the answer decides whether to try: an AV1-coded HEIF (`avif`)
|
|
* renders natively everywhere, so treating it as a transcode candidate only buys a
|
|
* wasted decode and a misleading failure.
|
|
*/
|
|
export function isHevcHeifContainer(buffer: Buffer): boolean {
|
|
return declaresBrand(buffer, HEVC_HEIF_BRANDS)
|
|
}
|
|
|
|
/**
|
|
* Transcode a HEVC-coded HEIF still to JPEG.
|
|
*
|
|
* Two reasons, neither with a workaround: no vision model accepts HEIC (the Claude
|
|
* Messages API takes JPEG, PNG, GIF, and WebP only), and sharp's prebuilt libvips
|
|
* ships libheif with AV1 but not HEVC — it decodes AVIF and rejects an iPhone photo.
|
|
*
|
|
* Returns `null` when the bytes cannot be decoded; never a partial image.
|
|
*/
|
|
export async function transcodeHeicToJpeg(buffer: Buffer): Promise<Buffer | null> {
|
|
if (buffer.length > MAX_TRANSCODE_INPUT_BYTES) {
|
|
logger.warn('Skipped HEIC transcode above the input ceiling', {
|
|
bytes: buffer.length,
|
|
ceiling: MAX_TRANSCODE_INPUT_BYTES,
|
|
})
|
|
return null
|
|
}
|
|
|
|
try {
|
|
// Read the declared dimensions first. `all()` parses the container and reports
|
|
// each image's size while leaving the decode — and therefore the allocation —
|
|
// for `decode()`, which is what makes refusing an oversized one cheap. The
|
|
// container gets parsed twice as a result; that is a header parse against a
|
|
// ceiling this path exists to enforce, and only on the HEVC fallback.
|
|
const { all } = await import('heic-decode')
|
|
const images = await all({ buffer })
|
|
// `all()` hands back live libheif handles and, unlike the default export, leaves
|
|
// freeing them to the caller — skipping this leaks the decoder context on the
|
|
// WebAssembly heap once per preview. The dimensions are plain numbers, so they
|
|
// outlive the handles safely.
|
|
let oversized: { width: number; height: number } | undefined
|
|
try {
|
|
oversized = images
|
|
.map(({ width, height }) => ({ width, height }))
|
|
.find((image) => image.width * image.height > MAX_TRANSCODE_INPUT_PIXELS)
|
|
} finally {
|
|
images.dispose()
|
|
}
|
|
if (oversized) {
|
|
logger.warn('Skipped HEIC transcode above the pixel ceiling', {
|
|
width: oversized.width,
|
|
height: oversized.height,
|
|
pixels: oversized.width * oversized.height,
|
|
ceiling: MAX_TRANSCODE_INPUT_PIXELS,
|
|
bytes: buffer.length,
|
|
})
|
|
return null
|
|
}
|
|
|
|
const convert = (await import('heic-convert')).default
|
|
const jpeg = await convert({ buffer, format: 'JPEG' })
|
|
logger.info('Transcoded HEIC image', {
|
|
inputBytes: buffer.length,
|
|
outputBytes: jpeg.length,
|
|
})
|
|
return Buffer.from(jpeg)
|
|
} catch (error) {
|
|
logger.warn('Failed to transcode HEIC image', {
|
|
bytes: buffer.length,
|
|
brand: buffer.toString('ascii', 8, 12),
|
|
error: getErrorMessage(error),
|
|
})
|
|
return null
|
|
}
|
|
}
|