packages feed

libheif-hs-0.1.0.0: src/Codec/HEIF/Bindings.hs

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedRecordUpdate #-}

module Codec.HEIF.Bindings
  ( Image (..),
    Metadata (..),
    MetadataBlock (..),
    HeifException (..),
    HeifError (..),
    HeifSubError (..),
    MetadataType (..),
    Encoder (..),
    EncodeQuality (..),
    EncodeOptions (..),
    DecodeOptions (..),
    heifVersion,
    withContext,
    withPrimaryImage,
    withDecodedRGB,
    withRGBImage,
    withEncoder,
    withNewRGBPlane,
    withEncodeImage,
    encodeQuality,
    readByteStringIntoContext,
    readFileIntoContext,
    extractImageData,
    extractMetadata,
    fillPlaneFromByteString,
    addMetadataBlock,
    encodeToByteString,
    encodeWriteToFile,
  )
where

import Codec.HEIF.Bindings.Generated qualified as HEIF
import Codec.HEIF.Bindings.Generated.Safe qualified as HEIF
import Codec.HEIF.Error
import Codec.HEIF.Error.Internal
import Control.Exception
import Control.Monad (when)
import Data.ByteString qualified as BS
import Data.ByteString.Builder qualified as BSB
import Data.ByteString.Internal qualified as BSI
import Data.ByteString.Lazy qualified as BSL
import Data.ByteString.Unsafe qualified as BSU (unsafeUseAsCStringLen)
import Data.IORef
import Data.Map qualified as M
import Foreign qualified
import Foreign.C.ConstPtr (ConstPtr (..))
import Foreign.C.String qualified as Foreign (peekCString, withCString)
import Foreign.Marshal.Utils (copyBytes)
import Foreign.Ptr (plusPtr)
import HsBindgen.Runtime.LibC qualified as Bindgen
import HsBindgen.Runtime.Prelude qualified as Bindgen
import HsBindgen.Runtime.PtrConst qualified as Bindgen
import HsBindgen.Runtime.Support qualified as Bindgen
import Numeric.Natural
import Prelude

data DecodeOptions
  = DecodeOptions
  { -- | Do not apply metadata transformations such as crop or rotate.
    doIgnoreTransformations :: Bool,
    -- | Downsamples high color depth images to 8 bits.
    doConvertHDRTo8Bit :: Bool
  }

newtype EncodeOptions
  = EncodeOptions
  { eoQuality :: EncodeQuality
  }

constNullPtr :: Bindgen.PtrConst a
constNullPtr = Bindgen.unsafeFromPtr Foreign.nullPtr

-- | Returns the version of the underlying libheif C library.
heifVersion :: IO String
heifVersion = do
  c_str <- HEIF.heif_get_version
  Foreign.peekCString $ Bindgen.unsafeToPtr c_str

ccharPtrToString :: Bindgen.PtrConst Bindgen.CChar -> IO (Maybe String)
ccharPtrToString constPtr = do
  let rawPtr = Bindgen.unsafeToPtr constPtr
  if rawPtr == Foreign.nullPtr
    then pure Nothing
    else Just <$> Foreign.peekCString rawPtr

errorToException :: HEIF.Heif_error -> IO HeifException
errorToException HEIF.Heif_error {..} = do
  message <- ccharPtrToString heif_error_message
  pure $ LibraryException Library {..}
  where
    code = convertHeifErrorCode heif_error_code
    subcode = convertHeifSubErrorCode heif_error_subcode

throwIfError :: IO HEIF.Heif_error -> IO ()
throwIfError action = do
  err <- action
  when (HEIF.unwrapHeif_error_code (HEIF.heif_error_code err) /= 0) $
    errorToException err >>= throwIO

newtype Context = Context
  { _unContextHandle :: Foreign.Ptr HEIF.Heif_context
  }

newtype ImageHandle = ImageHandle (Bindgen.PtrConst HEIF.Heif_image_handle)

newtype WritableImageHandle = WritableImageHandle (Foreign.Ptr HEIF.Heif_image_handle)

newtype HeifImage = HeifImage (Bindgen.PtrConst HEIF.Heif_image)

newtype WritableImage = WritableImage (Foreign.Ptr HEIF.Heif_image)

newtype Encoder = Encoder (Foreign.Ptr HEIF.Heif_encoder)

