~mser/emote-server

3affc1823f3f847bd94a7de45a4c2619bc1d059c — Michael Serajnik 8 months ago d382e18
Add frozen emote generation support for APNGs
8 files changed, 93 insertions(+), 30 deletions(-)

M .env.example
M CHANGELOG.md
M README.md
M docker-compose.yml.example
M index.js
M package.json
M src/util/emotes.js
M yarn.lock
M .env.example => .env.example +1 -1
@@ 5,6 5,6 @@ EMOTE_SERVER_ACCESS_KEY=
EMOTE_SERVER_NUMBER_OF_WORKERS=

# Files
EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif
EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif,apng
EMOTE_SERVER_EMOTES_PATH=./emotes
EMOTE_SERVER_FROZEN_EMOTES_PATH=./frozen-emotes

M CHANGELOG.md => CHANGELOG.md +4 -0
@@ 8,6 8,10 @@ and this project adheres to

## [Unreleased]

### Added

+ Frozen emote generation support for APNGs

## [1.0.0] - 2021-03-29

### Added

M README.md => README.md +8 -8
@@ 27,7 27,7 @@ API.
        + [Deleting emotes](#deleting-emotes)
        + [Listing emotes](#listing-emotes)
        + [Getting emotes](#getting-emotes)
        + [Getting frozen GIF emotes](#getting-frozen-gif-emotes)
        + [Getting frozen emotes](#getting-frozen-emotes)
+ [Maintainer](#maintainer)
+ [Contribute](#contribute)
+ [License](#license)


@@ 144,9 144,9 @@ attention to the instructions to prevent issues.
  note that increasing the number of workers beyond the number of logical CPUs
  might be detrimental to performance or cause even more serious issues (e.g.,
  crashes).
+ `EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif`: sets the file extensions
  for the files the server should serve. The extensions need to be separated
  with `,`.
+ `EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif,apng`: sets the file
  extensions for the files the server should serve. The extensions need to be
  separated with `,`.
+ `EMOTE_SERVER_EMOTES_PATH=./emotes`: the path emotes are served from. Can be
  relative or absolute.



@@ 286,14 286,14 @@ __Possible errors:__
+ `AccessKeyError`
+ `GetError`

###### Getting frozen GIF emotes
###### Getting frozen emotes

Responds with a "frozen" PNG version (containing the first frame) of the
requested GIF emote.
requested GIF or APNG emote.

__Route:__ `GET /frozen-emotes/<GIF emote filename>`
__Route:__ `GET /frozen-emotes/<GIF/APNG emote filename>`

__Output on success:__ The requested frozen GIF emote
__Output on success:__ The requested frozen emote

__Possible errors:__


M docker-compose.yml.example => docker-compose.yml.example +1 -1
@@ 16,6 16,6 @@ services:
      - EMOTE_SERVER_PORT=8000
      - EMOTE_SERVER_ACCESS_KEY=
      - EMOTE_SERVER_NUMBER_OF_WORKERS=
      - EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif
      - EMOTE_SERVER_SUPPORTED_FILE_EXTENSIONS=png,gif,apng
      - EMOTE_SERVER_EMOTES_PATH=/data/emotes
      - EMOTE_SERVER_FROZEN_EMOTES_PATH=/data/frozen-emotes

M index.js => index.js +1 -1
@@ 195,7 195,7 @@ service.get('/frozen-emotes/:emote', async (req, res) => {
    }
  }

  if (!await emotes.isGifEmote(req.params.emote)) {
  if (!await emotes.isAnimatedEmote(req.params.emote)) {
    return res.send({
      success: false,
      error: 'GetError'

M package.json => package.json +2 -0
@@ 19,8 19,10 @@
    "node": ">=14.0.0 <16.0.0"
  },
  "dependencies": {
    "apng-js": "^1.1.1",
    "body-parser": "^1.19.0",
    "connect-query": "^1.0.0",
    "cross-blob": "^2.0.0",
    "dotenv": "^8.2.0",
    "express-fileupload": "^1.2.1",
    "fast-glob": "^3.2.5",

M src/util/emotes.js => src/util/emotes.js +47 -13
@@ 4,6 4,9 @@ const fsp = fs.promises
const fg = require('fast-glob')
const FileType = require('file-type')
const gifFrames = require('gif-frames')
// apng-js requires a Blob polyfill; this is sadly not mentioned in the docs
globalThis.Blob = require('cross-blob')
const parseApng = require('apng-js').default

const config = require('../config')



@@ 80,7 83,7 @@ module.exports = {
      }
    }

    const isGifEmote = await this.isGifEmote(fileName)
    const isAnimatedEmote = await this.isAnimatedEmote(fileName)

    try {
      await fsp.unlink(filePath)


@@ 92,7 95,7 @@ module.exports = {
      }
    }

    if (isGifEmote) {
    if (isAnimatedEmote) {
      const frozenFilePath = `${config.frozenEmotesPath}/${fileName}.png`

      if (await this.frozenEmoteExists(fileName)) {


@@ 120,14 123,19 @@ module.exports = {
      code: 200
    }
  },
  async isGifEmote (fileName) {
  async getMimeType (filePath) {
    await fsp.access(filePath, fs.constants.F_OK)
    const fileType = await FileType.fromFile(filePath)

    return fileType.mime
  },
  async isAnimatedEmote (fileName) {
    const filePath = `${config.emotesPath}/${fileName}`

    try {
      await fsp.access(filePath, fs.constants.F_OK)
      const fileType = await FileType.fromFile(filePath)
      const mimeType = await this.getMimeType(filePath)

      if (!fileType || fileType.mime !== 'image/gif') {
      if (!mimeType || !['image/gif', 'image/apng'].includes(mimeType)) {
        return false
      }
    } catch {


@@ 155,14 163,34 @@ module.exports = {
      return true
    }

    let frameData
    let mimeType

    try {
      frameData = await gifFrames({
        url: filePath,
        frames: 0,
        outputType: 'png'
      })
      mimeType = await this.getMimeType(filePath)

      if (!mimeType || !['image/gif', 'image/apng'].includes(mimeType)) {
        return false
      }
    } catch {
      return false
    }

    let frameDataStream

    try {
      switch (mimeType) {
        case 'image/gif':
          frameDataStream = (await gifFrames({
            url: filePath,
            frames: 0,
            outputType: 'png'
          }))[0].getImage()
          break
        case 'image/apng':
          frameDataStream = (parseApng(
            await fsp.readFile(filePath)
          )).frames[0].imageData.stream()
      }
    } catch {
      return false
    }


@@ 176,7 204,13 @@ module.exports = {
            .on('finish', () => resolve(true))
            .on('error', () => reject(false))

          frameData[0].getImage().pipe(ws)
            switch (mimeType) {
              case 'image/gif':
                frameDataStream.pipe(ws)
                break
              case 'image/apng':
                frameDataStream.pipe(ws)
            }
        })
      })()


M yarn.lock => yarn.lock +29 -6
@@ 145,9 145,9 @@ ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4:
    uri-js "^4.2.2"

ajv@^8.0.1:
  version "8.0.1"
  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.0.1.tgz#dac101898a87f8ebb57fea69617e8096523c628c"
  integrity sha512-46ZA4TalFcLLqX1dEU3dhdY38wAtDydJ4e7QQTVekLUTzXkb1LfqU6VOBXC/a9wiv4T094WURqJH6ZitF92Kqw==
  version "8.0.3"
  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.0.3.tgz#81f1b07003b329f000b7912e59a24f52392867b6"
  integrity sha512-Df6NAivu9KpZw+q8ySijAgLvr1mUA5ihkRvCLCxpdYR21ann5yIuN+PpFxmweSj7i3yjJ0x5LN5KVs0RRzskAQ==
  dependencies:
    fast-deep-equal "^3.1.1"
    json-schema-traverse "^1.0.0"


@@ 178,6 178,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
  dependencies:
    color-convert "^2.0.1"

apng-js@^1.1.1:
  version "1.1.1"
  resolved "https://registry.yarnpkg.com/apng-js/-/apng-js-1.1.1.tgz#d356544c44361af7e10fe105b7adee4bb60bd55d"
  integrity sha512-UWaloDssWCE8Bj0wipyNxEXPnMadYS0VAjghCLas5nKGqfiBMNdQJhg8Fawq2+jZ50IOM1feKwjiqPAC/bvKgg==

argparse@^1.0.7:
  version "1.0.10"
  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"


@@ 249,6 254,11 @@ bcrypt-pbkdf@^1.0.0:
  dependencies:
    tweetnacl "^0.14.3"

blob-polyfill@^4.0.20200601:
  version "4.0.20200601"
  resolved "https://registry.yarnpkg.com/blob-polyfill/-/blob-polyfill-4.0.20200601.tgz#16430e9e50d63e122c6aac18b31f5f0bc8c0bf92"
  integrity sha512-1jB6WOIp6IDxNyng5+9A8g/f0uiphib2ELCN+XAnlssinsW8s1k4VYG9b1TxIUd3pdm9RJSLQuBh6iohYmD4hA==

body-parser@^1.19.0:
  version "1.19.0"
  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"


@@ 392,6 402,14 @@ core-util-is@1.0.2, core-util-is@~1.0.0:
  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=

cross-blob@^2.0.0:
  version "2.0.0"
  resolved "https://registry.yarnpkg.com/cross-blob/-/cross-blob-2.0.0.tgz#a7cf1901c746b606ec31e0d04838d18888ea35a4"
  integrity sha512-NWzFuyG4GTZnM9MtQvjPYVlO12lZCSBdoHIHCZl9WKShsK3CqO+bEH7nuKwlVomlByb37XvT6nVY5uQxDBmk5Q==
  dependencies:
    blob-polyfill "^4.0.20200601"
    fetch-blob "^2.0.1"

cross-spawn@^7.0.2:
  version "7.0.3"
  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"


@@ 803,6 821,11 @@ fastq@^1.6.0:
  dependencies:
    reusify "^1.0.4"

fetch-blob@^2.0.1:
  version "2.1.1"
  resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-2.1.1.tgz#a54ab0d5ed7ccdb0691db77b6674308b23fb2237"
  integrity sha512-Uf+gxPCe1hTOFXwkxYyckn8iUSk6CFXGy5VENZKifovUTZC9eUODWSBhOBS7zICGrAetKzdwLMr85KhIcePMAQ==

file-entry-cache@^6.0.1:
  version "6.0.1"
  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"


@@ 2035,9 2058,9 @@ supports-color@^7.1.0:
    has-flag "^4.0.0"

table@^6.0.4:
  version "6.0.8"
  resolved "https://registry.yarnpkg.com/table/-/table-6.0.8.tgz#b63f35af8d90601de282a3292226007a9429644f"
  integrity sha512-OBAdezyozae8IvjHGXBDHByVkLCcsmffXUSj8LXkNb0SluRd4ug3GFCjk6JynZONIPhOkyr0Nnvbq1rlIspXyQ==
  version "6.0.9"
  resolved "https://registry.yarnpkg.com/table/-/table-6.0.9.tgz#790a12bf1e09b87b30e60419bafd6a1fd85536fb"
  integrity sha512-F3cLs9a3hL1Z7N4+EkSscsel3z55XT950AvB05bwayrNg5T1/gykXtigioTAjbltvbMSJvvhFCbnf6mX+ntnJQ==
  dependencies:
    ajv "^8.0.1"
    is-boolean-object "^1.1.0"