mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 15:07:32 +00:00
* WIP: Haskell impl of resizeImageToStrSize * WIP: add FFI export wrapper * WIP: switch ios resizeImageToStrSizeSync to backend * WIP: fix resizeImageToStrSizeSync * WIP: add JNI wrappers * Add non-base64 resize and cWriteImage * WIP: add chat_write_image * fix resizeToStr * fixes * fix syntax, raise jpeg quality back * add chatWriteImage * Fix cfArgs in tests * Use jpeg-turbo encoder * add nix pins * bump jpeg-turbo * add CI deps * add encoding funs to android lib * bump jpeg-turbo for 20.04 fix * jpeg-turbo fix for 8.10 * android fixes * Bump jpeg-turbo * set static flag for jpeg-turbo * Fix nix for aarch64/android * Fix android C api * Fix resize in android * Bump hs-libjpeg-turbo * target correct override * override android lib from android pkgs * add macos build tweaks * bump hs-jpeg-turbo for 8.10 fixes * more build fixes * make ios compile * Always use pkgconfig for libjpeg-turbo * WIP: debug libjpeg in ubuntu pkg-config CI * Add pkg-config to dockerfiles * Add libturbojpeg to dockerfiles * Use -dev version of libturbojpeg in dockerfiles * Install pkg-config in windows CI * Disable flag for custom libturbojpeg linking options Workarounds relocation error on x86_64-linux with gcc: /usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/13/../../../x86_64-linux-gnu/libturbojpeg.a(turbojpeg.c.o): relocation R_X86_64_TPOFF32 against `errStr' can not be used when making a shared object; recompile with -fPIC /usr/bin/ld: failed to set dynamic section sizes: bad value * WIP: debug libjpeg in windows pkg-config CI * WIP: debug libjpeg package in windows CI * core: image resize module and buffer FFI * ci: link libjpeg-turbo statically on all platforms * ui: resize images in the core * core: rename image resize functions to chat_resize_image and chat_resize_image_uri * ci: restore whitespace in build.yml * ci: build libjpeg-turbo 3.1.4.1 from a submodule * core: hs-jpeg-turbo from github.com/simplex-chat * nodejs: resize images * python: resize images * core: hs-jpeg-turbo from gitlab.com, the GitHub import lacks LFS objects * core: generate /img previews * ci: link libjpeg-turbo on Windows without pkg-config * ci: build libjpeg-turbo on Windows with the GHC toolchain * docs: plan for image resizing in the core * docs: update image resizing plan * ios: prevent crash when wallpaper image cannot be resized * android, desktop: raise an error when an image cannot be resized * docs: update spec references for image resizing * docker: build the CLI image from a clone without submodules * docs: apply review fixes to image resizing plan * docker: build the CLI from a git worktree with the submodule present * docs: apply second review to image resizing plan * docs: point image resizing plan at the rewritten commit * core: choose image format by transparency in chat_resize_image * nodejs: resize images to the app sizes * python: resize images to the app sizes * nodejs, python: describe the core modules as used for image resizing * docs: add license notices of the image libraries * ci: describe the avatar method without attributing it to the apps * ci, ios, nix: remove the libjpeg-turbo build outside cabal * python: name resize_image in the core module text * nodejs, python: describe resizing to the app image sizes * core: refuse images larger than the supported size * core: resize to JPEG images with a side above 65500 pixels * docs: record image resizing decisions for the bundled libjpeg-turbo * core, docs: build libjpeg-turbo and stb from the fork submodules * docs: record the fork pins in the image resizing plan * core: resize images to at most 4320 pixels per side, refuse above 16384 * docs: record the image side limits in the image resizing plan * core: pin the rebased hs-jpeg-turbo fork * core: pin the hs-jpeg-turbo fork without package.yaml * docs: plan scaled JPEG decoding and EXIF orientation of images * docs: copy the JuicyPixels-stbir licence of the pinned fork * core: pin the hs-jpeg-turbo fork with scaled JPEG decoding * core: decode JPEG at the display size, up to 65500 pixels per side * core: rotate JPEG images by their EXIF orientation * docs: refer the image plan to the JPEG decoding code * docs: record the approved image processing decisions * core: limit JPEG decoding memory to 1024 MB outside iOS and Android * docs: name JPEG decoding in the libjpeg-turbo notice * docs: plan image resizing from files and the decisions of 26 Oct * core: scale images in one Catmull-Rom pass with sharpening * docs: plan image sizes at a fixed quality * core: resize images at a fixed quality to fill the size limit * core: resize images from a file path or a file descriptor * ios, android, desktop: resize picked images from their files * core: encode resized images with mozjpeg * tests: check descriptor closing by the file it refers to * core: raise JPEG quality only for a full-size image below 97% of the limit * ci: name the mozjpeg submodule in the Windows cleanup comments * docs: refer the image plan to the current code * docs: refer the image plan to the code merged with master * core: export every image resizing function from each library * core: close the file descriptor when its handle cannot be created * docs: count the C sources of mozjpeg with the arithmetic decoder * core: use the reviewed hs-jpeg-turbo and JuicyPixels-stbir forks * ci: build the armv7a Android core library on request * core: round image heights half up in the size search * ios: describe the file image resizing functions in SimpleX.h * ci: pick, preview and send images in the Claude app tests * core: decode PNG images with libspng and refuse other formats * core: encode PNG with libspng, keeping the smaller filter choice * ci: pick, preview and send images in the scripted app smoke tests * flake: build the PNG decoder package in nix builds * ios: remove the copy of a picked image after sending it * core: refuse image files larger than the decode memory limit * core: scale a JPEG image to its largest size once per resize * core: bisect JPEG quality without a step function * core: reduce the memory used to sharpen and to decode PNG images * android, desktop: pass the image format to the core as an integer * android, desktop: fuse the preview conditions of picked media * android, desktop, ci: check that the core resized the smoke test images * python: remove the docstrings of the explicit image resizing functions * docs: add the second review fixes to the image plan * ci: tap the iOS attachment button by its accessibility label * core: keep the colour profiles of resized images * docs: add the colour profiles to the image plan * ci: wait up to 30 s for the new group screens in the iOS smoke test * ci: pick the Android smoke test images from their own photo folders * ci: tap the iOS photo picker by position, as its tree is not shown * ci: pick the test card and the logo by their iOS picker positions * core: update JuicyPixels-stbir with the stb_image_resize licence terms * core: write a fixed Display P3 profile for Display P3 images only * core: do not keep the input image in memory for its colour profile * core: read the JPEG segments once for the orientation and the profile * core: read an image file into one buffer of its size * docs: add the third review fixes to the image plan * core: resize images with Display P3 or no profile, not profile bytes * ci: check the pixel sizes of the images sent in the iOS smoke test * core: update hs-jpeg-turbo with PNG chunk limits and encoder fixes * docs: list Compact ICC Profiles, libspng and miniz in the dependencies * android, desktop: reword the picked media preview comment * docs: refer the image plan to the code merged with master * ci: render the iOS snapshots before the images case * ci: dismiss the snapshot tests' camera alert before the iOS images case * core: update hs-jpeg-turbo with tests and the JuicyPixels-spng synopsis * core: pin hs-jpeg-turbo and JuicyPixels-stbir in simplex-chat repositories * update ios core library * core, ui: accept only JPEG and PNG image data * core: update JuicyPixels-stbir * docs: plan for the iOS copy of picked images --------- Co-authored-by: IC Rainbow <aenor.realm@gmail.com> Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Alexandre Esteves <alexfmpe@proton.me>
486 lines
19 KiB
Swift
486 lines
19 KiB
Swift
//
|
|
// ImageUtils.swift
|
|
// SimpleX (iOS)
|
|
//
|
|
// Created by Evgeny on 24/12/2022.
|
|
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import AVKit
|
|
import SwiftyGif
|
|
import LinkPresentation
|
|
|
|
public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? {
|
|
if let file = file, file.loaded {
|
|
return file.fileSource
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func getLoadedImage(_ file: CIFile?) -> UIImage? {
|
|
if let fileSource = getLoadedFileSource(file) {
|
|
let filePath = getAppFilePath(fileSource.filePath)
|
|
do {
|
|
let data = try getFileData(filePath, fileSource.cryptoArgs)
|
|
let img = UIImage(data: data)
|
|
do {
|
|
try img?.setGifFromData(data, levelOfIntegrity: 1.0)
|
|
return img
|
|
} catch {
|
|
return UIImage(data: data)
|
|
}
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func getFileData(_ path: URL, _ cfArgs: CryptoFileArgs?) throws -> Data {
|
|
if let cfArgs = cfArgs {
|
|
return try readCryptoFile(path: path.path, cryptoArgs: cfArgs)
|
|
} else {
|
|
return try Data(contentsOf: path)
|
|
}
|
|
}
|
|
|
|
public func getLoadedVideo(_ file: CIFile?) -> URL? {
|
|
if let fileSource = getLoadedFileSource(file) {
|
|
let filePath = getAppFilePath(fileSource.filePath)
|
|
if FileManager.default.fileExists(atPath: filePath.path) {
|
|
return filePath
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func saveAnimImage(_ image: UIImage) -> CryptoFile? {
|
|
let fileName = generateNewFileName("IMG", "gif")
|
|
guard let imageData = image.imageData else { return nil }
|
|
return saveFile(imageData, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
|
}
|
|
|
|
public func saveImage(_ uiImage: UIImage) -> CryptoFile? {
|
|
let hasAlpha = imageHasAlpha(uiImage)
|
|
let ext = hasAlpha ? "png" : "jpg"
|
|
if let imageDataResized = resizeImageToDataSize(uiImage, maxDataSize: MAX_IMAGE_SIZE, hasAlpha: hasAlpha) {
|
|
let fileName = generateNewFileName("IMG", ext)
|
|
return saveFile(imageDataResized, fileName, encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
public func saveImageFile(_ url: URL) -> CryptoFile? {
|
|
var cPath = url.path.cString(using: .utf8)!
|
|
guard let data = Data(base64Encoded: fromCString(chat_resize_image_file(&cPath, Int32(MAX_IMAGE_SIZE), -1)!)), !data.isEmpty else { return nil }
|
|
let ext = data.starts(with: [0x89, 0x50, 0x4E, 0x47]) ? "png" : "jpg"
|
|
return saveFile(data, generateNewFileName("IMG", ext), encrypted: privacyEncryptLocalFilesGroupDefault.get())
|
|
}
|
|
|
|
public func resizeImageFileToStrSize(_ url: URL, maxDataSize: Int64) -> String? {
|
|
var cPath = url.path.cString(using: .utf8)!
|
|
let str = fromCString(chat_resize_image_file_uri(&cPath, Int32(maxDataSize))!)
|
|
return str.isEmpty ? nil : str
|
|
}
|
|
|
|
public func cropToSquare(_ image: UIImage) -> UIImage {
|
|
let size = image.size
|
|
let side = min(size.width, size.height)
|
|
let newSize = CGSize(width: side, height: side)
|
|
var origin = CGPoint.zero
|
|
if size.width > side {
|
|
origin.x -= (size.width - side) / 2
|
|
} else if size.height > side {
|
|
origin.y -= (size.height - side) / 2
|
|
}
|
|
return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size), hasAlpha: imageHasAlpha(image))
|
|
}
|
|
|
|
public func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int64, hasAlpha: Bool) -> Data? {
|
|
let data = resizeImageWithCore(image, hasAlpha: hasAlpha) {
|
|
chat_resize_image($0, $1, Int32(maxDataSize), hasAlpha ? 1 : 0)
|
|
}.flatMap { Data(base64Encoded: $0) }
|
|
logger.debug("resizeImageToDataSize final \(data?.count ?? 0)")
|
|
return data
|
|
}
|
|
|
|
public func resizeImageToStrSizeSync(_ image: UIImage, maxDataSize: Int64) -> String? {
|
|
let str = resizeImageWithCore(image, hasAlpha: imageHasAlpha(image)) {
|
|
chat_resize_image_uri($0, $1, Int32(maxDataSize))
|
|
}
|
|
logger.debug("resizeImageToStrSize final \(str?.count ?? 0)")
|
|
return str
|
|
}
|
|
|
|
public func resizeImageToStrSize(_ image: UIImage, maxDataSize: Int64) async -> String? {
|
|
resizeImageToStrSizeSync(image, maxDataSize: maxDataSize)
|
|
}
|
|
|
|
private func resizeImageWithCore(_ image: UIImage, hasAlpha: Bool, _ resize: (UnsafeMutablePointer<CChar>, Int32) -> UnsafeMutablePointer<CChar>?) -> String? {
|
|
let bounds = CGRect(origin: .zero, size: image.size)
|
|
let img = image.imageOrientation == .up ? image : resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha)
|
|
guard var data = hasAlpha ? img.pngData() : img.jpegData(compressionQuality: 0.85) else { return nil }
|
|
let str = data.withUnsafeMutableBytes { fromCString(resize($0.baseAddress!.assumingMemoryBound(to: CChar.self), Int32($0.count))!) }
|
|
return str.isEmpty ? nil : str
|
|
}
|
|
|
|
public func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = 1.0
|
|
format.opaque = !hasAlpha
|
|
return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in
|
|
image.draw(in: drawIn)
|
|
}
|
|
}
|
|
|
|
public func imageHasAlpha(_ img: UIImage) -> Bool {
|
|
if let cgImage = img.cgImage {
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
|
if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) {
|
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
|
if let data = context.data {
|
|
let data = data.assumingMemoryBound(to: UInt8.self)
|
|
let size = cgImage.width * cgImage.height * 4
|
|
var i = 0
|
|
while i < size {
|
|
if data[i] < 255 { return true }
|
|
i += 4
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Reduces image size, while consuming less RAM
|
|
///
|
|
/// Used by ShareExtension to downsize large images
|
|
/// before passing them to regular image processing pipeline
|
|
/// to avoid exceeding 120MB memory
|
|
///
|
|
/// - Parameters:
|
|
/// - url: Location of the image data
|
|
/// - size: Maximum dimension (width or height)
|
|
/// - Returns: Downsampled image or `nil`, if the image can't be located
|
|
public func downsampleImage(at url: URL, to size: Int64) -> UIImage? {
|
|
autoreleasepool {
|
|
if let source = CGImageSourceCreateWithURL(url as CFURL, nil) {
|
|
CGImageSourceCreateThumbnailAtIndex(
|
|
source,
|
|
0,
|
|
[
|
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
kCGImageSourceShouldCacheImmediately: true,
|
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
|
kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString
|
|
] as CFDictionary
|
|
)
|
|
.map { UIImage(cgImage: $0) }
|
|
} else { nil }
|
|
}
|
|
}
|
|
|
|
public func saveFileFromURL(_ url: URL) -> CryptoFile? {
|
|
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
|
let savedFile: CryptoFile?
|
|
if url.startAccessingSecurityScopedResource() {
|
|
do {
|
|
let fileName = uniqueCombine(url.lastPathComponent)
|
|
let toPath = getAppFilePath(fileName).path
|
|
if encrypted {
|
|
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: toPath)
|
|
savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
|
|
} else {
|
|
try FileManager.default.copyItem(atPath: url.path, toPath: toPath)
|
|
savedFile = CryptoFile.plain(fileName)
|
|
}
|
|
} catch {
|
|
logger.error("FileUtils.saveFileFromURL error: \(error.localizedDescription)")
|
|
savedFile = nil
|
|
}
|
|
} else {
|
|
logger.error("FileUtils.saveFileFromURL startAccessingSecurityScopedResource returned false")
|
|
savedFile = nil
|
|
}
|
|
url.stopAccessingSecurityScopedResource()
|
|
return savedFile
|
|
}
|
|
|
|
public func moveTempFileFromURL(_ url: URL) -> CryptoFile? {
|
|
do {
|
|
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
|
|
let fileName = uniqueCombine(url.lastPathComponent)
|
|
let savedFile: CryptoFile?
|
|
if encrypted {
|
|
let cfArgs = try encryptCryptoFile(fromPath: url.path, toPath: getAppFilePath(fileName).path)
|
|
try FileManager.default.removeItem(atPath: url.path)
|
|
savedFile = CryptoFile(filePath: fileName, cryptoArgs: cfArgs)
|
|
} else {
|
|
try FileManager.default.moveItem(at: url, to: getAppFilePath(fileName))
|
|
savedFile = CryptoFile.plain(fileName)
|
|
}
|
|
return savedFile
|
|
} catch {
|
|
logger.error("ImageUtils.moveTempFileFromURL error: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func saveWallpaperFile(url: URL) -> String? {
|
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", "jpg", fullPath: true))
|
|
do {
|
|
try FileManager.default.copyItem(atPath: url.path, toPath: destFile.path)
|
|
return destFile.lastPathComponent
|
|
} catch {
|
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func saveWallpaperFile(image: UIImage) -> String? {
|
|
let hasAlpha = imageHasAlpha(image)
|
|
let destFile = URL(fileURLWithPath: generateNewFileName(getWallpaperDirectory().path + "/" + "wallpaper", hasAlpha ? "png" : "jpg", fullPath: true))
|
|
guard let dataResized = resizeImageToDataSize(image, maxDataSize: 5_000_000, hasAlpha: hasAlpha) else { return nil }
|
|
do {
|
|
try dataResized.write(to: destFile)
|
|
return destFile.lastPathComponent
|
|
} catch {
|
|
logger.error("FileUtils.saveWallpaperFile error: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public func removeWallpaperFile(fileName: String? = nil) {
|
|
do {
|
|
try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: getWallpaperDirectory().path), includingPropertiesForKeys: nil, options: []).forEach { url in
|
|
if url.lastPathComponent == fileName {
|
|
try FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
} catch {
|
|
logger.error("FileUtils.removeWallpaperFile error: \(error)")
|
|
}
|
|
if let fileName {
|
|
WallpaperType.cachedImages.removeValue(forKey: fileName)
|
|
}
|
|
}
|
|
|
|
public func removeWallpaperFilesFromTheme(_ theme: ThemeModeOverrides?) {
|
|
if let theme {
|
|
removeWallpaperFile(fileName: theme.light?.wallpaper?.imageFile)
|
|
removeWallpaperFile(fileName: theme.dark?.wallpaper?.imageFile)
|
|
}
|
|
}
|
|
|
|
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
|
|
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
|
|
}
|
|
|
|
private func uniqueCombine(_ fileName: String, fullPath: Bool = false) -> String {
|
|
func tryCombine(_ fileName: String, _ n: Int) -> String {
|
|
let ns = fileName as NSString
|
|
let name = ns.deletingPathExtension
|
|
let ext = ns.pathExtension
|
|
let suffix = (n == 0) ? "" : "_\(n)"
|
|
let f = ext.isEmpty ? "\(name)\(suffix)" : "\(name)\(suffix).\(ext)"
|
|
return (FileManager.default.fileExists(atPath: fullPath ? f : getAppFilePath(f).path)) ? tryCombine(fileName, n + 1) : f
|
|
}
|
|
return tryCombine(fileName, 0)
|
|
}
|
|
|
|
private var tsFormatter: DateFormatter?
|
|
|
|
private func getTimestamp() -> String {
|
|
var df: DateFormatter
|
|
if let tsFormatter = tsFormatter {
|
|
df = tsFormatter
|
|
} else {
|
|
df = DateFormatter()
|
|
df.dateFormat = "yyyyMMdd_HHmmss"
|
|
df.locale = Locale(identifier: "US")
|
|
df.timeZone = TimeZone(secondsFromGMT: 0)
|
|
tsFormatter = df
|
|
}
|
|
return df.string(from: Date())
|
|
}
|
|
|
|
public func dropImagePrefix(_ s: String) -> String {
|
|
dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,")
|
|
}
|
|
|
|
private func dropPrefix(_ s: String, _ prefix: String) -> String {
|
|
s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s
|
|
}
|
|
|
|
public func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
|
|
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
|
|
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
|
|
s.outputURL = outputUrl
|
|
s.outputFileType = .mp4
|
|
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
|
await s.export()
|
|
if let err = s.error {
|
|
logger.error("Failed to export video with error: \(err)")
|
|
}
|
|
return s.status == .completed
|
|
}
|
|
return false
|
|
}
|
|
|
|
extension AVAsset {
|
|
public func generatePreview() -> (UIImage, Int)? {
|
|
let generator = AVAssetImageGenerator(asset: self)
|
|
generator.appliesPreferredTrackTransform = true
|
|
var actualTime = CMTimeMake(value: 0, timescale: 0)
|
|
if let image = try? generator.copyCGImage(at: CMTimeMakeWithSeconds(0.0, preferredTimescale: 1), actualTime: &actualTime) {
|
|
return (UIImage(cgImage: image), Int(duration.seconds))
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
extension UIImage {
|
|
public func replaceColor(_ from: UIColor, _ to: UIColor) -> UIImage {
|
|
if let cgImage = cgImage {
|
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
|
if let context = CGContext(data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: 8, bytesPerRow: cgImage.width * 4, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) {
|
|
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
|
|
if let data = context.data {
|
|
var fromAlpha: CGFloat = 0
|
|
var fromRed: CGFloat = 0
|
|
var fromGreen: CGFloat = 0
|
|
var fromBlue: CGFloat = 0
|
|
var toAlpha: CGFloat = 0
|
|
var toRed: CGFloat = 0
|
|
var toGreen: CGFloat = 0
|
|
var toBlue: CGFloat = 0
|
|
from.getRed(&fromRed, green: &fromGreen, blue: &fromBlue, alpha: &fromAlpha)
|
|
to.getRed(&toRed, green: &toGreen, blue: &toBlue, alpha: &toAlpha)
|
|
let fAlpha = UInt8(UInt8(fromAlpha * 255))
|
|
let fRed = UInt8(fromRed * 255)
|
|
let fGreen = UInt8(fromGreen * 255)
|
|
let fBlue = UInt8(fromBlue * 255)
|
|
let tAlpha = UInt8(toAlpha * 255)
|
|
let tRed = UInt8(toRed * 255)
|
|
let tGreen = UInt8(toGreen * 255)
|
|
let tBlue = UInt8(toBlue * 255)
|
|
let data = data.assumingMemoryBound(to: UInt8.self)
|
|
let size = cgImage.width * cgImage.height * 4
|
|
var i = 0
|
|
while i < size {
|
|
if data[i] == fAlpha && data[i + 1] == fRed && data[i + 2] == fGreen && data[i + 3] == fBlue {
|
|
data[i + 0] = tAlpha
|
|
data[i + 1] = tRed
|
|
data[i + 2] = tGreen
|
|
data[i + 3] = tBlue
|
|
}
|
|
i += 4
|
|
}
|
|
}
|
|
if let img = context.makeImage() {
|
|
return UIImage(cgImage: img)
|
|
}
|
|
}
|
|
}
|
|
return self
|
|
}
|
|
}
|
|
|
|
// Max image height/width ratio for chat item display, taller images are cropped
|
|
public func heightRatio(_ size: CGSize) -> CGFloat {
|
|
size.width > 0 ? min(size.height / size.width, 2.33) : 1
|
|
}
|
|
|
|
public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
|
|
if let base64Encoded {
|
|
if let img = imageCache.object(forKey: base64Encoded as NSString) {
|
|
return img
|
|
} else if let data = Data(base64Encoded: dropImagePrefix(base64Encoded)),
|
|
let img = UIImage(data: data) {
|
|
imageCacheQueue.async {
|
|
imageCache.setObject(img, forKey: base64Encoded as NSString)
|
|
}
|
|
return img
|
|
} else {
|
|
return nil
|
|
}
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private let imageCacheQueue = DispatchQueue.global(qos: .background)
|
|
|
|
private var imageCache: NSCache<NSString, UIImage> = {
|
|
var cache = NSCache<NSString, UIImage>()
|
|
cache.countLimit = 1000
|
|
return cache
|
|
}()
|
|
|
|
public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
|
|
logger.debug("getLinkMetadata: fetching URL preview")
|
|
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
|
|
if let e = error {
|
|
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
|
|
}
|
|
if let metadata = metadata,
|
|
let imageProvider = metadata.imageProvider,
|
|
imageProvider.canLoadObject(ofClass: UIImage.self) {
|
|
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
|
|
var linkPreview: LinkPreview? = nil
|
|
if let error = error {
|
|
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
|
|
} else {
|
|
if let image = object as? UIImage,
|
|
let resized = resizeImageToStrSizeSync(image, maxDataSize: 14000),
|
|
let title = metadata.title,
|
|
let uri = metadata.originalURL {
|
|
linkPreview = LinkPreview(uri: uri.absoluteString, title: title, image: resized)
|
|
}
|
|
}
|
|
cb(linkPreview)
|
|
}
|
|
} else {
|
|
logger.error("Could not load link preview image")
|
|
cb(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func getLinkPreview(for url: URL) async -> LinkPreview? {
|
|
await withCheckedContinuation { cont in
|
|
getLinkPreview(url: url) { cont.resume(returning: $0) }
|
|
}
|
|
}
|
|
|
|
private let squareToCircleRatio = 0.935
|
|
|
|
private let radiusFactor = (1 - squareToCircleRatio) / 50
|
|
|
|
@ViewBuilder public func clipProfileImage(_ img: Image, size: CGFloat, radius: Double, blurred: Bool = false) -> some View {
|
|
if radius >= 50 {
|
|
blurredFrame(img, size, blurred).clipShape(Circle())
|
|
} else if radius <= 0 {
|
|
let sz = size * squareToCircleRatio
|
|
blurredFrame(img, sz, blurred).padding((size - sz) / 2)
|
|
} else {
|
|
let sz = size * (squareToCircleRatio + radius * radiusFactor)
|
|
blurredFrame(img, sz, blurred)
|
|
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
|
|
.padding((size - sz) / 2)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func blurredFrame(_ img: Image, _ size: CGFloat, _ blurred: Bool) -> some View {
|
|
let v = img.resizable().frame(width: size, height: size)
|
|
if blurred {
|
|
v.blur(radius: size / 4)
|
|
} else {
|
|
v
|
|
}
|
|
}
|