├── Procfile ├── .gitignore ├── package.json ├── src ├── mimetypes.js ├── settings.js ├── tempfile.js ├── main.js ├── util.js ├── UrlParser.js └── Server.js ├── static ├── img2x.html ├── utils.js └── quality.html ├── test └── UrlParserTest.js └── README.markdown /Procfile: -------------------------------------------------------------------------------- 1 | web: node src/main.js $PORT 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp/ 2 | img 3 | *.local.js 4 | *.log 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-resize-image", 3 | "version": "0.0.1", 4 | "description": "wanna resize", 5 | "repository": { 6 | "type": "git" 7 | }, 8 | "scripts": { 9 | "start": "node src/main.js" 10 | }, 11 | "engines": { 12 | "node": "0.10.x" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/mimetypes.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | var EXTENSIONS = { 4 | '.jpg': 'image/jpeg', 5 | '.jpeg': 'image/jpeg', 6 | '.png': 'image/png', 7 | '.gif': 'image/gif', 8 | '.tif': 'image/tiff', 9 | '.html': 'text/html', 10 | '.htm': 'text/html', 11 | '.js': 'text/javascript', 12 | '.json': 'application/json' 13 | }; 14 | 15 | function getMimeType(name) { 16 | var ext = path.extname(name).toLowerCase(); 17 | return EXTENSIONS[ext] || 'application/octet-stream'; 18 | }; 19 | 20 | exports.fromPath = getMimeType; 21 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | var config = {}; 2 | config.port = 8383; 3 | config.host = '0.0.0.0'; 4 | 5 | //imagemagick command. can be absolute path: /usr/bin/convert 6 | config.convertCmd = 'convert'; 7 | 8 | //where images are located. can be absolute path. 9 | config.srcDir = './img/'; 10 | //where to store resized files. 11 | config.destDir = './tmp/'; 12 | //whether to leave generated thumbnails or not. 13 | config.cacheImages = true; 14 | 15 | // where static files are located. 16 | config.staticDir = './static/'; 17 | 18 | //maximum supported output image size. 19 | config.maxOutputSize = 2000; 20 | 21 | config.debug = false; 22 | 23 | var localConfig = {}; 24 | try { 25 | localConfig = require('./settings.local'); 26 | } catch(err) { 27 | } finally { 28 | localConfig.__proto__ = config; 29 | } 30 | 31 | module.exports = localConfig; 32 | -------------------------------------------------------------------------------- /src/tempfile.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var os = require('os'); 3 | 4 | var util = require('./util'); 5 | 6 | var defaultRootDir = os.tmpdir(); 7 | var defaultPrefix = "tmp-"; 8 | var defaultPostfix = ""; 9 | 10 | var generateName = function() { 11 | return '%s-%s'.f( 12 | Date.now() 13 | , (Math.random() * 0x100000000 + 1).toString(36)); 14 | }; 15 | 16 | 17 | 18 | var getName = function(prefix, postfix) { 19 | if (undefined === prefix) { 20 | prefix = defaultPrefix; 21 | } 22 | if (undefined === postfix) { 23 | postfix = defaultPostfix; 24 | } 25 | return prefix + generateName() + postfix; 26 | }; 27 | 28 | var getPath = function(prefix, postfix, rootDir) { 29 | if (undefined === rootDir) { 30 | rootDir = defaultRootDir; 31 | } 32 | return path.join(rootDir, getName(prefix, postfix)); 33 | }; 34 | 35 | exports.getPath = getPath; 36 | exports.getName = getName; 37 | -------------------------------------------------------------------------------- /static/img2x.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Image Quality Test 4 | 9 |
10 |
11 | 12 | 49 | 50 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | var util = require('./util'); 4 | var settings = require('./settings'); 5 | var Server = require('./Server'); 6 | 7 | var main = function() { 8 | var argv = process.argv.splice(2); 9 | var port = argv.length > 0 ? (argv[0] * 1) : settings.port; 10 | var host = settings.host; 11 | var convertCmd = settings.convertCmd; 12 | var srcDir = settings.srcDir; 13 | var destDir = settings.destDir; 14 | var staticDir = settings.staticDir; 15 | var baseDir = process.cwd();//__dirname; 16 | var cacheImages = settings.cacheImages; 17 | var maxOutputSize = settings.maxOutputSize; 18 | var debug = settings.debug; 19 | 20 | if (!srcDir.startsWith('/')) { 21 | srcDir = path.join(baseDir, srcDir); 22 | } 23 | if (!destDir.startsWith('/')) { 24 | destDir = path.join(baseDir, destDir); 25 | } 26 | if (!staticDir.startsWith('/')) { 27 | staticDir = path.join(baseDir, staticDir); 28 | } 29 | 30 | 31 | 32 | console.log('Using\n' 33 | + ' host %s\n'.f(host) 34 | + ' port %s\n'.f(port) 35 | + ' convert cmd %s\n'.f(convertCmd) 36 | + ' staticDir %s\n'.f(staticDir) 37 | + ' srcDir %s\n'.f(srcDir) 38 | + ' destDir %s\n'.f(destDir) 39 | + ' cacheImages %s\n'.f(cacheImages) 40 | + ' maxOutputSize %s\n'.f(maxOutputSize) 41 | + ' debug %s\n'.f(debug) 42 | ); 43 | 44 | var server = Server(convertCmd, srcDir, destDir, cacheImages, maxOutputSize, staticDir); 45 | var exit = function(ret) { 46 | if (typeof ret === 'undefined') { 47 | ret = 0; 48 | } 49 | server.stop(); 50 | console.log('Bye'); 51 | process.exit(ret); 52 | }; 53 | 54 | //process.on('exit', exit); 55 | process.on('SIGINT', exit); 56 | process.on('SIGTERM', exit); 57 | process.on('SIGQUIT', exit); 58 | //process.on('uncaughtException', function(err) { 59 | // console.log(err); 60 | // console.log(err.stack); 61 | // //exit(1); 62 | //}); 63 | server.start(host, port); 64 | }; 65 | 66 | main(); 67 | -------------------------------------------------------------------------------- /test/UrlParserTest.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | 3 | var UrlParser = require('../src/UrlParser'); 4 | 5 | describe('UrlParser', function() { 6 | describe('#parse()', function() { 7 | var urlParser = UrlParser('/src', '/out'); 8 | 9 | var centerCropRemote = urlParser.parse('/http://imgur.com/a.100x200.jpg'); 10 | 11 | it('center crop remote -> remoteUrl', function() { 12 | assert.equal('http://imgur.com/a.jpg', centerCropRemote.remoteUrl); 13 | }); 14 | 15 | it('center crop remote -> out', function() { 16 | assert.equal('/out/http/imgur.com/a.100x200.jpg', centerCropRemote.out); 17 | }); 18 | 19 | it('center crop remote -> src', function() { 20 | assert.equal('/src/http/imgur.com/a.jpg', centerCropRemote.src); 21 | }); 22 | 23 | var centerCropRemoteNormalized = urlParser.parse('/http/imgur.com/a.100x200.jpg'); 24 | 25 | it('center crop remote normalized -> remoteUrl', function() { 26 | assert.equal('http://imgur.com/a.jpg', centerCropRemoteNormalized.remoteUrl); 27 | }); 28 | 29 | it('center crop remote normalized -> out', function() { 30 | assert.equal('/out/http/imgur.com/a.100x200.jpg', centerCropRemoteNormalized.out); 31 | }); 32 | 33 | it('center crop remote normalized -> src', function() { 34 | assert.equal('/src/http/imgur.com/a.jpg', centerCropRemoteNormalized.src); 35 | }); 36 | 37 | var badProtocol = urlParser.parse('/httpa/imgur.com/a.100x200sw.jpg'); 38 | it('httpa -> remoteUrl', function() { 39 | assert.equal(null, badProtocol.remoteUrl); 40 | }); 41 | 42 | it('httpa -> src', function() { 43 | assert.equal('/src/httpa/imgur.com/a.jpg', badProtocol.src); 44 | }); 45 | 46 | 47 | it('httpa -> out', function() { 48 | assert.equal('/out/httpa/imgur.com/a.100x200sw.jpg', badProtocol.out); 49 | }); 50 | 51 | it('a', function() { 52 | assert.equal(null, urlParser.parse('/http:/imgur.com/a.100x200t.jpg').remoteUrl); 53 | }); 54 | 55 | it('b', function() { 56 | assert.equal('http://imgur.com/a.jpg', urlParser.parse('/http/imgur.com/a.100x200sw.jpg').remoteUrl); 57 | }); 58 | 59 | it('c', function() { 60 | assert.equal('https://imgur.com/a.jpg', urlParser.parse('/https/imgur.com/a.100x200sw.jpg').remoteUrl); 61 | }); 62 | 63 | it('d', function() { 64 | assert.equal('https://imgur.com/a.100x200abc.jpg', urlParser.parse('/https/imgur.com/a.100x200abc.jpg').remoteUrl); 65 | }); 66 | 67 | it('e', function() { 68 | assert.equal('https://imgur.com/a.jpg', urlParser.parse('/https://imgur.com/a.100x200sw.jpg').remoteUrl); 69 | }); 70 | 71 | it('dirname a', function() { 72 | assert.equal('https://imgur.com/a.jpg', urlParser.parse('/https://imgur.com/a.100x200sw.jpg').remoteUrl); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | if (typeof Object.create !== 'function') { 2 | Object.create = function(o) { 3 | var F = function() {}; 4 | F.prototype = o; 5 | return new F(); 6 | }; 7 | } 8 | 9 | if (String.prototype.f !== 'function') { 10 | String.prototype.f = function() { 11 | var args = arguments; 12 | var index = 0; 13 | var r = function() { 14 | return args[index++]; 15 | }; 16 | return this.replace(/%s/g, r); 17 | }; 18 | } 19 | 20 | if (String.prototype.fo !== 'function') { 21 | String.prototype.fo = function(o) { 22 | var r = function(matched, group_1) { 23 | return o[group_1]; 24 | }; 25 | return this.replace(/%\(([^)]+)\)s/g, r); 26 | }; 27 | } 28 | 29 | if (String.prototype.startsWith !== 'function') { 30 | String.prototype.startsWith = function(s) { 31 | return this.indexOf(s) === 0; 32 | }; 33 | } 34 | 35 | /* 36 | if (Array.prototype.collectFirst !== 'function') { 37 | Array.prototype.collectFirst = function(pred) { 38 | var n = this.length; 39 | var i = 0; 40 | for (; i < n; i++) { 41 | var x = this[i]; 42 | if (pred(x)) { 43 | return x; 44 | } 45 | } 46 | return null; 47 | }; 48 | } 49 | */ 50 | 51 | var path = require('path'); 52 | var fs = require('fs'); 53 | var http = require('http'); 54 | var https = require('https'); 55 | 56 | var mkdirP = function(p, mode, callback) { 57 | var cb = callback || function () {}; 58 | if (p.charAt(0) !== '/') { 59 | p = path.join(process.cwd(), p); 60 | } 61 | 62 | var ps = path.normalize(p).split('/'); 63 | fs.exists(p, function (exists) { 64 | if (exists) { 65 | cb(null); 66 | } else { 67 | mkdirP(ps.slice(0,-1).join('/'), mode, function (err) { 68 | if (err && err.errno != process.EEXIST) { 69 | cb(err); 70 | } else { 71 | fs.mkdir(p, mode, cb); 72 | } 73 | }); 74 | } 75 | }); 76 | }; 77 | 78 | 79 | var downloadAnd = function(url, target, callback) { 80 | var GET = http.get; 81 | if (url.startsWith('https://')) { 82 | GET = https.get; 83 | } 84 | console.log('downloading %s => %s', url, target); 85 | GET(url, function(resp) { 86 | if (resp.statusCode !== 200) { 87 | callback(new Error("remote server didn't respond with 200: "+url)); 88 | return; 89 | } 90 | 91 | //maybe download to /tmp and rename. 92 | var file = fs.createWriteStream(target); 93 | resp.on('data', function(chunk) { 94 | file.write(chunk); 95 | }).on('end', function() { 96 | file.end(); 97 | callback(null); 98 | }); 99 | }).on('error', function(err) { 100 | callback(err); 101 | }).setTimeout(5000); 102 | }; 103 | 104 | 105 | /** 106 | * downloads url and calls callback(error or null); 107 | */ 108 | var ensureDirAndDownload = function(url, target, callback) { 109 | var targetDir = path.dirname(target); 110 | fs.exists(targetDir, function(exists) { 111 | if (!exists) { 112 | mkdirP(targetDir, 0755, function(err) { 113 | if (err) { 114 | callback(err); 115 | } else { 116 | downloadAnd(url, target, callback); 117 | } 118 | }); 119 | } else { 120 | downloadAnd(url, target, callback); 121 | } 122 | }); 123 | }; 124 | 125 | module.exports = { 126 | mkdirP: mkdirP, 127 | downloadAnd: ensureDirAndDownload 128 | }; 129 | 130 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | For GET requests, dynamically crops and resizes image using 4 | ImageMagick `convert` command. 5 | And, serves the resized image. 6 | 7 | 8 | The following crops 1:2 rectangle at center of some/image.jpg. And resizes the crop to 10x20: 9 | 10 | ``` 11 | GET /some/image.10x20.jpg 12 | ``` 13 | 14 | You can specify where to crop other than center. The following crops at the top (north). 15 | 16 | ``` 17 | GET /some/image.10x20n.jpg 18 | ``` 19 | 20 | You can specify crop size in percentage (defaults to `100%`): 21 | 22 | ``` 23 | GET /some/image.10x20n.70.jpg 24 | ``` 25 | 26 | would crop 1:2 rectangle that'll fit about 70% of original image. 27 | 28 | Other parameters: 29 | 30 | Parameter | Crops At | Example | Comment 31 | ----------|----------|---------|--------- 32 | | Center | GET /some/image.10x20.jpg | Not specifying a parameter crops at center. 33 | n | North | GET /a/b.100x400n.jpg | 34 | s | South | GET /a/b.100x400s.jpg | 35 | w | West | GET /a/b.100x400w.jpg | 36 | e | East | GET /a/b.100x400e.jpg | 37 | nw | NorthWest | GET /a/b.100x400nw.jpg | 38 | ne | NorthEast | GET /a/b.100x400ne.jpg | 39 | sw | SouthWest | GET /a/b.100x400sw.jpg | 40 | se | SouthEast | GET /a/b.100x400se.jpg | 41 | t | No Crop | GET /some/image.100x400t.jpg | Instead of cropping, some/image.jpg is resized to fit in 100x400 rectangle. 42 | 43 | # Example 44 | 45 | - [original is horizontal.](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.jpg) 46 | - Horizontal to square: 47 | - [300x300 cropped to center](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.300x300.jpg) 48 | - [300x300 cropped to west](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.300x300w.jpg) 49 | - [300x300 cropped to east](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.300x300e.jpg) 50 | - [300x300 cropped to north west 100%](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.300x300nw.jpg) 51 | - [300x300 cropped to north west 50%](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.300x300nw.50.jpg) 52 | - [300x300 thumbnail](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.300x300t.jpg) 53 | - Horizontal to vertical: 54 | - [320x480 cropped to center](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.320x480.jpg) 55 | - [320x480 cropped to west](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.320x480w.jpg) 56 | - [320x480 cropped to east](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.320x480e.jpg) 57 | - [320x480 cropped to north east 100%](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.320x480ne.jpg) 58 | - [320x480 cropped to north east 75%](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.320x480ne.75.jpg) 59 | - Horizontal to horizontal: 60 | - [500x281 cropped to center](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.500x281.jpg) 61 | - [500x281 cropped to north](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.500x281n.jpg) 62 | - [500x281 cropped to south](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.500x281s.jpg) 63 | - [500x281 cropped to south west 100%](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.500x281sw.jpg) 64 | - [500x281 cropped to south west 80%](http://nodejs-resize-image.herokuapp.com/http://i.imgur.com/gWthS3m.500x281sw.80.jpg) 65 | 66 | # Quickstart 67 | 68 | Run 69 | 70 | node src/main.js 71 | 72 | Configure 73 | 74 | cp src/settings.js src/settings.local.js 75 | vim settings.local.js 76 | 77 | Example `settings.local.js`: 78 | 79 | 80 | module.exports = { 81 | port: 8081, 82 | srcDir: '/var/www', 83 | destDir: '/var/cache', 84 | convertcmd: '/usr/local/bin/convert' 85 | } 86 | -------------------------------------------------------------------------------- /static/utils.js: -------------------------------------------------------------------------------- 1 | function parseQueryString(query) { 2 | query = decodeURIComponent(query || window.location.search.substring(1)); 3 | 4 | var args = {}; 5 | query.split(/&/).forEach(function(kv) { 6 | var splitted = kv.split(/=/); 7 | args[splitted[0]] = splitted[1]; 8 | }); 9 | return args; 10 | } 11 | 12 | function parseAssetUrl(url) { 13 | var regex = new RegExp('^(.+)(\\.\\w+)$'); 14 | var m = regex.exec(url); 15 | return m; 16 | } 17 | 18 | function getRendition(url, width, height, isNoCrop, quality) { 19 | var parsed = parseAssetUrl(url); 20 | if (!parsed) { 21 | return null; 22 | } 23 | 24 | 25 | var withoutExt = parsed[1]; 26 | var ext = parsed[2]; 27 | 28 | var args = []; 29 | if (!isNaN(width) && !isNaN(height)) { 30 | var dimension = width+'x'+height; 31 | if (isNoCrop) { 32 | dimension += 't'; 33 | } 34 | args.push(dimension); 35 | } 36 | 37 | if (!isNaN(quality)) { 38 | args.push('q'+quality); 39 | } 40 | 41 | return '/'+withoutExt+'.'+args.join('.')+ext; 42 | } 43 | 44 | function createAnchor(url, text) { 45 | var a = document.createElement('a'); 46 | a.href = url; 47 | a.innerHTML = text || url; 48 | return a; 49 | } 50 | 51 | function range(start, end, stepsFn) { 52 | if (start === end) { 53 | return [start, end]; 54 | } 55 | 56 | var shouldLoop = function() { 57 | return start < end; 58 | }; 59 | if (start > end) { 60 | shouldLoop = function() { 61 | return start > end; 62 | }; 63 | } 64 | 65 | if (typeof stepsFn !== 'function') { 66 | stepsFn = function(x) { return x + 1; }; 67 | } 68 | var arr = []; 69 | 70 | 71 | while (shouldLoop()) { 72 | arr.push(start); 73 | start = stepsFn(start); 74 | } 75 | arr.push(end); 76 | return arr; 77 | } 78 | 79 | function initFormInputFromQuery(query, key, form, defaultValue, name) { 80 | name = name || key; 81 | 82 | var input = form.querySelector('input[name="'+name+'"]'); 83 | var value = query[key]; 84 | if (typeof value === 'undefined') { 85 | value = defaultValue; 86 | } 87 | 88 | if (input) { 89 | input.value = value; 90 | } 91 | return input; 92 | } 93 | 94 | function commaSeparatedNumbers(str) { 95 | if (str) { 96 | return str.split(',').map(function(x) { return Number(x); }); 97 | } 98 | return []; 99 | } 100 | 101 | function decimalDigits(num) { 102 | return Math.max(Math.log(Math.floor(num)) / Math.LN10 + 1, 1); 103 | } 104 | 105 | function toFractionPrecision(num, precision) { 106 | return num.toPrecision(decimalDigits(num) + precision); 107 | } 108 | 109 | function toKilobyte(size) { 110 | return toFractionPrecision(size / Math.pow(2, 10), 2); 111 | } 112 | 113 | // loads image via xhr to get image byte size :P 114 | // onSuccess(Image, int byte size) 115 | function loadImage(url, onSuccess) { 116 | var xhr = new XMLHttpRequest(); 117 | xhr.responseType = 'blob'; 118 | 119 | xhr.addEventListener('load', function() { 120 | var blob = xhr.response; 121 | 122 | var reader = new FileReader(); 123 | reader.addEventListener('loadend', function() { 124 | var blobBase64 = reader.result; 125 | var img = new Image(); 126 | img.src = blobBase64; 127 | if (typeof onSuccess == 'function') { 128 | var size = parseInt(xhr.getResponseHeader('Content-Length'), 10) || blob.size; 129 | onSuccess(img, size); 130 | } 131 | }); 132 | 133 | reader.readAsDataURL(xhr.response); 134 | }); 135 | xhr.open('GET', url); 136 | xhr.send(); 137 | } 138 | 139 | function appendImageCell(cellWidth, cellHeight, quality, url, parentElem) { 140 | var cell = document.createElement('div'); 141 | cell.className = 'cell'; 142 | cell.style.width = cellWidth + 'px'; 143 | 144 | function append(img, size) { 145 | 146 | img.addEventListener('load', function() { 147 | var kb = toKilobyte(size); 148 | cell.appendChild(document.createElement('br')); 149 | 150 | var msg = img.naturalWidth+'x'+img.naturalHeight+' | q'+quality+' | '+kb+'kb'; 151 | cell.appendChild(document.createTextNode(msg)); 152 | cell.appendChild(document.createElement('br')); 153 | 154 | cell.appendChild(createAnchor(url)); 155 | 156 | }); 157 | 158 | 159 | cell.appendChild(img); 160 | } 161 | 162 | 163 | parentElem.appendChild(cell); 164 | loadImage(url, append); 165 | } 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /src/UrlParser.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var util = require('./util'); 3 | 4 | /** 5 | * require('UrlParser')(srcDir, destDir).parse(url); 6 | * 7 | * paramatized module 8 | */ 9 | var Module = function(srcDir, destDir, maxOutputSize) { 10 | var me = {}; 11 | 12 | 13 | var PathUrlRE = /^\/*(https?)(?:\:\/)?\/(.+)$/; 14 | var PathRE = /^\/*(.+)$/; 15 | var HttpUrlRE = /^(https?)(?:\:\/)?\/(.+)$/; 16 | 17 | var normalizeUrl = function(url) { 18 | var m = PathUrlRE.exec(url); 19 | if (!m) { 20 | var pathMatch = PathRE.exec(url); 21 | return pathMatch[1]; 22 | } 23 | return path.join(m[1], m[2]); 24 | }; 25 | 26 | var normalizeSize = function(width, height) { 27 | var w, h; 28 | if (width > height) { 29 | h = maxOutputSize * height / width; 30 | w = maxOutputSize; 31 | } else { 32 | w = maxOutputSize * width / height; 33 | h = maxOutputSize; 34 | } 35 | if (w >= width && h >= height) { 36 | w = width; 37 | h = height; 38 | } 39 | return {width: w, height: h}; 40 | }; 41 | 42 | 43 | var getImgPath = function(imgId) { 44 | return path.join(srcDir, normalizeUrl(imgId)); 45 | }; 46 | 47 | var getOutputPath = function(url) { 48 | return path.join(destDir, normalizeUrl(url)); 49 | }; 50 | 51 | var getRemoteOriginUrl = function(imgId) { 52 | var m = HttpUrlRE.exec(imgId); 53 | if (!m) { 54 | return null; 55 | } 56 | return m[1] + '://' + m[2]; 57 | }; 58 | 59 | function getQuality(str) { 60 | if (str && str.startsWith('.q')) { 61 | return parseInt(str.substring(2), 10); 62 | } 63 | return null; 64 | } 65 | 66 | function maybeAddQuality(args, quality) { 67 | if (quality) { 68 | args.push('-quality'); 69 | args.push(quality); 70 | } 71 | } 72 | 73 | var CropParser = function() { 74 | var me = {}; 75 | 76 | var UrlRE = /^\/(.+)\.(\d+)x(\d+)(n|w|s|e|nw|ne|sw|se)?(\.\d+)?(\.q\d+)?(\.\w+)$/; 77 | var GravityMap = { 78 | n: 'North', 79 | w: 'West', 80 | s: 'South', 81 | e: 'East', 82 | nw: 'NorthWest', 83 | ne: 'NorthEast', 84 | sw: 'SouthWest', 85 | se: 'SouthEast' 86 | }; 87 | 88 | var getGravity = function(key) { 89 | if (!key) { 90 | return 'Center'; 91 | } 92 | return GravityMap[key]; 93 | }; 94 | 95 | var MIN_CROP_PERCENT = 10; 96 | var MAX_CROP_PERCENT = 100; 97 | 98 | var getCropPercentage = function(percent) { 99 | if (typeof percent === 'undefined') { 100 | return undefined; 101 | } 102 | percent = percent.substring(1) * 1;//get rid of leading dot. 103 | if (Number.isNaN(percent)) { 104 | return undefined; 105 | } 106 | if (percent <= MIN_CROP_PERCENT) { 107 | return MIN_CROP_PERCENT; 108 | } 109 | if (percent >= MAX_CROP_PERCENT) { 110 | return MAX_CROP_PERCENT; 111 | } 112 | return percent; 113 | }; 114 | 115 | me.parse = function(url) { 116 | var m = UrlRE.exec(url); 117 | if (!m) { 118 | return null; 119 | } 120 | 121 | var size = normalizeSize(m[2]*1, m[3]*1); 122 | var width = size.width; 123 | var height = size.height; 124 | var gravity = getGravity(m[4]); 125 | var cropPercentage = getCropPercentage(m[5]); 126 | var quality = getQuality(m[6]); 127 | var ext = m[7]; 128 | var imgId = m[1] + ext; 129 | 130 | var src = getImgPath(imgId); 131 | var out = getOutputPath(url); 132 | var remoteUrl = getRemoteOriginUrl(imgId); 133 | 134 | var initialResizeWidth = width; 135 | var initialResizeHeight = height; 136 | if (cropPercentage) { 137 | var initialCropRatio = -1/100 * cropPercentage + 2; 138 | initialResizeWidth = Math.floor(width * initialCropRatio); 139 | initialResizeHeight = Math.floor(height * initialCropRatio); 140 | } 141 | 142 | var args = ['-coalesce', '-resize', '%sx%s^'.f(initialResizeWidth, initialResizeHeight), 143 | '-gravity', gravity, 144 | '-crop', '%sx%s+0+0'.f(width, height), 145 | '+repage']; 146 | maybeAddQuality(args, quality); 147 | 148 | return ({ 149 | remoteUrl: remoteUrl, 150 | width: width, 151 | height: height, 152 | src: src, 153 | out: out, 154 | args: args 155 | }); 156 | }; 157 | return me; 158 | } 159 | 160 | var ThumbnailParser = function() { 161 | var me = {}; 162 | 163 | var UrlRE = /^\/(.+)\.(\d+)x(\d+)t(\.q\d+)?(\.\w+)$/; 164 | 165 | me.parse = function(url) { 166 | var m = UrlRE.exec(url); 167 | if (!m) { 168 | return null; 169 | } 170 | 171 | 172 | var size = normalizeSize(m[2]*1, m[3]*1); 173 | var width = size.width; 174 | var height = size.height; 175 | var quality = getQuality(m[4]); 176 | var ext = m[5]; 177 | var imgId = m[1] + ext; 178 | 179 | var src = getImgPath(imgId); 180 | var out = getOutputPath(url); 181 | var remoteUrl = getRemoteOriginUrl(imgId); 182 | 183 | var args = ['-coalesce', '-resize', '%sx%s>'.f(width, height)]; 184 | maybeAddQuality(args, quality); 185 | 186 | return ({ 187 | remoteUrl: remoteUrl, 188 | width: width, 189 | height: height, 190 | src: src, 191 | out: out, 192 | args: args 193 | }); 194 | }; 195 | return me; 196 | }; 197 | 198 | var OriginalImgParser = function() { 199 | var me = {}; 200 | var UrlRE = /^\/(.+\.\w+)$/; 201 | me.parse = function(url) { 202 | var m = UrlRE.exec(url); 203 | if (!m) { 204 | return null; 205 | } 206 | var imgId = m[1]; 207 | var src = getImgPath(imgId); 208 | var remoteUrl = getRemoteOriginUrl(imgId); 209 | return ({ 210 | remoteUrl: remoteUrl, 211 | src: src 212 | }); 213 | }; 214 | return me; 215 | }; 216 | 217 | 218 | var parsers = [CropParser(), ThumbnailParser(), OriginalImgParser()]; 219 | var parsersLength = parsers.length; 220 | 221 | me.parse = function(url) { 222 | var i = 0; 223 | for (; i < parsersLength; i++) { 224 | var parser = parsers[i]; 225 | var parsed = parser.parse(url); 226 | if (parsed) { 227 | return parsed; 228 | } 229 | } 230 | return null; 231 | }; 232 | 233 | return me; 234 | }; 235 | 236 | module.exports = Module; 237 | 238 | -------------------------------------------------------------------------------- /static/quality.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Image Quality Curve 4 | 12 |
0,0
13 | 14 |
15 | p1: 16 | p2:
17 | Minimum Quality: 18 | Maximum Quality: 19 | Minimum Width: 20 | Maximum Width:
21 | Image Url: 22 | Cell Width: 23 | Cell Height: 24 | Rendition Widths: 25 | 26 |
27 |
28 | 29 | 295 | -------------------------------------------------------------------------------- /src/Server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | var child = require('child_process'); 5 | var events = require('events'); 6 | var urlutil = require('url'); 7 | 8 | var util = require('./util'); 9 | var UrlParser = require('./UrlParser'); 10 | var settings = require('./settings'); 11 | var mimetypes = require('./mimetypes'); 12 | 13 | var Server = function(convertCmd, srcDir, destDir, cacheImages, maxOutputSize, staticDir) { 14 | var me = {}; 15 | 16 | var urlParser = UrlParser(srcDir, destDir, maxOutputSize); 17 | 18 | 19 | //handles request. 20 | var Handler = function(request, response) { 21 | var me = {}; 22 | 23 | var emitter = new events.EventEmitter(); 24 | me.emitter = emitter; 25 | 26 | var serveFile = function(filePath, mimeType, callback) { 27 | if (!mimeType) { 28 | mimeType = mimetypes.fromPath(filePath); 29 | } 30 | 31 | var encoding = 'binary'; 32 | 33 | fs.stat(filePath, function(err, stat) { 34 | if (err || !stat.isFile()) { 35 | emitter.emit('error', me.do404, 'not found: ' + filePath); 36 | return; 37 | } 38 | 39 | var ifModifiedSince = new Date(request.headers['if-modified-since']); 40 | if (!isNaN(ifModifiedSince.getTime()) && ifModifiedSince >= stat.mtime) { 41 | response.writeHead(304); 42 | response.end(); 43 | return; 44 | } 45 | 46 | response.writeHead(200, { 47 | 'Content-Type': mimeType, 48 | 'Content-Length': stat.size, 49 | 'Last-Modified': stat.mtime 50 | }); 51 | var stream = fs.createReadStream(filePath, { 52 | flags: 'r' 53 | , encoding: encoding 54 | }); 55 | stream.on('data', function(data) { 56 | response.write(data, encoding); 57 | }); 58 | stream.on('close', function() { 59 | response.end(); 60 | if (typeof callback === 'function') { 61 | callback(); 62 | } 63 | }); 64 | stream.on('error', function(err) { 65 | emitter.emit('error', me.do500, 'error reading file: ' + filePath); 66 | }); 67 | }); 68 | }; 69 | 70 | var resizeImage = function(src, out, convertArgs, callback) { 71 | var basePath = path.dirname(out); 72 | 73 | var doResize = function() { 74 | var args = [src].concat(convertArgs); 75 | args.push(out); 76 | console.log('executing: %s %s', convertCmd, args.join(' ')); 77 | var p = child.spawn(convertCmd, args); 78 | p.on('exit', function(code) { 79 | if (code === 0) { 80 | if (typeof callback === 'function') { 81 | callback(out); 82 | } 83 | } else { 84 | emitter.emit('error', me.do500, 'convert %s exited with %s'.f(args.join(' '), code)); 85 | } 86 | }); 87 | }; 88 | 89 | util.mkdirP(basePath, 0755, function(err) { 90 | if (err) { 91 | emitter.emit('error', me.do500, 'cannot create directory: ' + basePath); 92 | } else { 93 | process.nextTick(doResize); 94 | } 95 | }); 96 | 97 | }; 98 | 99 | me.do404 = function(msg) { 100 | response.writeHead(404, { 101 | 'Content-Type': 'text/plain' 102 | }); 103 | var data = 'Not Found: ' + request.url; 104 | if (msg) { 105 | data = '%s\n%s'.f(data, msg); 106 | } 107 | response.end(data); 108 | }; 109 | 110 | me.do500 = function(msg) { 111 | response.writeHead(500, { 112 | 'Content-Type': 'text/plain' 113 | }); 114 | var data = 'Internal Error: ' + request.url; 115 | if (msg) { 116 | data = '%s\n%s'.f(data, msg); 117 | } 118 | response.end(data); 119 | }; 120 | 121 | me.do501 = function(msg) { 122 | response.writeHead(501, { 123 | 'Content-Type': 'text/plain' 124 | }); 125 | var data = 'Not Supported Method: %s %s'.f(request.method, request.url); 126 | if (msg) { 127 | data = '%s\n%s'.f(data, msg); 128 | } 129 | response.end(data); 130 | }; 131 | 132 | 133 | //serves cached image at imagePath (and deletes possibly). 134 | var serveCachedImage = function(imagePath) { 135 | serveFile(imagePath, undefined, function() { 136 | if (!cacheImages) { 137 | fs.unlink(imagePath, function(err) { 138 | if (err) { 139 | console.log('error while deleting: ' + imagePath); 140 | } 141 | }); 142 | } 143 | }); 144 | }; 145 | 146 | var serveCachedImageOrGenerate = function(param) { 147 | fs.exists(param.out, function(exists) { 148 | if (exists && !settings.debug) { 149 | serveCachedImage(param.out); 150 | } else { 151 | resizeImage(param.src, param.out, param.args, function() { 152 | serveCachedImage(param.out); 153 | }); 154 | } 155 | }); 156 | }; 157 | 158 | var serveImage = function(params) { 159 | if (typeof params.args === 'undefined') { 160 | //original 161 | serveFile(params.src, undefined); 162 | } else { 163 | serveCachedImageOrGenerate(params); 164 | } 165 | }; 166 | 167 | var downloadAndTry = function(params) { 168 | var url = params.remoteUrl; 169 | var output = params.src; 170 | util.downloadAnd(url, output, function(err) { 171 | if (err) { 172 | emitter.emit('error', me.do500, 'Failed to download: '+url); 173 | } else { 174 | serveImage(params); 175 | } 176 | }); 177 | }; 178 | 179 | 180 | var staticPrefix = '/static/'; 181 | var start = function() { 182 | if (request.url.startsWith(staticPrefix)) { 183 | var urlPath = urlutil.parse(request.url).pathname; 184 | var filePath = path.join(staticDir, urlPath.substring(staticPrefix.length)); 185 | serveFile(filePath); 186 | } else { 187 | 188 | if (request.method !== 'GET') { 189 | emitter.emit('error', me.do501); 190 | return; 191 | } 192 | 193 | if (request.url === '/favicon.ico') { 194 | emitter.emit('error', me.do404); 195 | return; 196 | } 197 | 198 | var url = request.url; 199 | var parsed = urlParser.parse(request.url); 200 | if (!parsed) { 201 | emitter.emit('error', me.do404); 202 | return; 203 | } 204 | var src = parsed.src; 205 | var isRemoteSrc = !!parsed.remoteUrl; 206 | 207 | fs.exists(src, function(exists) { 208 | if (exists) { 209 | serveImage(parsed); 210 | } else if (isRemoteSrc) { 211 | downloadAndTry(parsed); 212 | } else { 213 | emitter.emit('error', me.do500, 'Image does not exist and cannot be downloaded from remote: '+src); 214 | } 215 | }); 216 | } 217 | }; 218 | 219 | me.start = function() { 220 | process.nextTick(start); 221 | } 222 | 223 | 224 | return me; 225 | }; 226 | 227 | 228 | 229 | 230 | function onEachRequest(req, resp) { 231 | var handler = Handler(req, resp); 232 | handler.emitter.on('error', onError); 233 | handler.start(); 234 | } 235 | 236 | 237 | me.server = http.createServer(onEachRequest); 238 | 239 | me.server.on('close', function() { 240 | console.log('Server stopped'); 241 | }); 242 | 243 | me.start = function(host, port) { 244 | me.stop(); 245 | me.server.listen(port, host, function() { 246 | console.log('Server started %s:%d', host, port); 247 | }); 248 | }; 249 | 250 | me.stop = function() { 251 | try { 252 | me.server.close(); 253 | } catch (e) { 254 | } 255 | }; 256 | 257 | return me; 258 | }; 259 | 260 | function onError(f, msg) { 261 | if (typeof f === 'function') { 262 | if (typeof msg !== 'undefined') { 263 | console.log(msg); 264 | } 265 | f(msg); 266 | } 267 | } 268 | 269 | module.exports = Server; 270 | --------------------------------------------------------------------------------