diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..9cf2b94 --- /dev/null +++ b/circle.yml @@ -0,0 +1,11 @@ +dependencies: + pre: + - sudo apt-get install libgif-dev + +machine: + node: + version: 6.7.0 + +test: + override: + - npm run lint diff --git a/js/constants/colors.js b/js/constants/colors.js new file mode 100644 index 0000000..1c22588 --- /dev/null +++ b/js/constants/colors.js @@ -0,0 +1,16 @@ +const head = { + brick: [0, 62, 53], + yellow: [48, 100, 64] +} +const bg = { + forest: '#006375', + green: '#63D6A3', + blue: '#358EFF', + salmon: '#FF9B7A', + coral: '#F96854' +} + +module.exports = { + head: head, + bg: bg +} diff --git a/js/fox.js b/js/fox.js index 7ff2ae9..e0d63b5 100644 --- a/js/fox.js +++ b/js/fox.js @@ -1,87 +1,91 @@ -const Chance = require('chance'); +const Chance = require('chance') +const colors = require('./constants/colors.js') const hsl = function (h, s, l) { - return "hsl(" + h + "," + s + "%, " + l + "%)"; + return 'hsl(' + h + ',' + s + '%, ' + l + '%)' } const Fox = function (IMG_WIDTH, IMG_HEIGHT, seed) { - if (seed) { - chance = new Chance(seed); - } else { - chance = new Chance(); - } + const chance = seed ? new Chance(seed) : new Chance() // origin: head top left corner const kappa = chance.floating({min: 0.2, max: 0.45}) - const hue = chance.integer({min: 5, max: 50}); - const saturation = chance.integer({min: 70, max: 90}); - const lightness = chance.integer({min: 40, max: 60}); + const headColor = (function () { + const level = chance.floating({min: 0, max: 1}) + const result = [] + const min = colors.head.brick + const max = colors.head.yellow + for (let i = 0; i < min.length; i++) { + result.push(min[i] + (max[i] - min[i]) * level) + } + return hsl.apply(null, result) + })() const head = { width: 0.6 * IMG_WIDTH, height: 0.6 * IMG_HEIGHT, kappa: kappa, - color: hsl(hue, saturation, lightness) + color: headColor } - const origin = {x: IMG_WIDTH / 2 - head.width / 2, y: 0.5 * IMG_HEIGHT - head.height / 2}; + const origin = {x: IMG_WIDTH / 2 - head.width / 2, y: 0.5 * IMG_HEIGHT - head.height / 2} const ears = (function (origin, headWidth, headHeight, headColor) { - const offsetX = chance.floating({min: 0.17 * headWidth, max: 0.2 * headWidth}); - const angle = chance.floating({min: 0.05 * Math.PI, max: 0.2 * Math.PI}); + const offsetX = chance.floating({min: 0.17 * headWidth, max: 0.2 * headWidth}) + const angle = chance.floating({min: 0.05 * Math.PI, max: 0.2 * Math.PI}) return { color: headColor, kappa: 0.9 * kappa, left: { - x: origin.x + (headWidth/2) - offsetX, + x: origin.x + (headWidth / 2) - offsetX, y: origin.y + (0.15 * headHeight), angle: angle, width: 0.4 * headWidth, height: 0.8 * headHeight }, right: { - x: origin.x + (headWidth/2) + offsetX, + x: origin.x + (headWidth / 2) + offsetX, y: origin.y + (0.15 * headHeight), angle: -angle, width: 0.4 * headWidth, height: 0.8 * headHeight } - }; - }(origin, head.width, head.height, head.color)); + } + }(origin, head.width, head.height, head.color)) const eyes = (function (origin, headWidth, headHeight) { // TODO: color - const offsetY = chance.floating({min: -0.05 * headHeight, max: -0.025 * headHeight}); - const offsetX = chance.floating({min: 0.13 * headWidth, max: 0.25 * headWidth}); + const offsetY = chance.floating({min: -0.05 * headHeight, max: -0.025 * headHeight}) + const offsetX = chance.floating({min: 0.13 * headWidth, max: 0.25 * headWidth}) - const eyeHeight = chance.floating({min: 0.08 * headHeight, max: 0.13 * headHeight}); + const eyeHeight = chance.floating({min: 0.08 * headHeight, max: 0.13 * headHeight}) return { height: eyeHeight, - width: eyeHeight/2, + width: eyeHeight / 2, style: 'ellipse', // style: chance.pickone(['ellipse', 'smiley']), left: { - x: origin.x + (headWidth/2) - offsetX, - y: origin.y + (headHeight/2) + offsetY + x: origin.x + (headWidth / 2) - offsetX, + y: origin.y + (headHeight / 2) + offsetY }, right: { - x: origin.x + (headWidth/2) + offsetX, - y: origin.y + (headHeight/2) + offsetY + x: origin.x + (headWidth / 2) + offsetX, + y: origin.y + (headHeight / 2) + offsetY } } - }(origin, head.width, head.height)); + }(origin, head.width, head.height)) const nose = { - x: origin.x + (head.width/2), + x: origin.x + (head.width / 2), y: (eyes.left.y + chance.floating({min: 0.2, max: 0.4}) * (origin.y + head.height - eyes.left.y)), width: chance.floating({min: 0.03, max: 0.04}) * head.width, height: chance.floating({min: 0.03, max: 0.04}) * head.width } const mouth = { - x: origin.x + (head.width/2), + x: origin.x + (head.width / 2), y: (nose.y + chance.floating({min: 0.2, max: 0.35}) * (origin.y + head.height - nose.y)), width: chance.floating({min: 0.08, max: 0.15}) * head.width, height: chance.floating({min: 0.03, max: 0.06}) * head.width, @@ -92,24 +96,20 @@ const Fox = function (IMG_WIDTH, IMG_HEIGHT, seed) { width: chance.floating({min: 0.5 * IMG_WIDTH, max: IMG_WIDTH}), height: chance.floating({min: 1.7 * (IMG_HEIGHT - eyes.left.y), max: 1.85 * (IMG_HEIGHT - eyes.left.y)}) } - head.mask = mask; + head.mask = mask return { canvas: { height: IMG_HEIGHT, width: IMG_WIDTH, - color: hsl( - chance.integer({min:0, max:360}), - chance.integer({min:0, max:100}), - chance.integer({min:10, max:100}) - ), + color: chance.pickone(Object.keys(colors.bg).map(function (key) { return colors.bg[key] })) }, head: head, ears: ears, eyes: eyes, nose: nose, mouth: mouth - }; -}; + } +} -module.exports = Fox; +module.exports = Fox diff --git a/js/render-fox.js b/js/render-fox.js index a244451..ee51983 100644 --- a/js/render-fox.js +++ b/js/render-fox.js @@ -1,151 +1,150 @@ const renderFox = function (canvas, opts) { - const width = opts.canvas.width; - const height = opts.canvas.height; - const ctx = canvas.getContext('2d'); + const width = opts.canvas.width + const height = opts.canvas.height + const ctx = canvas.getContext('2d') - ctx.fillStyle = opts.canvas.color; - ctx.fillRect(0, 0, width, height); - renderEars(ctx, opts.ears); - renderHead(ctx, opts.head); - renderEyes(ctx, opts.eyes); - renderNose(ctx, opts.nose); - renderMouth(ctx, opts.mouth); + ctx.fillStyle = opts.canvas.color + ctx.fillRect(0, 0, width, height) + renderEars(ctx, opts.ears) + renderHead(ctx, opts.head) + renderEyes(ctx, opts.eyes) + renderNose(ctx, opts.nose) + renderMouth(ctx, opts.mouth) - shift_canvas(ctx, width, height, 0, 0.06 * height); -}; - -function shift_canvas(ctx, w, h, dx, dy) { - const topImage = ctx.getImageData(0, 0, w, h); - const bottomImage = ctx.getImageData(0, h - dy, w, h); - - ctx.clearRect(0, 0, w, h); - ctx.putImageData(bottomImage, 0, 0); - ctx.putImageData(topImage, dx, dy); + shiftCanvas(ctx, width, height, 0, 0.06 * height) } -function renderHead(ctx, opts) { - ctx.save(); - ctx.translate(ctx.canvas.width/2, ctx.canvas.height/2); - ctx.rotate(Math.PI / 4); - drawEllipseByCenter(ctx, 0, 0, opts.width, opts.height, opts.color, null, opts.kappa); - ctx.restore(); - ctx.clip(); - drawEllipseByCenter(ctx, ctx.canvas.width / 2, ctx.canvas.height, opts.mask.width, opts.mask.height, '#fff', '#fff', 0.5); +function shiftCanvas (ctx, w, h, dx, dy) { + const topImage = ctx.getImageData(0, 0, w, h) + const bottomImage = ctx.getImageData(0, h - dy, w, h) + + ctx.clearRect(0, 0, w, h) + ctx.putImageData(bottomImage, 0, 0) + ctx.putImageData(topImage, dx, dy) } -function renderEars(ctx, opts) { - const offset = { - x: ctx.canvas.width/2, - y: ctx.canvas.height/2 - } - ctx.save(); - ctx.translate(offset.x, offset.y); - ctx.rotate(-opts.left.angle); - drawEllipseByCenter(ctx, opts.left.x - offset.x, opts.left.y - offset.y, opts.left.width, opts.left.height, opts.color, null, opts.kappa); - ctx.restore(); - - ctx.save(); - ctx.translate(offset.x, offset.y); - ctx.rotate(-opts.right.angle); - drawEllipseByCenter(ctx, opts.right.x - offset.x, opts.right.y - offset.y, opts.right.width, opts.right.height, opts.color, null, opts.kappa); - ctx.restore(); +function renderHead (ctx, opts) { + ctx.save() + ctx.translate(ctx.canvas.width / 2, ctx.canvas.height / 2) + ctx.rotate(Math.PI / 4) + drawEllipseByCenter(ctx, 0, 0, opts.width, opts.height, opts.color, null, opts.kappa) + ctx.restore() + ctx.clip() + drawEllipseByCenter(ctx, ctx.canvas.width / 2, ctx.canvas.height, opts.mask.width, opts.mask.height, '#fff', '#fff', 0.5) } -function renderEyes(ctx, opts) { - switch (opts.style) { - case "ellipse": - drawEllipseByCenter(ctx, opts.left.x, opts.left.y, opts.width, opts.height, "black", null, 0.5); - drawEllipseByCenter(ctx, opts.right.x, opts.right.y, opts.width, opts.height, "black", null, 0.5); - break; - case "smiley": - ctx.strokeStyle = "black"; - ctx.beginPath(); - ctx.moveTo(opts.left.x - opts.width, opts.left.y + opts.height); - ctx.bezierCurveTo(opts.left.x - opts.width, opts.left.y + opts.height, opts.left.x, opts.left.y, opts.left.x + opts.width, opts.left.y + opts.height); - ctx.lineWidth = 2; - ctx.stroke(); - ctx.closePath(); +function renderEars (ctx, opts) { + const offset = { + x: ctx.canvas.width / 2, + y: ctx.canvas.height / 2 + } + ctx.save() + ctx.translate(offset.x, offset.y) + ctx.rotate(-opts.left.angle) + drawEllipseByCenter(ctx, opts.left.x - offset.x, opts.left.y - offset.y, opts.left.width, opts.left.height, opts.color, null, opts.kappa) + ctx.restore() - ctx.beginPath(); - ctx.moveTo(opts.right.x - opts.width, opts.right.y + opts.height); - ctx.bezierCurveTo(opts.right.x - opts.width, opts.right.y + opts.height, opts.right.x, opts.right.y, opts.right.x + opts.width, opts.right.y + opts.height); - ctx.lineWidth = 2; - ctx.stroke(); - ctx.closePath(); - break; - case "none": - break; - } + ctx.save() + ctx.translate(offset.x, offset.y) + ctx.rotate(-opts.right.angle) + drawEllipseByCenter(ctx, opts.right.x - offset.x, opts.right.y - offset.y, opts.right.width, opts.right.height, opts.color, null, opts.kappa) + ctx.restore() } -function renderNose(ctx, opts) { - - ctx.strokeStyle = "black"; - ctx.beginPath(); - ctx.moveTo(opts.x - opts.width/2, opts.y - opts.height/2); - ctx.bezierCurveTo(opts.x - opts.width/2, opts.y - opts.height/2, opts.x, opts.y - opts.height, opts.x + opts.width/2, opts.y - opts.height/2); - ctx.bezierCurveTo(opts.x + opts.width/2, opts.y - opts.height/2, opts.x + opts.width/2, opts.y + opts.height/2, opts.x, opts.y + opts.height/2); - ctx.bezierCurveTo(opts.x, opts.y + opts.height/2, opts.x - opts.width/2, opts.y + opts.height/2, opts.x - opts.width/2, opts.y - opts.height/2); - ctx.fillStyle = "black"; - ctx.fill(); - ctx.stroke(); -} - -function renderMouth(ctx, opts) { - ctx.strokeStyle = "black"; - ctx.lineWidth = 0.01 * ctx.canvas.width; - ctx.beginPath(); +function renderEyes (ctx, opts) { switch (opts.style) { - case "smirk": - ctx.moveTo(opts.x - opts.width/2, opts.y - opts.height/2); - ctx.bezierCurveTo(opts.x - opts.width/2, opts.y - opts.height/2, - opts.x - opts.width/2, opts.y + opts.height/2, - opts.x + opts.width/2, opts.y + case 'ellipse': + drawEllipseByCenter(ctx, opts.left.x, opts.left.y, opts.width, opts.height, 'black', null, 0.5) + drawEllipseByCenter(ctx, opts.right.x, opts.right.y, opts.width, opts.height, 'black', null, 0.5) + break + case 'smiley': + ctx.strokeStyle = 'black' + ctx.beginPath() + ctx.moveTo(opts.left.x - opts.width, opts.left.y + opts.height) + ctx.bezierCurveTo(opts.left.x - opts.width, opts.left.y + opts.height, opts.left.x, opts.left.y, opts.left.x + opts.width, opts.left.y + opts.height) + ctx.lineWidth = 2 + ctx.stroke() + ctx.closePath() + + ctx.beginPath() + ctx.moveTo(opts.right.x - opts.width, opts.right.y + opts.height) + ctx.bezierCurveTo(opts.right.x - opts.width, opts.right.y + opts.height, opts.right.x, opts.right.y, opts.right.x + opts.width, opts.right.y + opts.height) + ctx.lineWidth = 2 + ctx.stroke() + ctx.closePath() + break + case 'none': + break + } +} + +function renderNose (ctx, opts) { + ctx.strokeStyle = 'black' + ctx.beginPath() + ctx.moveTo(opts.x - opts.width / 2, opts.y - opts.height / 2) + ctx.bezierCurveTo(opts.x - opts.width / 2, opts.y - opts.height / 2, opts.x, opts.y - opts.height, opts.x + opts.width / 2, opts.y - opts.height / 2) + ctx.bezierCurveTo(opts.x + opts.width / 2, opts.y - opts.height / 2, opts.x + opts.width / 2, opts.y + opts.height / 2, opts.x, opts.y + opts.height / 2) + ctx.bezierCurveTo(opts.x, opts.y + opts.height / 2, opts.x - opts.width / 2, opts.y + opts.height / 2, opts.x - opts.width / 2, opts.y - opts.height / 2) + ctx.fillStyle = 'black' + ctx.fill() + ctx.stroke() +} + +function renderMouth (ctx, opts) { + ctx.strokeStyle = 'black' + ctx.lineWidth = 0.01 * ctx.canvas.width + ctx.beginPath() + switch (opts.style) { + case 'smirk': + ctx.moveTo(opts.x - opts.width / 2, opts.y - opts.height / 2) + ctx.bezierCurveTo(opts.x - opts.width / 2, opts.y - opts.height / 2, + opts.x - opts.width / 2, opts.y + opts.height / 2, + opts.x + opts.width / 2, opts.y ) - break; - case "cat": - ctx.moveTo(opts.x - opts.width/2, opts.y + opts.height/2); - ctx.bezierCurveTo(opts.x - opts.width/2, opts.y + opts.height/2, - opts.x, opts.y + opts.height/2, - opts.x, opts.y - opts.height/2 + break + case 'cat': + ctx.moveTo(opts.x - opts.width / 2, opts.y + opts.height / 2) + ctx.bezierCurveTo(opts.x - opts.width / 2, opts.y + opts.height / 2, + opts.x, opts.y + opts.height / 2, + opts.x, opts.y - opts.height / 2 ) ctx.bezierCurveTo( - opts.x, opts.y - opts.height/2, - opts.x, opts.y + opts.height/2, - opts.x + opts.width/2, opts.y + opts.height/2 + opts.x, opts.y - opts.height / 2, + opts.x, opts.y + opts.height / 2, + opts.x + opts.width / 2, opts.y + opts.height / 2 ) - break; + break } - ctx.stroke(); + ctx.stroke() } -function drawEllipseByCenter(ctx, cx, cy, w, h, color, fillColor, kappa) { - drawEllipse(ctx, cx - w/2.0, cy - h/2.0, w, h, color, fillColor, kappa); +function drawEllipseByCenter (ctx, cx, cy, w, h, color, fillColor, kappa) { + drawEllipse(ctx, cx - w / 2.0, cy - h / 2.0, w, h, color, fillColor, kappa) } -function drawEllipse(ctx, x, y, w, h, color, fillColor, kappa=0.3) { - const ox = (w / 2) * kappa; // control point offset horizontal - const oy = (h / 2) * kappa; // control point offset vertical - const xe = x + w; // x-end - const ye = y + h; // y-end - const xm = x + w / 2; // x-middle - const ym = y + h / 2; // y-middle +function drawEllipse (ctx, x, y, w, h, color, fillColor, kappa = 0.3) { + const ox = (w / 2) * kappa // control point offset horizontal + const oy = (h / 2) * kappa // control point offset vertical + const xe = x + w // x-end + const ye = y + h // y-end + const xm = x + w / 2 // x-middle + const ym = y + h / 2 // y-middle if (color) { - ctx.strokeStyle = color; + ctx.strokeStyle = color } - ctx.beginPath(); - ctx.moveTo(x, ym); - ctx.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y); - ctx.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym); - ctx.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye); - ctx.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym); - fillColor = fillColor || color; + ctx.beginPath() + ctx.moveTo(x, ym) + ctx.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y) + ctx.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym) + ctx.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye) + ctx.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym) + fillColor = fillColor || color if (fillColor) { - ctx.fillStyle = fillColor; - ctx.fill(); + ctx.fillStyle = fillColor + ctx.fill() } - ctx.stroke(); + ctx.stroke() } -module.exports = renderFox; +module.exports = renderFox diff --git a/package.json b/package.json index 3fed837..e90d137 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Makes Fox Faces", "main": "server.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "mocha", + "lint": "standard" }, "repository": { "type": "git", @@ -25,5 +26,10 @@ "node-gyp": "^3.4.0", "sanitize-filename": "^1.6.1", "uuid": "^3.0.1" + }, + "devDependencies": { + "mocha": "^3.4.2", + "sharp": "^0.18.1", + "supertest": "^3.0.0" } } diff --git a/run.js b/run.js index b489db8..e189159 100644 --- a/run.js +++ b/run.js @@ -1,13 +1,13 @@ -const cluster = require('express-cluster'); -const app = require('./server.js'); +const cluster = require('express-cluster') +const app = require('./server.js') -const activePort = process.env.PORT || 3000; +const activePort = process.env.PORT || 3000 cluster((worker) => { - app.listen(activePort, () => { - console.log('worker ' + worker.id + ' is listening on port ' + activePort); - }); + app.listen(activePort, () => { + console.log('worker ' + worker.id + ' is listening on port ' + activePort) + }) }, { - 'respawn': true, // workers will restart on failure - 'verbose': true, // logs what happens to console -}); + 'respawn': true, // workers will restart on failure + 'verbose': true // logs what happens to console +}) diff --git a/server.js b/server.js index 912edb4..53bed90 100644 --- a/server.js +++ b/server.js @@ -1,42 +1,44 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "newrelic" }] */ + try { - const newrelic = require('newrelic'); + const newrelic = require('newrelic') } catch (e) { - console.error("WARNING unable to load newrelic") + console.error('WARNING unable to load newrelic') } -const express = require('express'); -const uuid = require('uuid/v4'); -const sanitize = require('sanitize-filename'); -const Canvas = require('canvas'); +const express = require('express') +const uuid = require('uuid/v4') +const sanitize = require('sanitize-filename') +const Canvas = require('canvas') -const Fox = require('./js/fox.js'); -const renderFox = require('./js/render-fox.js'); +const Fox = require('./js/fox.js') +const renderFox = require('./js/render-fox.js') -function composeImage(width, height, seed) { - seed = seed || uuid(); - const fox = Fox(width, height, seed); - const canvas = new Canvas(width, height); - renderFox(canvas, fox); - return canvas; +function composeImage (width, height, seed) { + seed = seed || uuid() + const fox = Fox(width, height, seed) + const canvas = new Canvas(width, height) + renderFox(canvas, fox) + return canvas }; -const cacheTimeout = 60 * 60 * 24 * 30; -const app = express(); +const cacheTimeout = 60 * 60 * 24 * 30 +const app = express() app.get('/healthcheck', (req, res) => { - res.status(200).end(); -}); + res.status(200).end() +}) app.get('/:width/:seed', (req, res) => { - let width = parseInt(req.params.width) || 400; - if (width > 400) width = 400; - const seed = sanitize(req.params.seed) || uuid(); - const canvas = composeImage(width, width, seed); - const buffer = canvas.toBuffer(); - res.set('Cache-Control', 'max-age=' + cacheTimeout); - res.set('Content-length', buffer.length); - res.type('png'); - res.end(buffer, 'binary'); -}); + let width = parseInt(req.params.width) || 400 + if (width > 400) width = 400 + const seed = sanitize(req.params.seed) || uuid() + const canvas = composeImage(width, width, seed) + const buffer = canvas.toBuffer() + res.set('Cache-Control', 'max-age=' + cacheTimeout) + res.set('Content-length', buffer.length) + res.type('png') + res.end(buffer, 'binary') +}) -module.exports = app; +module.exports = app diff --git a/test/test.js b/test/test.js new file mode 100644 index 0000000..5d809d6 --- /dev/null +++ b/test/test.js @@ -0,0 +1,49 @@ +const describe = require('mocha').describe +const it = require('mocha').it +const request = require('supertest') +const assert = require('assert') +const sharp = require('sharp') + +const app = require('../server') + +const testUID = 4125370 + +describe('Foxy-moxy', () => { + describe('fox generation', () => { + it('should respect widths < 400', (done) => { + const width = 158 + request(app) + .get(`/${width}/${testUID}`) + .expect('Content-Type', 'image/png') + .expect(200) + .end(function (err, res) { + assert(!err, String(err)) + sharp(res.body).metadata((err, metadata) => { + assert(!err, String(err)) + assert.equal(metadata.format, 'png') + assert.equal(metadata.height, width) + assert.equal(metadata.width, width) + done() + }) + }) + }) + + it('should allow max width of 400', (done) => { + const width = 510 + request(app) + .get(`/${width}/${testUID}`) + .expect('Content-Type', 'image/png') + .expect(200) + .end(function (err, res) { + assert(!err, String(err)) + sharp(res.body).metadata((err, metadata) => { + assert(!err, String(err)) + assert.equal(metadata.format, 'png') + assert.equal(metadata.height, 400) + assert.equal(metadata.width, 400) + done() + }) + }) + }) + }) +})