data Plane = Plane
  { planePtr :: Foreign.Ptr Bindgen.Word8,
    planeStride :: Int,
    planeWidth :: Natural,
    planeHeight :: Natural
  }

-- | Lossy values must be in range 0-100.
data EncodeQuality = Lossless | Lossy Natural

withContext :: (Context -> IO a) -> IO a
withContext = bracket acquire release
  where
    acquire = Context <$> HEIF.heif_context_alloc
    release (Context ptr) = HEIF.heif_context_free ptr

withPrimaryImage :: Context -> (ImageHandle -> IO a) -> IO a
withPrimaryImage (Context contextPtr) = bracket acquire release
  where
    acquire = Foreign.alloca $ \imageHandleOutPtr -> do
      throwIfError $
        HEIF.heif_context_get_primary_image_handle
          contextPtr
          imageHandleOutPtr
      ImageHandle . Bindgen.unsafeFromPtr <$> Foreign.peek imageHandleOutPtr
    release (ImageHandle ptr) = HEIF.heif_image_handle_release ptr

withOptionsPointer :: (Foreign.Ptr HEIF.Heif_decoding_options -> IO a) -> IO a
withOptionsPointer =
  bracket acquire release
  where
    acquire = HEIF.heif_decoding_options_alloc
    release = HEIF.heif_decoding_options_free

withDecodedRGB :: DecodeOptions -> ImageHandle -> (HeifImage -> IO a) -> IO a
withDecodedRGB DecodeOptions {..} (ImageHandle handlePtr) = bracket acquire release
  where
    acquire = Foreign.alloca $ \imageOutPtr -> do
      withOptionsPointer $ \optionsPtr -> do
        Foreign.poke
          optionsPtr.heif_decoding_options_ignore_transformations
          (if doIgnoreTransformations then 1 else 0)
        Foreign.poke
          optionsPtr.heif_decoding_options_convert_hdr_to_8bit
          (if doConvertHDRTo8Bit then 1 else 0)
        throwIfError $
          HEIF.heif_decode_image
            handlePtr
            imageOutPtr
            HEIF.Heif_colorspace_RGB
            HEIF.Heif_chroma_interleaved_RGB
            (Bindgen.unsafeFromPtr optionsPtr)
      HeifImage . Bindgen.unsafeFromPtr <$> Foreign.peek imageOutPtr
    release (HeifImage ptr) = HEIF.heif_image_release ptr

withEncoder :: Context -> (Encoder -> IO a) -> IO a
withEncoder (Context contextPtr) = bracket acquire release
  where
    acquire = Foreign.alloca $ \encoderOutPtr -> do
      throwIfError $
        HEIF.heif_context_get_encoder_for_format
          contextPtr
          HEIF.Heif_compression_HEVC
          encoderOutPtr
      Encoder <$> Foreign.peek encoderOutPtr
    release (Encoder ptr) = HEIF.heif_encoder_release ptr

encodeQuality :: EncodeQuality -> Encoder -> IO ()
encodeQuality Lossless (Encoder encoderPtr) =
  throwIfError $ HEIF.heif_encoder_set_lossless encoderPtr 1
encodeQuality (Lossy quality) (Encoder encoderPtr) = do
  throwIfError $ HEIF.heif_encoder_set_lossless encoderPtr 0
  throwIfError $ HEIF.heif_encoder_set_lossy_quality encoderPtr (fromIntegral quality)

readByteStringIntoContext :: Context -> BS.ByteString -> IO ()
readByteStringIntoContext (Context contextPtr) bs =
  BSU.unsafeUseAsCStringLen bs $ \(cStrPtr, len) -> do
    let voidPtr = Bindgen.unsafeFromPtr (Foreign.castPtr cStrPtr)
        cSize = fromIntegral len :: Bindgen.CSize
    -- heif_context_read_from_memory_without_copy is not suitable because the underlying
    -- bytestring could be garbage collected, in which case we'd be feeding invalid data
    -- to the decoder...
    throwIfError $ HEIF.heif_context_read_from_memory contextPtr voidPtr cSize constNullPtr

readFileIntoContext :: Context -> FilePath -> IO ()
readFileIntoContext (Context contextPtr) filePath =
  Foreign.withCString filePath $ \filePathPtr ->
    throwIfError $
      HEIF.heif_context_read_from_file contextPtr (Bindgen.unsafeFromPtr filePathPtr) constNullPtr

-- | Raw encoding of RGB data.
data Image = Image
  { imagePixelsRGB :: BS.ByteString,
    imageWidth :: Natural,
    imageHeight :: Natural
  }
  deriving (Show, Eq)

extractImageData :: HeifImage -> IO Image
extractImageData (HeifImage imgPtr) = Foreign.alloca $ \strideOutPtr -> do
  rawPlanePtr <-
    HEIF.heif_image_get_plane_readonly imgPtr HEIF.Heif_channel_interleaved strideOutPtr

  -- TODO: we could check that
  -- Bindgen.unsafeToPtr rawPlanePtr /= Foreign.nullPtr
  strideCSize <- Foreign.peek strideOutPtr
  heightCInt <- HEIF.heif_image_get_height imgPtr HEIF.Heif_channel_interleaved
  widthCInt <- HEIF.heif_image_get_width imgPtr HEIF.Heif_channel_interleaved

  let stride = fromIntegral strideCSize :: Int
      imageHeight :: Natural = fromIntegral heightCInt
      imageWidth :: Natural = fromIntegral widthCInt
      rowBytes = imageWidth * 3 -- 3 bytes per pixel for RGB
      totalExpectedBytes = rowBytes * imageHeight
      sourcePtr = Foreign.castPtr (Bindgen.unsafeToPtr rawPlanePtr)

  -- Allocate exactly the memory needed for the final contiguous ByteString
  imagePixelsRGB <- BSI.create (fromIntegral totalExpectedBytes) $ \destinationPtr ->
    mapM_
      ( \row -> do
          let currentSrcRow = sourcePtr `plusPtr` (row * stride)
              currentDestRow = destinationPtr `plusPtr` (row * fromIntegral rowBytes)
          copyBytes currentDestRow currentSrcRow $ fromIntegral rowBytes
      )
      [0 .. fromIntegral imageHeight - 1]

  pure $ Image {..}

data MetadataType = Exif | Mime | OtherMetadataType String
  deriving (Show, Eq)

-- | Metadata of type e.g. Exif, mime, or anything else. The block contains raw metadata bytes,
-- it is the caller's responsability to validate and decode it. For Exif consider using
-- [hsexif](https://hackage.haskell.org/package/hsexif).
data MetadataBlock = MetadataBlock
  { metadataBlockType :: MetadataType,
    metadataBlockContentType :: String,
    metadataBlockData :: BS.ByteString
  }
  deriving (Show, Eq)

-- | Metadata found in the image.
newtype Metadata = Metadata
  { metadataBlocks :: M.Map Bindgen.Word32 MetadataBlock
  }
  deriving (Show, Eq)

getMetadataBlocks :: ImageHandle -> IO (M.Map Bindgen.Word32 MetadataBlock)
getMetadataBlocks imageHandle@(ImageHandle image) = do
  numBlocks <- HEIF.heif_image_handle_get_number_of_metadata_blocks image constNullPtr
  let count = fromIntegral numBlocks
  if count <= 0
    then pure mempty
    else do
      ids <- Foreign.allocaArray count $ \idsPtr -> do
        _ActualCount <-
          HEIF.heif_image_handle_get_list_of_metadata_block_IDs
            image
            constNullPtr
            idsPtr
            (fromIntegral count)
        Foreign.peekArray count idsPtr
      M.fromList <$> mapM (extractMetadataBlock imageHandle) ids

-- | Exif metadata has a 4 0-byte prefix, strip it
dropExifHeader :: String -> BS.ByteString -> BS.ByteString
dropExifHeader "Exif" rawData
  | BS.length rawData > 4 = BS.drop 4 rawData
  | otherwise = rawData
dropExifHeader "mime" rawData = rawData -- Direct raw XML, no offset to drop
dropExifHeader _ rawData = rawData

mkMetadataType :: String -> MetadataType
mkMetadataType "Exif" = Exif
mkMetadataType "mime" = Mime
mkMetadataType other = OtherMetadataType other

metadataTypeToString :: MetadataType -> String
metadataTypeToString Exif = "Exif"
metadataTypeToString Mime = "mime"
metadataTypeToString (OtherMetadataType other) = other

extractMetadataBlock :: ImageHandle -> HEIF.Heif_item_id -> IO (Bindgen.Word32, MetadataBlock)
extractMetadataBlock (ImageHandle image) itemId = do
  -- Fetch the block type string ("Exif", "mime", etc.)
  metadataTypePtr <- HEIF.heif_image_handle_get_metadata_type image itemId
  metadataTypeString <- Foreign.peekCString (Bindgen.unsafeToPtr metadataTypePtr)

  metadataContentTypePtr <- HEIF.heif_image_handle_get_metadata_content_type image itemId
  metadataContentTypeString <- Foreign.peekCString (Bindgen.unsafeToPtr metadataContentTypePtr)

  -- Get exact size in bytes needed for memory allocation
  dataSize <- HEIF.heif_image_handle_get_metadata_size image itemId

  -- Allocate byte buffer, call C function, and pack into a ByteString
  bsData <- Foreign.allocaBytes (fromIntegral dataSize) $ \outBuf -> do
    throwIfError $ HEIF.heif_image_handle_get_metadata image itemId (Foreign.castPtr outBuf)
    -- Check 'err' here if your bindings expose error validation
    BS.packCStringLen (Foreign.castPtr outBuf, fromIntegral dataSize)

  pure
    ( HEIF.unwrapHeif_item_id itemId,
      MetadataBlock
        { metadataBlockType = mkMetadataType metadataTypeString,
          metadataBlockData = dropExifHeader metadataTypeString bsData,
          metadataBlockContentType = metadataContentTypeString
        }
    )

-- | Returns (some) metadata information about the image.
extractMetadata :: ImageHandle -> IO Metadata
extractMetadata imageHandle = do
  metadataBlocks <- getMetadataBlocks imageHandle
  pure $ Metadata {..}

withRGBImage :: Natural -> Natural -> (WritableImage -> IO a) -> IO a
withRGBImage width height = bracket acquire release
  where
    acquire = Foreign.alloca $ \imageOutPtr -> do
      throwIfError $
        HEIF.heif_image_create
          (fromIntegral width)
          (fromIntegral height)
          HEIF.Heif_colorspace_RGB
          HEIF.Heif_chroma_interleaved_RGB
          imageOutPtr
      WritableImage <$> Foreign.peek imageOutPtr
    release (WritableImage imageOutPtr) =
      HEIF.heif_image_release (Bindgen.unsafeFromPtr imageOutPtr)

withNewRGBPlane :: WritableImage -> Natural -> Natural -> (Plane -> IO a) -> IO a
withNewRGBPlane (WritableImage imagePtr) planeWidth planeHeight = bracket acquire release
  where
    acquire = Foreign.alloca $ \stridePtr -> do
      throwIfError $
        HEIF.heif_image_add_plane
          imagePtr
          HEIF.Heif_channel_interleaved
          (fromIntegral planeWidth)
          (fromIntegral planeHeight)
          24 -- 24 bits per pixel (RGB)
      planePtr <- HEIF.heif_image_get_plane imagePtr HEIF.Heif_channel_interleaved stridePtr
      planeStride <- fromIntegral <$> Foreign.peek stridePtr
      pure Plane {..}
    release _ = pure ()

fillPlaneFromByteString :: Plane -> BS.ByteString -> IO ()
fillPlaneFromByteString Plane {..} rgbData
  | dataLength /= expectedSize =
      throw $
        DecodeSizeMismatch
          SizeMismatch
            { expectedBytes = fromIntegral expectedSize,
              infoWidth = planeWidth,
              infoHeight = planeHeight,
              gotBytes = fromIntegral dataLength
            }
  | planeStride == rgbWidth = copyFast
  | otherwise = copyStrided
  where
    w = fromIntegral planeWidth :: Int
    h = fromIntegral planeHeight :: Int
    rgbWidth = w * 3
    expectedSize = rgbWidth * h
    dataLength = BS.length rgbData
    copyFast = BSU.unsafeUseAsCStringLen rgbData $ \(sourcePtr, len) ->
      copyBytes planePtr (Foreign.castPtr sourcePtr) len
    copyStrided = BSU.unsafeUseAsCStringLen rgbData $ \(srcPtr, _) -> do
      mapM_ (copyRow srcPtr) [0 .. h - 1]
    copyRow srcBytePtr y = do
      let srcRow = srcBytePtr `plusPtr` (y * rgbWidth)
          dstRow = planePtr `plusPtr` (y * planeStride)
      copyBytes dstRow srcRow rgbWidth

withEncodeImage ::
  Context -> WritableImage -> Encoder -> (WritableImageHandle -> IO a) -> IO a
withEncodeImage (Context contextPtr) (WritableImage image) (Encoder encoderPtr) =
  bracket acquire release
  where
    acquire = Foreign.alloca $ \imageHandlePtr -> do
      throwIfError $
        HEIF.heif_context_encode_image
          contextPtr
          (Bindgen.unsafeFromPtr image)
          encoderPtr
          constNullPtr
          imageHandlePtr
      WritableImageHandle <$> Foreign.peek imageHandlePtr

    release (WritableImageHandle imageHandlePtr) =
      HEIF.heif_image_handle_release (Bindgen.unsafeFromPtr imageHandlePtr)

-- | It's complementary to 'dropExifHeader'.
addExifHeader :: MetadataType -> BS.ByteString -> BS.ByteString
addExifHeader Exif = (BS.replicate 4 0x00 <>)
addExifHeader _ = id

addMetadataBlock :: Context -> WritableImageHandle -> MetadataBlock -> IO ()
addMetadataBlock (Context contextPtr) (WritableImageHandle imageHandle) MetadataBlock {..} =
  BSU.unsafeUseAsCStringLen (addExifHeader metadataBlockType metadataBlockData) $
    \(dataPtr, len) -> do
      Foreign.withCString (metadataTypeToString metadataBlockType) $ \metadataTypePtr ->
        Foreign.withCString metadataBlockContentType $ \contentTypePtr ->
          throwIfError $
            HEIF.heif_context_add_generic_metadata
              contextPtr
              (Bindgen.unsafeFromPtr imageHandle)
              (Bindgen.unsafeFromPtr $ Foreign.castPtr dataPtr)
              (fromIntegral len)
              (Bindgen.unsafeFromPtr metadataTypePtr)
              (Bindgen.unsafeFromPtr contentTypePtr)

type HsWriteCb =
  Bindgen.Ptr HEIF.Heif_context ->
  Bindgen.PtrConst Bindgen.Void ->
  Bindgen.CSize ->
  Bindgen.Ptr Bindgen.Void ->
  IO Bindgen.CInt

foreign import ccall "get_c_trampoline_ptr"
  c_get_trampoline_ptr :: IO (Bindgen.Ptr ())

foreign import ccall "wrapper"
  mkHsWriteCb :: HsWriteCb -> IO (Bindgen.FunPtr HsWriteCb)

encodeWriteToFile :: Context -> FilePath -> IO ()
encodeWriteToFile (Context contextPtr) filePath =
  Foreign.withCString filePath $ \filePathPtr -> do
    throwIfError $
      HEIF.heif_context_write_to_file
        contextPtr
        (Bindgen.unsafeFromPtr filePathPtr)

encodeToByteString :: Context -> IO BSL.ByteString
encodeToByteString (Context contextPtr) = do
  bufferRef <- newIORef mempty
  bracket (acquire bufferRef) release (action bufferRef)
  where
    acquire ref = mkHsWriteCb (writeCallback ref)
    release = Foreign.freeHaskellFunPtr
    action ref chunkWriteFunctionPtr = do
      Foreign.alloca $ \writerPtr -> do
        trampolinePtr <- c_get_trampoline_ptr
        Foreign.poke writerPtr.heif_writer_writer_api_version (1 :: Bindgen.CInt)
        Foreign.poke writerPtr.heif_writer_write (Foreign.castPtrToFunPtr trampolinePtr)

        -- Trigger the encoding; this will invoke `cbPtr` multiple times
        -- Critical: pass the Haskell FunPtr as the 'userdata' argument!
        throwIfError $
          HEIF.heif_context_write
            contextPtr
            writerPtr
            (Foreign.castFunPtrToPtr chunkWriteFunctionPtr)

      -- Extract and finalize the ByteString from the builder
      finalBuilder <- readIORef ref
      pure $ BSB.toLazyByteString finalBuilder

    -- The Callback: appends raw C memory chunks to our IORef Builder
    writeCallback :: IORef BSB.Builder -> HsWriteCb
    writeCallback ref _ctx dataPtr size _userData = do
      chunk <- BS.packCStringLen (Foreign.castPtr $ Bindgen.unsafeToPtr dataPtr, fromIntegral size)
      modifyIORef' ref (\b -> b <> BSB.byteString chunk)
      pure 0