ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

image-utils.ts 4.4 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. import { copy, remove } from 'fs-extra/esm'
  2. import { readFile, rename } from 'fs/promises'
  3. import { ColorActionName } from '@jimp/plugin-color'
  4. import { buildUUID, getLowercaseExtension } from '@peertube/peertube-node-utils'
  5. import { convertWebPToJPG, processGIF } from './ffmpeg/index.js'
  6. import { logger } from './logger.js'
  7. import type Jimp from 'jimp'
  8. export function generateImageFilename (extension = '.jpg') {
  9. return buildUUID() + extension
  10. }
  11. export async function processImage (options: {
  12. path: string
  13. destination: string
  14. newSize: { width: number, height: number }
  15. keepOriginal?: boolean // default false
  16. }) {
  17. const { path, destination, newSize, keepOriginal = false } = options
  18. const extension = getLowercaseExtension(path)
  19. if (path === destination) {
  20. throw new Error('Jimp/FFmpeg needs an input path different that the output path.')
  21. }
  22. logger.debug('Processing image %s to %s.', path, destination)
  23. // Use FFmpeg to process GIF
  24. if (extension === '.gif') {
  25. await processGIF({ path, destination, newSize })
  26. } else {
  27. await jimpProcessor({ path, destination, newSize, inputExt: extension })
  28. }
  29. if (keepOriginal !== true) await remove(path)
  30. logger.debug('Finished processing image %s to %s.', path, destination)
  31. }
  32. export async function getImageSize (path: string) {
  33. const inputBuffer = await readFile(path)
  34. const Jimp = await import('jimp')
  35. const image = await Jimp.default.read(inputBuffer)
  36. return {
  37. width: image.getWidth(),
  38. height: image.getHeight()
  39. }
  40. }
  41. // ---------------------------------------------------------------------------
  42. // Private
  43. // ---------------------------------------------------------------------------
  44. async function jimpProcessor (options: {
  45. path: string
  46. destination: string
  47. newSize: {
  48. width: number
  49. height: number
  50. }
  51. inputExt: string
  52. }) {
  53. const { path, destination, newSize, inputExt } = options
  54. let sourceImage: Jimp
  55. const inputBuffer = await readFile(path)
  56. const Jimp = await import('jimp')
  57. try {
  58. sourceImage = await Jimp.default.read(inputBuffer)
  59. } catch (err) {
  60. logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err })
  61. const newName = path + '.jpg'
  62. await convertWebPToJPG({ path, destination: newName })
  63. await rename(newName, path)
  64. sourceImage = await Jimp.default.read(path)
  65. }
  66. await remove(destination)
  67. // Optimization if the source file has the appropriate size
  68. const outputExt = getLowercaseExtension(destination)
  69. if (skipProcessing({ sourceImage, newSize, imageBytes: inputBuffer.byteLength, inputExt, outputExt })) {
  70. return copy(path, destination)
  71. }
  72. await autoResize({ sourceImage, newSize, destination })
  73. }
  74. async function autoResize (options: {
  75. sourceImage: Jimp
  76. newSize: { width: number, height: number }
  77. destination: string
  78. }) {
  79. const { sourceImage, newSize, destination } = options
  80. // Portrait mode targeting a landscape, apply some effect on the image
  81. const sourceIsPortrait = sourceImage.getWidth() <= sourceImage.getHeight()
  82. const destIsPortraitOrSquare = newSize.width <= newSize.height
  83. removeExif(sourceImage)
  84. if (sourceIsPortrait && !destIsPortraitOrSquare) {
  85. const baseImage = sourceImage.cloneQuiet().cover(newSize.width, newSize.height)
  86. .color([ { apply: ColorActionName.SHADE, params: [ 50 ] } ])
  87. const topImage = sourceImage.cloneQuiet().contain(newSize.width, newSize.height)
  88. return write(baseImage.blit(topImage, 0, 0), destination)
  89. }
  90. return write(sourceImage.cover(newSize.width, newSize.height), destination)
  91. }
  92. function write (image: Jimp, destination: string) {
  93. return image.quality(80).writeAsync(destination)
  94. }
  95. function skipProcessing (options: {
  96. sourceImage: Jimp
  97. newSize: { width: number, height: number }
  98. imageBytes: number
  99. inputExt: string
  100. outputExt: string
  101. }) {
  102. const { sourceImage, newSize, imageBytes, inputExt, outputExt } = options
  103. const { width, height } = newSize
  104. if (hasExif(sourceImage)) return false
  105. if (sourceImage.getWidth() !== width || sourceImage.getHeight() !== height) return false
  106. if (inputExt !== outputExt) return false
  107. const kB = 1000
  108. if (height >= 1000) return imageBytes <= 200 * kB
  109. if (height >= 500) return imageBytes <= 100 * kB
  110. return imageBytes <= 15 * kB
  111. }
  112. function hasExif (image: Jimp) {
  113. return !!(image.bitmap as any).exifBuffer
  114. }
  115. function removeExif (image: Jimp) {
  116. (image.bitmap as any).exifBuffer = null
  117. }