diff --git a/.gitignore b/.gitignore index a3b5ad7..47fb82e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ temp/* # ignore files in folder uploads/* # except -!uploads/README.md \ No newline at end of file +!uploads/README.md diff --git a/client/src/pages/GroupPhoto/GroupPhoto.js b/client/src/pages/GroupPhoto/GroupPhoto.js index 24a2b24..ebcbdbd 100644 --- a/client/src/pages/GroupPhoto/GroupPhoto.js +++ b/client/src/pages/GroupPhoto/GroupPhoto.js @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react' import Button from '../../components/Button' import Page from '../../components/Page' import { downloadFromS3 } from '../../utils/download' +import { onWsEvent } from '../../websockets' import './GroupPhoto.css' const GroupPhoto = () => { @@ -11,15 +12,12 @@ const GroupPhoto = () => { const createGroupPhoto = async () => { setIsGenerating(true) - const res = await fetch('/createGroupPhoto', { + fetch('/createGroupPhoto', { method: 'POST', headers: { 'Content-Type': 'application/json', }, }) - const json = await res.json() - setFile(json) - setIsGenerating(false) } const getGroupPhoto = async () => { @@ -36,6 +34,14 @@ const GroupPhoto = () => { useEffect(() => { getGroupPhoto() + onWsEvent('group-photo', (data) => { + if (data.status === 200) { + setFile(data.data) + } else { + console.log(data.message, data.error) + } + setIsGenerating(false) + }) }, []) const header =

Create Group Photo

diff --git a/client/src/websockets.js b/client/src/websockets.js new file mode 100644 index 0000000..6d70d52 --- /dev/null +++ b/client/src/websockets.js @@ -0,0 +1,12 @@ +const HOST = window.location.origin.replace(/^http/, 'ws') + +if (process.env.NODE_ENV === 'development') HOST.replace('3000', '3001') + +export const ws = new WebSocket(HOST) + +export const onWsEvent = (eventId, callback) => { + ws.onmessage = (event) => { + const data = JSON.parse(event.data) + if (data.id === eventId) callback(data) + } +} diff --git a/package-lock.json b/package-lock.json index 6abfbca..596ebb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -468,6 +468,13 @@ "url": "0.10.3", "uuid": "3.3.2", "xml2js": "0.4.19" + }, + "dependencies": { + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + } } }, "axe-core": { @@ -476,14 +483,6 @@ "integrity": "sha512-5P0QZ6J5xGikH780pghEdbEKijCTrruK9KxtPZCFWUpef0f6GipO+xEZ5GKCb020mmqgbiNO6TcA55CriL784Q==", "dev": true }, - "axios": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.20.0.tgz", - "integrity": "sha512-ANA4rr2BDcmmAQLOKft2fufrtuvlqR+cXNNinUmvfeSNCOF98PZL+7M/v1zIdGo7OLjEA9J2gXJL+j4zGsl0bA==", - "requires": { - "follow-redirects": "^1.10.0" - } - }, "axobject-query": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz", @@ -735,6 +734,15 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" }, + "bufferutil": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.1.tgz", + "integrity": "sha512-xowrxvpxojqkagPcWRQVXZl0YXhRhAtBEIq3VoER1NH5Mw1n1o0ojdspp+GS2J//2gCVyrzQDApQ4unGF+QOoA==", + "optional": true, + "requires": { + "node-gyp-build": "~3.7.0" + } + }, "busboy": { "version": "0.2.14", "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz", @@ -2101,11 +2109,6 @@ "which": "^1.1.1" } }, - "follow-redirects": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz", - "integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==" - }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -3419,6 +3422,12 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.0.0.tgz", "integrity": "sha512-sSHCgWfJ+Lui/u+0msF3oyCgvdkhxDbkCS6Q8uiJquzOimkJBvX6hl5aSSA7DR1XbMpdM8r7phjcF63sF4rkKg==" }, + "node-gyp-build": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.7.0.tgz", + "integrity": "sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w==", + "optional": true + }, "nodemon": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.4.tgz", @@ -4906,6 +4915,15 @@ "prepend-http": "^2.0.0" } }, + "utf-8-validate": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.2.tgz", + "integrity": "sha512-SwV++i2gTD5qh2XqaPzBnNX88N6HdyhQrNNRykvcS0QKvItV9u3vPEJr+X5Hhfb1JC0r0e1alL0iB09rY8+nmw==", + "optional": true, + "requires": { + "node-gyp-build": "~3.7.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4917,9 +4935,9 @@ "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" }, "uuid": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", - "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.0.tgz", + "integrity": "sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ==" }, "v8-compile-cache": { "version": "2.1.1", @@ -5082,6 +5100,11 @@ "typedarray-to-buffer": "^3.1.5" } }, + "ws": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz", + "integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==" + }, "xdg-basedir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", diff --git a/package.json b/package.json index ebeca62..382acbf 100644 --- a/package.json +++ b/package.json @@ -17,14 +17,15 @@ "license": "ISC", "dependencies": { "aws-sdk": "^2.729.0", - "axios": "^0.20.0", "concurrently": "^5.2.0", "dotenv": "^8.2.0", "express": "^4.17.1", "express-basic-auth": "^1.2.0", "fluent-ffmpeg": "^2.1.2", "multer": "^1.4.2", - "sharp": "^0.26.0" + "sharp": "^0.26.0", + "uuid": "^8.3.0", + "ws": "^7.3.1" }, "devDependencies": { "babel-eslint": "^10.0.1", @@ -52,5 +53,9 @@ "eslint --fix", "prettier --write" ] + }, + "optionalDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" } } diff --git a/server/server.js b/server/server.js index 7caf4e8..6e7cec6 100644 --- a/server/server.js +++ b/server/server.js @@ -1,3 +1,4 @@ +/* eslint-disable-next-line global-require */ if (process.env.NODE_ENV !== 'production') require('dotenv').config() const express = require('express') @@ -7,10 +8,15 @@ const multer = require('multer') const path = require('path') const fs = require('fs') const AWS = require('aws-sdk') +const { Server } = require('ws') +const { v4: uuidv4 } = require('uuid') const config = require('../config') -const { createGroupPhotoStream } = require('./utils/group-photo') +const { + outputGifToJpeg, + createGroupPhotoStream, +} = require('./utils/group-photo') -const makeFileLocation = (file) => +const getFileLocation = (file) => `https://${config.AWS_BUCKET_NAME}.s3.amazonaws.com/${file.Key}` const s3 = new AWS.S3({ @@ -35,9 +41,10 @@ const storage = multer.diskStorage({ cb(null, './uploads/') }, filename(req, file, cb) { - let filename = `${Date.now()}.${file.originalname.split('.')[1]}` + const id = uuidv4() + let filename = `${id}.${file.originalname.split('.')[1]}` if (req.path === '/uploadBlob') { - filename = `${Date.now()}.webm` + filename = `${id}.webm` } cb(null, filename) }, @@ -46,11 +53,12 @@ const storage = multer.diskStorage({ const upload = multer({ storage }) const GREETING_PREFIX = 'public/gifs/greeting-' +const PHOTO_PREFIX = 'public/photos/photo-' -const listGifs = async () => { +const listByPrefix = async (Prefix) => { const params = { Bucket: config.AWS_BUCKET_NAME, - Prefix: GREETING_PREFIX, + Prefix, } const getAllContents = async (PrevContents = [], NextContinuationToken) => { @@ -72,90 +80,64 @@ const listGifs = async () => { const OrderedContents = Contents.map((file) => ({ ...file, - Location: makeFileLocation(file), - })).reverse() + Location: getFileLocation(file), + })).sort((a, b) => b.LastModified - a.LastModified) return OrderedContents } app.get('/listGifs', async (_, res) => { try { - const result = await listGifs() + const result = await listByPrefix(GREETING_PREFIX) res.send(result) } catch (e) { console.log(e) } }) -const groupPhotoPath = 'public/group_photo.jpeg' - -app.post('/getGroupPhoto', async (_, res) => { - const params = { - Bucket: config.AWS_BUCKET_NAME, - Prefix: groupPhotoPath, - } - const result = await s3.listObjects(params).promise() - result.Contents = result.Contents.map((file) => ({ - ...file, - Location: makeFileLocation(file), - })) - res.send(result) -}) - -app.post('/createGroupPhoto', async (_, res) => { +const uploadGIF = async (res, filename, folderName, onSuccess = () => {}) => { + const filepath = `${folderName}/${filename}.gif` try { - const result = await listGifs() - const urls = result.map((file) => makeFileLocation(file)) - const stream = await createGroupPhotoStream(urls) - if (!stream) return + const fileStream = fs.createReadStream(filepath) + const GifKey = `${GREETING_PREFIX}${filename}.gif` + const PhotoKey = `${PHOTO_PREFIX}${filename}.jpeg` + const params = { - Key: groupPhotoPath, + Key: GifKey, Bucket: config.AWS_BUCKET_NAME, - Body: stream, - ContentType: 'image/png', + Body: fileStream, + ContentType: 'image/gif', ACL: 'public-read', } - s3.upload(params, (err, data) => { - if (err) { - console.log(err, err.stack) - } else { - console.log(`Group Photo Uploaded to s3: ${groupPhotoPath}`) - data.LastModified = Date.now() - res.send(data) - } - }) - } catch (e) { - console.log(e) - } -}) + const data = await s3.upload(params).promise() + console.log('Uploaded user gif to', data.Location) -const uploadGIF = async (res, filename, folderName, onSuccess) => { - const filepath = `${folderName}/${filename}.gif` - const fileStream = fs.createReadStream(filepath) + res.send(data) + onSuccess() - const params = { - Key: `${GREETING_PREFIX}${Date.now()}.gif`, - Bucket: config.AWS_BUCKET_NAME, - Body: fileStream, - ContentType: 'image/gif', - ACL: 'public-read', - } + const jpegPath = await outputGifToJpeg(filepath) + const jpegStream = fs.createReadStream(jpegPath) - await s3 - .upload(params) - .promise() - .then((data) => { - console.log('s3.upload', data) - res.send(data) - fs.unlink(filepath, () => - console.log(`${filepath} was deleted after upload`), - ) - if (onSuccess) onSuccess() - }) - .catch((e) => { - console.log(e, e.stack) - res.status(500).send(e) - }) + // upload the middle page of the gif to s3 as a JPEG to enable faster processing of group photo + await s3 + .upload({ + ...params, + Key: PhotoKey, + Body: jpegStream, + ContentType: 'image/jpeg', + }) + .promise() + + fs.unlink(filepath, () => + console.log(`${filepath} was deleted after upload`), + ) + fs.unlink(jpegPath, () => + console.log(`${jpegPath} was deleted after upload`), + ) + } catch (e) { + console.log(e, e.stack, filepath) + res.status(500).send(e) + } } app.post('/uploadUserGIF', upload.single('gif'), async (req, res) => { @@ -174,9 +156,8 @@ app.post('/uploadUserGIF', upload.single('gif'), async (req, res) => { app.post('/uploadGIF', ({ body }, res) => { const { filename } = body uploadGIF(res, filename, 'temp', () => { - fs.unlink(`uploads/${filename}.webm`, () => - console.log('.webm file was deleted'), - ) + const filepath = `uploads/${filename}.webm` + fs.unlink(filepath, () => console.log(`${filepath} was deleted`)) }) }) @@ -208,7 +189,7 @@ app.post('/video2gif', upload.none(), ({ body }, res) => { res.send(body) }) .on('error', (err) => { - console.log(`an error happened: ${err.message}`) + console.log(`The following error occured: ${err.message}`) res.send(err) }) .save(`temp/${videoId}.gif`) @@ -237,7 +218,7 @@ app.get('/download', (req, res) => { const filepath = `temp/${filename}.gif` res.download(filepath, (err) => { if (err) console.log(err) - console.log('Your file has been downloaded!') + console.log(`User downloaded ${filepath}`) }) }) @@ -272,6 +253,85 @@ app.get('/*', (req, res) => res.sendFile(path.join(__dirname, '../client/build', 'index.html')), ) -app.listen(app.get('port'), () => { +const server = app.listen(app.get('port'), () => { console.log(`Find the server at: http://localhost:${app.get('port')}/`) }) + +const wss = new Server({ server }) + +const groupPhotoPath = 'public/group_photo.jpeg' + +app.post('/getGroupPhoto', async (_, res) => { + const params = { + Bucket: config.AWS_BUCKET_NAME, + Prefix: groupPhotoPath, + } + const result = await s3.listObjects(params).promise() + result.Contents = result.Contents.map((file) => ({ + ...file, + Location: getFileLocation(file), + })) + res.send(result) +}) + +const fetchImageBuffer = (image) => { + const params = { Bucket: config.AWS_BUCKET_NAME, Key: image.Key } + return new Promise((resolve, reject) => + s3.getObject(params, (error, result) => + error ? reject(error) : resolve(result.Body), + ), + ) +} + +app.post('/createGroupPhoto', async (req, res) => { + res.send({ message: 'Starting group photo processing' }) + try { + console.log('Starting group photo creation') + const images = await listByPrefix(PHOTO_PREFIX) + const buffers = await Promise.all( + images.map((image) => fetchImageBuffer(image)), + ) + console.log('Group photo input buffers fetched from s3') + const stream = await createGroupPhotoStream(buffers) + if (!stream) return + const params = { + Key: groupPhotoPath, + Bucket: config.AWS_BUCKET_NAME, + Body: stream, + ContentType: 'image/png', + ACL: 'public-read', + } + s3.upload(params, (err, data) => { + if (err) { + console.log(err, err.stack) + } else { + console.log(`Group photo uploaded to s3: ${groupPhotoPath}`) + const _data = { + ...data, + LastModified: Date.now(), + } + wss.clients.forEach((client) => { + client.send( + JSON.stringify({ + id: 'group-photo', + status: 200, + data: _data, + message: 'Group photo processed successfully', + }), + ) + }) + } + }) + } catch (e) { + wss.clients.forEach((client) => { + client.send( + JSON.stringify({ + id: 'group-photo', + status: 500, + message: 'Group photo processing failed', + error: e, + }), + ) + }) + } +}) diff --git a/server/utils/group-photo.js b/server/utils/group-photo.js index 2e8bee7..d1d03d9 100644 --- a/server/utils/group-photo.js +++ b/server/utils/group-photo.js @@ -1,5 +1,4 @@ const sharp = require('sharp') -const axios = require('axios') const fs = require('fs') const chunkArray = (array, size) => { @@ -9,23 +8,6 @@ const chunkArray = (array, size) => { return [firstChunk].concat(chunkArray(array.slice(size, array.length), size)) } -const fetchImg = async (url) => { - try { - const res = await axios({ - url, - responseType: 'arraybuffer', - }) - return res - } catch (e) { - return null - } -} - -const fetchImgs = async (imgs) => { - const fetched = await Promise.all(imgs.map(fetchImg)) - return fetched.filter((img) => img !== null) -} - const createImageLayout = (imgs) => { const aspectRatio = 1 + 1 / 3 @@ -72,41 +54,31 @@ const createImageLayout = (imgs) => { } } -const compositeGif = async (buffer, { width, height, top, left, id }) => { - const toSizedBuffer = (frame) => frame.resize(width, height).raw().toBuffer() - - let input - const firstFrame = await sharp(buffer) +const createJpegComposite = async (buffer, { width, height, top, left }) => { try { - const { pages } = await firstFrame.metadata() - const page = Math.round(pages / 2) || 0 - const middleFrame = await sharp(buffer, { page }) - input = await toSizedBuffer(middleFrame) + const resizedBuffer = await sharp(buffer) + .resize(width, height) + .raw() + .toBuffer({ resolveWithObject: true }) + + return { + input: resizedBuffer.data, + raw: resizedBuffer.info, + top, + left, + } } catch (e) { - // so far we have only seen faulty gifs cause this to catch - console.log( - 'Failed to slice middle GIF frame (or "page"), defaulting to first frame.', - { id }, - ) - input = await toSizedBuffer(firstFrame) - } - - return { - input, - raw: { width, height, channels: 4 }, - top, - left, + console.log('Image Resize Failed') + return null } } -const createGroupPhoto = async (urls) => { +const createGroupPhoto = async (buffers) => { const padding = 16 const brandingHeight = 80 const conferenceOutputPath = './temp/conference_logo.png' - const imgs = await fetchImgs(urls.slice(0, 80)) - - const layout = createImageLayout(imgs) + const layout = createImageLayout(buffers) await sharp('./branding/Logo.png') .resize(null, brandingHeight) @@ -125,12 +97,11 @@ const createGroupPhoto = async (urls) => { imgRows.map(async (row, i) => { const composites = await Promise.all( row.map((img) => - compositeGif(img.data.data, { + createJpegComposite(img.data, { top: 0, left: img.left, width: layout.imgWidth, height: layout.imgHeight, - id: img.data.config.url, }), ), ) @@ -142,7 +113,7 @@ const createGroupPhoto = async (urls) => { channels: 3, }, }) - .composite(composites) + .composite(composites.filter((c) => c)) .toBuffer({ resolveWithObject: true }) return { @@ -193,15 +164,15 @@ const createGroupPhoto = async (urls) => { const outputPath = './temp/group-photo.jpeg' -const createGroupPhotoStream = async (urls) => { +const createGroupPhotoStream = async (buffers) => { try { - const groupPhoto = await createGroupPhoto(urls) - console.log('Group Photo Processed') + const groupPhoto = await createGroupPhoto(buffers) + console.log('Group photo processed') const jpeg = await groupPhoto.jpeg({ quality: 75, }) await jpeg.toFile(outputPath) - console.log(`Group Photo Output to ${outputPath}`) + console.log(`Group photo output to ${outputPath}`) return fs.createReadStream(outputPath) } catch (e) { console.log(e) @@ -209,4 +180,25 @@ const createGroupPhotoStream = async (urls) => { } } -module.exports = { createGroupPhotoStream } +const outputGifToJpeg = async (path) => { + let frame + const firstPage = await sharp(path) + try { + const { pages } = await firstPage.metadata() + const page = Math.round(pages / 2) || 0 + const middlePage = await sharp(path, { page }) + frame = middlePage + } catch (e) { + // so far we have only seen faulty gifs cause this to catch + console.log( + 'Failed to slice middle GIF page, defaulting to first page:', + path, + ) + frame = firstPage + } + const filepath = path.replace('.gif', '.jpeg') + await frame.jpeg({ quality: 75 }).toFile(filepath) + return filepath +} + +module.exports = { outputGifToJpeg, createGroupPhotoStream } diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29