]>
Commit | Line | Data |
---|---|---|
1 | import { copy, readFile, remove, rename } from 'fs-extra' | |
2 | import Jimp, { read as jimpRead } from 'jimp' | |
3 | import { join } from 'path' | |
4 | import { getLowercaseExtension } from '@shared/core-utils' | |
5 | import { buildUUID } from '@shared/extra-utils' | |
6 | import { convertWebPToJPG, generateThumbnailFromVideo, processGIF } from './ffmpeg/ffmpeg-images' | |
7 | import { logger, loggerTagsFactory } from './logger' | |
8 | ||
9 | const lTags = loggerTagsFactory('image-utils') | |
10 | ||
11 | function generateImageFilename (extension = '.jpg') { | |
12 | return buildUUID() + extension | |
13 | } | |
14 | ||
15 | async function processImage (options: { | |
16 | path: string | |
17 | destination: string | |
18 | newSize: { width: number, height: number } | |
19 | keepOriginal?: boolean // default false | |
20 | }) { | |
21 | const { path, destination, newSize, keepOriginal = false } = options | |
22 | ||
23 | const extension = getLowercaseExtension(path) | |
24 | ||
25 | if (path === destination) { | |
26 | throw new Error('Jimp/FFmpeg needs an input path different that the output path.') | |
27 | } | |
28 | ||
29 | logger.debug('Processing image %s to %s.', path, destination) | |
30 | ||
31 | // Use FFmpeg to process GIF | |
32 | if (extension === '.gif') { | |
33 | await processGIF(path, destination, newSize) | |
34 | } else { | |
35 | await jimpProcessor(path, destination, newSize, extension) | |
36 | } | |
37 | ||
38 | if (keepOriginal !== true) await remove(path) | |
39 | } | |
40 | ||
41 | async function generateImageFromVideoFile (options: { | |
42 | fromPath: string | |
43 | folder: string | |
44 | imageName: string | |
45 | size: { width: number, height: number } | |
46 | }) { | |
47 | const { fromPath, folder, imageName, size } = options | |
48 | ||
49 | const pendingImageName = 'pending-' + imageName | |
50 | const pendingImagePath = join(folder, pendingImageName) | |
51 | ||
52 | try { | |
53 | await generateThumbnailFromVideo(fromPath, folder, imageName) | |
54 | ||
55 | const destination = join(folder, imageName) | |
56 | await processImage({ path: pendingImagePath, destination, newSize: size }) | |
57 | } catch (err) { | |
58 | logger.error('Cannot generate image from video %s.', fromPath, { err, ...lTags() }) | |
59 | ||
60 | try { | |
61 | await remove(pendingImagePath) | |
62 | } catch (err) { | |
63 | logger.debug('Cannot remove pending image path after generation error.', { err, ...lTags() }) | |
64 | } | |
65 | } | |
66 | } | |
67 | ||
68 | async function getImageSize (path: string) { | |
69 | const inputBuffer = await readFile(path) | |
70 | ||
71 | const image = await jimpRead(inputBuffer) | |
72 | ||
73 | return { | |
74 | width: image.getWidth(), | |
75 | height: image.getHeight() | |
76 | } | |
77 | } | |
78 | ||
79 | // --------------------------------------------------------------------------- | |
80 | ||
81 | export { | |
82 | generateImageFilename, | |
83 | generateImageFromVideoFile, | |
84 | ||
85 | processImage, | |
86 | ||
87 | getImageSize | |
88 | } | |
89 | ||
90 | // --------------------------------------------------------------------------- | |
91 | ||
92 | async function jimpProcessor (path: string, destination: string, newSize: { width: number, height: number }, inputExt: string) { | |
93 | let sourceImage: Jimp | |
94 | const inputBuffer = await readFile(path) | |
95 | ||
96 | try { | |
97 | sourceImage = await jimpRead(inputBuffer) | |
98 | } catch (err) { | |
99 | logger.debug('Cannot read %s with jimp. Try to convert the image using ffmpeg first.', path, { err }) | |
100 | ||
101 | const newName = path + '.jpg' | |
102 | await convertWebPToJPG(path, newName) | |
103 | await rename(newName, path) | |
104 | ||
105 | sourceImage = await jimpRead(path) | |
106 | } | |
107 | ||
108 | await remove(destination) | |
109 | ||
110 | // Optimization if the source file has the appropriate size | |
111 | const outputExt = getLowercaseExtension(destination) | |
112 | if (skipProcessing({ sourceImage, newSize, imageBytes: inputBuffer.byteLength, inputExt, outputExt })) { | |
113 | return copy(path, destination) | |
114 | } | |
115 | ||
116 | await autoResize({ sourceImage, newSize, destination }) | |
117 | } | |
118 | ||
119 | async function autoResize (options: { | |
120 | sourceImage: Jimp | |
121 | newSize: { width: number, height: number } | |
122 | destination: string | |
123 | }) { | |
124 | const { sourceImage, newSize, destination } = options | |
125 | ||
126 | // Portrait mode targeting a landscape, apply some effect on the image | |
127 | const sourceIsPortrait = sourceImage.getWidth() < sourceImage.getHeight() | |
128 | const destIsPortraitOrSquare = newSize.width <= newSize.height | |
129 | ||
130 | removeExif(sourceImage) | |
131 | ||
132 | if (sourceIsPortrait && !destIsPortraitOrSquare) { | |
133 | const baseImage = sourceImage.cloneQuiet().cover(newSize.width, newSize.height) | |
134 | .color([ { apply: 'shade', params: [ 50 ] } ]) | |
135 | ||
136 | const topImage = sourceImage.cloneQuiet().contain(newSize.width, newSize.height) | |
137 | ||
138 | return write(baseImage.blit(topImage, 0, 0), destination) | |
139 | } | |
140 | ||
141 | return write(sourceImage.cover(newSize.width, newSize.height), destination) | |
142 | } | |
143 | ||
144 | function write (image: Jimp, destination: string) { | |
145 | return image.quality(80).writeAsync(destination) | |
146 | } | |
147 | ||
148 | function skipProcessing (options: { | |
149 | sourceImage: Jimp | |
150 | newSize: { width: number, height: number } | |
151 | imageBytes: number | |
152 | inputExt: string | |
153 | outputExt: string | |
154 | }) { | |
155 | const { sourceImage, newSize, imageBytes, inputExt, outputExt } = options | |
156 | const { width, height } = newSize | |
157 | ||
158 | if (hasExif(sourceImage)) return false | |
159 | if (sourceImage.getWidth() > width || sourceImage.getHeight() > height) return false | |
160 | if (inputExt !== outputExt) return false | |
161 | ||
162 | const kB = 1000 | |
163 | ||
164 | if (height >= 1000) return imageBytes <= 200 * kB | |
165 | if (height >= 500) return imageBytes <= 100 * kB | |
166 | ||
167 | return imageBytes <= 15 * kB | |
168 | } | |
169 | ||
170 | function hasExif (image: Jimp) { | |
171 | return !!(image.bitmap as any).exifBuffer | |
172 | } | |
173 | ||
174 | function removeExif (image: Jimp) { | |
175 | (image.bitmap as any).exifBuffer = null | |
176 | } |