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