S3 + Lambda + ffmpeg (supports heic)

от автора

Достаточно частая задача веб разработчика — нарезать картинки. Предлагаю вашему вниманию готовое решение, используя Serverless framework + Lambda + S3.

Лямбда функция слушает событие загрузки файла в S3 и запускается. На входе функции мы можем извлечь имя S3 и ключ файла. Далее алгоритм такой: скачиваем файл в лямбду, нарезаем изображения и загружаем их в другой S3. Очень важно загружать в другой, потому что при загрузке в тот же S3 вызовется лямбда опять и будет нарезать уже нарезанные картинки (бесконечная рекурсия, за которую нужно будет заплатить). Это все в теории. Переходим к практике. 

Для нарезки изображений будем использовать ffmpeg. Вот пример как можно сделать изображение уменьшенное и обрезанное до 300х300px: 

ffmpeg -i source.jpg -filter:v "scale=300:-1,crop=300:300:0:0" -y crop300x300.jpg

Таким образом мы получили первый thumb:

Все замечательно работает локально, но в лямбде нет ffmpeg. Поэтому нужен бинарник для лямбды, который положим в лямбда слой. Чтобы запустить консольную команду из JavaScript воспользуемся child_process:

const childProcess = require('child_process') spawnPromise(command, argsarray, envOptions) {     return new Promise((resolve, reject) => {       const childProc = childProcess.spawn(command, argsarray, envOptions || { env: process.env, cwd: process.cwd() }),         resultBuffers = []       childProc.stdout.on('data', buffer => {         resultBuffers.push(buffer)       })       childProc.stderr.on('data', buffer => console.log(buffer.toString()))       childProc.on('exit', (code, signal) => {         if (code || signal) {           reject(`${command} failed with ${code || signal}`)         } else {           resolve(Buffer.concat(resultBuffers).toString().trim())         }       })     })   }

Также нам надо будет взять мета информацию из картинки: ширину, высоту, угол поворота (необходим, для правильного поворота картинки если делать снимок на телефон). Для этого воспользуемся ffprobe. Также, разместим бинарник в лямбда слое. И сделаем функцию, которая будет доставать эти данные:

async getResolution(inputFile) {     try {       let rotate = await this.spawnPromise(         config.FFPROBE,         [           '-v', 'error',           '-select_streams', 'v:0',           '-show_entries',           'stream_tags=rotate',           '-of', 'default=nw=1:nk=1',           inputFile],       )       rotate = rotate || 0       const str = await this.spawnPromise(         config.FFPROBE,         [           '-v', 'error',           '-select_streams', 'v:0',           '-show_entries',           'stream=width,height',           '-of', 'csv=s=x:p=0',           inputFile],       )        let [width, height] = String(str).split('x')        if (String(rotate) === '90' || String(rotate) === '-90' || String(rotate) === '270' || String(rotate) ===         '-270') {         [width, height] = [height, width]       }       return { width, height, rotate }     } catch (e) {       console.log('ffprobe error', e)       return { width: 1080, height: 1920 }     }   }

Теперь, зная мета информацию о файле, можно сделать основную функцию конвертации:

async thumb(inputFile, outputFile, filterData, imageData = {}) {     try {       const isVertical = Number(imageData.width) < Number(imageData.height)       let filter       if (filterData.crop) {         const newImageWidth = isVertical ? filterData.height : filterData.width         const newImageHeight = isVertical ? filterData.width : filterData.height         const resizeK = newImageWidth / imageData.width          const x = parseInt(isVertical ? 0 : (resizeK * imageData.width / 2 - newImageWidth / 2))         const y = parseInt(isVertical ? (resizeK * imageData.height / 2 - newImageHeight / 2) : 0)          if (newImageWidth === newImageHeight) {           // square           filter = `scale=${isVertical             ? `${newImageWidth}:-1`             : `-1:${newImageWidth}`},crop=${newImageWidth}:${newImageHeight}:${x}:${y}`         } else {           filter = `scale=${isVertical             ? `${newImageWidth}:-1`             : `-1:${newImageWidth}`},crop=${newImageWidth}:${newImageHeight}:${x}:${y}`         }       } else {         const newImageWidth = isVertical ? filterData.height : filterData.width         filter = `scale=${newImageWidth}:-1`       }        if (Number(imageData.rotate) !== 0) {         const times = Number(imageData.rotate) / 90         for (let i = 0; i < times; i++) {           filter += `,transpose=1`         }       }       const callData = [         '-v',         'error',         '-noautorotate',         '-i',         inputFile,         '-filter:v',         filter,         '-y',       ]       return await this.spawnPromise(         config.FFMPEG,         [           ...callData,           outputFile],       )     } catch (e) {       console.log('ffmpeg error', e)     }

Я это делал для одного из своих проектов и первая проблема, с которой столкнулись пользователи, это загрузка картинок из iPhone. Так как Apple сделали свой формат изображений, который еще не все поддерживают. Пока единственное решение, которое я нашел это скомпилировать tifig бинарник и добавить его в слой для лямбды. К сожалению, все бинарники я компилировал для лямбды давно и о том, как скомпилировать tifig для лямбды не расскажу, но зато поделюсь готовыми бинарниками.

И добавим функцию конвертации heic в jpg:

heicToJpg(filePath, newFilePath) {     return new Promise((resolve, reject) => {       childProcess.exec(se([config.TIFIG, filePath, newFilePath]), { encoding: 'utf8' },         function (error, stdout, stderr) {           if (error) {             console.error(stderr)             return reject(error)           }           fs.unlinkSync(filePath)           return resolve(true)         })     })   }

Итак, основной код лямбды на самом высоком уровне выглядит вот так:

 module.exports.handler = async (event) => {   const s3Record = event.Records[0].s3   const sourceKey = s3Record.object.key   if (!file.isPhoto(sourceKey)) {     return true   }   await file.download(s3Record.bucket.name, sourceKey,file.getDownloadName(sourceKey))   const ext = file.getExt(sourceKey)   const isHeic = file.isHeic(sourceKey)    // convert iphone photo format to jpg   if (isHeic) {     await convert.heicToJpg(file.getDownloadName(sourceKey), file.getDownloadNameIfHeic(sourceKey, ext))   }    // create thumbs by config.RESOLUTIONS   const { width, height, rotate } = await convert.getResolution(file.getDownloadNameIfHeic(sourceKey, ext))   await Promise.all(config.RESOLUTIONS.map(r => createThumbAndUpload(s3Record, r, { width, height, rotate, ext })))    // remove unnecessary files   await file.remove(file.getDownloadName(sourceKey))   if (isHeic) {     await file.remove(file.getDownloadNameIfHeic(sourceKey, ext))   }   return true }

При этом всем config.RESOLUTIONS это константа настройки, при помощи которой можно задать в какие форматы необходимо конвертировать изображения. Для примера я сделал несколько вариантов:

RESOLUTIONS: [   { name: '1920x1080', width: 1920, height: 1080 },   { name: '1280x720', width: 1280, height: 720 },   { name: 'crop600x400', crop: true, width: 600, height: 400 },   { name: 'crop300x300', crop: true, width: 300, height: 300 }, ]

Развернем все в облаке. Итак подготовим конфиг деплоя Serverless Framework с поясняющими коментариями:

service: service-lambda-thumb-tifig frameworkVersion: '2 || 3' app: lambda-thumb-tifig  package:   # исключаем лишние файлы из архива лямбды, чтоб уменьшить ее размер   patterns:     - '!node_modules/aws-sdk'     - '!node_modules/@aws-cdk'     - '!node_modules/serverless'     - '!node_modules/serverless-lift'     - '!.idea'     - '!yarn.lock'     - '!yarn.error.log'     - '!README.md'     - '!.gitignore'     - '!package.json'     - '!lib'     - '!.git'  custom:   name: 'lambda-thumb-tifig'   environment: 'prod'   region: 'us-east-1'   lambda_prefix: ${self:custom.environment}-${self:custom.name}  # Отмечу что я использовал авто создание S3 для демо.  # Но в реальности можно подключить уже раннее созданные S3. # этот конфиг создает новые S3 constructs:   sourceBucket:     type: storage   destinationBucket:     type: storage   provider:   name: aws   lambdaHashingVersion: '20201221'   environment:     DESTINATION_BUCKET: ${construct:destinationBucket.bucketName}     REGION: ${self:custom.region}   region: ${self:custom.region}   runtime: nodejs14.x   iamRoleStatements:     - Effect: "Allow"       Action:         - "s3:PutBucketNotification"         - "s3:GetObject"         - "s3:PutObject"       Resource:         Fn::Join:           - ""           - - "arn:aws:s3:::*"     - Effect: "Allow"       Action:         - "rds:*"       Resource: "*"  plugins:   - serverless-lift  functions:   # создаем лямбда-функцию   ffmpeg-tifig:     # Увеличиваем память и процессор лямбда функции      # для увеличения быстродействия     # такие лямбды будут дороже, но так как в этом проекте      # я не выхожу за бесплатные лимиты в месяц - буду использовать      # самый быстрый вариант     memorySize: 10240     name: ${self:custom.lambda_prefix}     handler: src/index.handler     # Увеличиваем максимальное время выполнения функции     timeout: 300     # Подключим функцию к собитию загрузки файла в S3 Source     events:       - s3:           bucket: ${construct:sourceBucket.bucketName}           existing: true     # Подключим слой Lib к лямбда-функции     layers:       - { Ref: LibLambdaLayer }  # Создадим лямбда слой и поместим в него все содержимое папки lib. layers:   lib:     path: lib 

Развернем это все в AWS:

sls deploy

В результате получаем лямбда функцию, подключенную к S3:

Проверим это все в AWS Console, загрузив несколько картинок в Source S3. И посмотрим в Destination S3:

Весь код доступен тут. Если у вас возникло желание каким-либо образом улучшить код — прошу сделать PR в этот репозиторий. Сделаем мир лучше вместе.


ссылка на оригинал статьи https://habr.com/ru/post/648519/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *