aboutsummaryrefslogtreecommitdiffhomepage
path: root/server/helpers/image-utils.ts
blob: 6e4a2b0003c0dc85e06e335f58ab6f29c09856af (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { copy, readFile, remove, rename } from 'fs-extra'
import Jimp, { read } from 'jimp'
import { join } from 'path'
import { getLowercaseExtension } from '@shared/core-utils'
import { buildUUID } from '@shared/extra-utils'
import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images'
import { logger, loggerTagsFactory } from './logger'

const lTags = loggerTagsFactory('image-utils')

function generateImageFilename (extension = '.jpg') {
  return buildUUID() + extension
}

async function processImage (
  path: string,
  destination: string,
  newSize: { width: number, height: number },
  keepOriginal = false
) {
  const extension = getLowercaseExtension(path)

  if (path === destination) {
    throw new Error('Jimp/FFmpeg needs an input path different that the output path.')
  }

  logger.debug('Processing image %s to %s.', path, destination)

  // Use FFmpeg to process GIF
  if (extension === '.gif') {
    await processGIF(path, destination, newSize)
  } else {
    await jimpProcessor(path, destination, newSize, extension)
  }

  if (keepOriginal !== true) await remove(path)
}

async function generateImageFromVideoFile (fromPath: string, folder: string, imageName: string, size: { width: number, height: number }) {
  const pendingImageName = 'pending-' + imageName
  const pendingImagePath = join(folder, pendingImageName)

  try {
    await generateThumbnailFromVideo(fromPath, folder, imageName)

    const destination = join(folder, imageName)
    await processImage(pendingImagePath, destination, size)
  } catch (err) {
    logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() })

    try {
      await remove(pendingImagePath)
    } catch (err) {
      logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() })
    }
  }
}

// ---------------------------------------------------------------------------

export {
  generateImageFilename,
  generateImageFromVideoFile,
  processImage
}

// ---------------------------------------------------------------------------

async function jimpProcessor (path: string, destination: string, newSize: { width: number, height: number }, inputExt: string) {
  let sourceImage: Jimp
  const inputBuffer = await readFile(path)

  try {
    sourceImage = await read(inputBuffer)
  } catch (err) {
    logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err })

    const newName = path + '.jpg'
    await convertWebPToJPG(path, newName)
    await rename(newName, path)

    sourceImage = await read(path)
  }

  await remove(destination)

  // Optimization if the source file has the appropriate size
  const outputExt = getLowercaseExtension(destination)
  if (skipProcessing({ sourceImage, newSize, imageBytes: inputBuffer.byteLength, inputExt, outputExt })) {
    return copy(path, destination)
  }

  await autoResize({ sourceImage, newSize, destination })
}

async function autoResize (options: {
  sourceImage: Jimp
  newSize: { width: number, height: number }
  destination: string
}) {
  const { sourceImage, newSize, destination } = options

  // Portrait mode targetting a landscape, apply some effect on the image
  const sourceIsPortrait = sourceImage.getWidth() < sourceImage.getHeight()
  const destIsPortraitOrSquare = newSize.width <= newSize.height

  if (sourceIsPortrait && !destIsPortraitOrSquare) {
    const baseImage = sourceImage.cloneQuiet().cover(newSize.width, newSize.height)
                                              .color([ { apply: 'shade', params: [ 50 ] } ])

    const topImage = sourceImage.cloneQuiet().contain(newSize.width, newSize.height)

    return write(baseImage.blit(topImage, 0, 0), destination)
  }

  return write(sourceImage.cover(newSize.width, newSize.height), destination)
}

function write (image: Jimp, destination: string) {
  return image.quality(80).writeAsync(destination)
}

function skipProcessing (options: {
  sourceImage: Jimp
  newSize: { width: number, height: number }
  imageBytes: number
  inputExt: string
  outputExt: string
}) {
  const { sourceImage, newSize, imageBytes, inputExt, outputExt } = options
  const { width, height } = newSize

  if (sourceImage.getWidth() > width || sourceImage.getHeight() > height) return false
  if (inputExt !== outputExt) return false

  const kB = 1000

  if (height >= 1000) return imageBytes <= 200 * kB
  if (height >= 500) return imageBytes <= 100 * kB

  return imageBytes <= 15 * kB
}