diff --git a/.buildpacks b/.buildpacks new file mode 100644 index 0000000..516e38b --- /dev/null +++ b/.buildpacks @@ -0,0 +1,2 @@ +https://github.com/mojodna/heroku-buildpack-cairo.git +https://github.com/heroku/heroku-buildpack-nodejs.git \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 58e6785..33aaa51 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,8 @@ node_js: - "0.10" before_script: - cp "modules/config.example.js" "modules/config.js" +before_install: + - sudo apt-get install libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++ notifications: irc: channels: diff --git a/README.md b/README.md index fc4ed8c..acb1ac9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ https://crafatar.com Crafatar serves Minecraft avatars based on the skin for use in external applications. Inspired by [Gravatar](https://gravatar.com) (hence the name) and [Minotar](https://minotar.net). -Image manipulation is done by [lwip](https://github.com/EyalAr/lwip) +Image manipulation is done by [lwip](https://github.com/EyalAr/lwip). Renders are created with [node-canvas](https://github.com/Automattic/node-canvas), based on math by [confuser](https://github.com/confuser/serverless-mc-skin-viewer). ![redstone_sheep's avatar](https://crafatar.com/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=128) ![Jake0oo0's avatar](https://crafatar.com/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=128) ![Notch's avatar](https://crafatar.com/avatars/069a79f444e94726a5befca90e38aaf5?size=128) ![sk89q's avatar](https://crafatar.com/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=128) ![md_5's avatar](https://crafatar.com/avatars/af74a02d19cb445bb07f6866a861f783?size=128) ## Usage / Documentation @@ -20,6 +20,7 @@ Please [visit the website](https://crafatar.com) for details. ## Install * Clone the repository +* Install [node-canvas](https://github.com/Automattic/node-canvas/wiki#desktop) dependencies. * `npm install` * `redis-server` * `cp "modules/config.example.js" "modules/config.js"` diff --git a/app.js b/app.js index ad3b38e..7058de0 100644 --- a/app.js +++ b/app.js @@ -4,9 +4,10 @@ var logger = require("morgan"); var cookieParser = require("cookie-parser"); var bodyParser = require("body-parser"); -var routes = require("./routes/index"); -var avatars = require("./routes/avatars"); -var skins = require("./routes/skins") +var routes = require('./routes/index'); +var avatars = require('./routes/avatars'); +var skins = require('./routes/skins'); +var renders = require('./routes/renders'); var app = express(); @@ -20,10 +21,10 @@ app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, "public"))); -app.use("/", routes); -app.use("/avatars", avatars); -app.use("/skins", skins) - +app.use('/', routes); +app.use('/avatars', avatars); +app.use('/skins', skins); +app.use('/renders', renders); // catch 404 and forward to error handler app.use(function(req, res, next) { diff --git a/modules/clean_images.js b/modules/clean_images.js index f3e5501..1c8e3c2 100644 --- a/modules/clean_images.js +++ b/modules/clean_images.js @@ -33,14 +33,24 @@ exp.run = function() { logging.error(err); } else if (clean) { logging.warn("ImageCleaner: Disk limit reached! Cleaning images now"); - var skindir = __dirname + "/../" + config.faces_dir; + var facesdir = __dirname + "/../" + config.faces_dir; var helmdir = __dirname + "/../" + config.helms_dir; - var files = fs.readdirSync(skindir); + var renderdir = __dirname + "/../" + config.renders_dir; + var skindir = __dirname + "/../" + config.skins_dir; + var files = fs.readdirSync(facesdir); for (var i = 0; i < Math.min(files.length, config.cleaning_amount); i++) { var filename = files[i]; if (filename[0] != ".") { - fs.unlink(skindir + filename, function(){}); + fs.unlink(facesdir + filename, function(){}); fs.unlink(helmdir + filename, function(){}); + fs.unlink(skindir + filename, function(){}); + } + } + files = fs.readdirSync(renderdir); + for (var j = 0; j < Math.min(files.length, config.cleaning_amount); j++) { + var filename = files[j]; + if (filename[0] != ".") { + fs.unlink(renderdir + filename, function(){}); } } } else { diff --git a/modules/config.example.js b/modules/config.example.js index 3bd43c0..53c1576 100644 --- a/modules/config.example.js +++ b/modules/config.example.js @@ -1,16 +1,21 @@ var config = { - min_size: 1, // < 1 will (obviously) cause crash - max_size: 512, // too big values might lead to slow response time or DoS - default_size: 160, // size to be used when no size given - local_cache_time: 1200, // seconds until we will check if the image changed. should be > 60 to prevent mojang 429 response - browser_cache_time: 3600, // seconds until browser will request image again - cleaning_interval: 1800, // seconds interval: deleting images if disk size at limit - cleaning_limit: 10240, // minumum required available KB on disk to trigger cleaning - cleaning_amount: 50000, // amount of avatar (and their helm) files to clean - http_timeout: 1000, // ms until connection to mojang is dropped - faces_dir: "skins/faces/", // directory where faces are kept. should have trailing "/" - helms_dir: "skins/helms/", // directory where helms are kept. should have trailing "/" - debug_enabled: false // enables logging.debug + min_size: 1, // for avatars + max_size: 512, // for avatars; too big values might lead to slow response time or DoS + default_size: 160, // for avatars; size to be used when no size given + local_cache_time: 1200, // seconds until we will check if the image changed. should be > 60 to prevent mojang 429 response + browser_cache_time: 3600, // seconds until browser will request image again + cleaning_interval: 1800, // seconds interval: deleting images if disk size at limit + cleaning_limit: 10240, // minumum required available KB on disk to trigger cleaning + cleaning_amount: 50000, // amount of avatar (and their helm) files to clean + http_timeout: 1000, // ms until connection to mojang is dropped + faces_dir: 'skins/faces/', // directory where faces are kept. should have trailing '/' + helms_dir: 'skins/helms/', // directory where helms are kept. should have trailing '/' + skins_dir: 'skins/skins/', // directory where skins are kept. should have trailing '/' + renders_dir: 'skins/renders/', // Directory where rendered skins are kept. should have trailing '/' + debug_enabled: false, // enables logging.debug + min_scale: 1, // for renders + max_scale: 10, // for renders; too big values might lead to slow response time or DoS + default_scale: 6, // for renders; scale to be used when no scale given }; module.exports = config; \ No newline at end of file diff --git a/modules/helpers.js b/modules/helpers.js index 5bd4926..739d356 100644 --- a/modules/helpers.js +++ b/modules/helpers.js @@ -3,6 +3,7 @@ var logging = require("./logging"); var config = require("./config"); var cache = require("./cache"); var skins = require("./skins"); +var renders = require("./renders"); var fs = require("fs"); // 0098cb60-fa8e-427c-b299-793cbd302c9a @@ -160,19 +161,62 @@ exp.get_avatar = function(uuid, helm, size, callback) { exp.get_skin = function(uuid, callback) { logging.log(uuid + " skin request"); exp.get_image_hash(uuid, function(err, status, hash) { - if (hash) { - var skinurl = "http://textures.minecraft.net/texture/" + hash; - networking.get_skin(skinurl, function(err, img) { + var skinpath = __dirname + "/../" + config.skins_dir + hash + ".png"; + if (fs.existsSync(skinpath)) { + logging.log("skin already exists, not downloading"); + skins.open_skin(skinpath, function(err, img) { + callback(err, hash, img); + }); + return; + } + networking.save_skin(uuid, hash, skinpath, function(err, img) { + callback(err, hash, img); + }); + }); +}; + +function get_type(helm, body) { + var text = body ? "body" : "head"; + return helm ? text+"helm" : text; +} + +// handles creations of skin renders +// callback contanis error, hash, image buffer +exp.get_render = function(uuid, scale, helm, body, callback) { + logging.log(uuid + " render request"); + exp.get_image_hash(uuid, function(err, status, hash) { + exp.get_skin(uuid, function(err, hash, img) { + if (!hash) { + callback(err, -1, hash, null); + return; + } + logging.debug("TYPE: " + get_type(helm, body)); + var renderpath = __dirname + "/../" + config.renders_dir + hash + "-" + scale + "-" + get_type(helm, body) + ".png"; + if (fs.existsSync(renderpath)) { + renders.open_render(renderpath, function(err, img) { + callback(err, 1, hash, img); + }); + return; + } + if (!img) { + callback(err, 0, hash, null); + return; + } + renders.draw_model(uuid, img, scale, helm, body, function(err, img) { if (err) { - logging.error("error while downloading skin"); - callback(err, hash, null); + callback(err, -1, hash, null); + } else if (!img) { + callback(null, 0, hash, null); } else { - callback(null, hash, img); + fs.writeFile(renderpath, img, 'binary', function(err){ + if (err) { + logging.log(err); + } + callback(null, 2, hash, img); + }); } }); - } else { - callback(err, null, null); - } + }); }); }; diff --git a/modules/networking.js b/modules/networking.js index eec75d4..397169b 100644 --- a/modules/networking.js +++ b/modules/networking.js @@ -141,4 +141,25 @@ exp.get_skin = function(url, callback) { }); }; +exp.save_skin = function(uuid, hash, outpath, callback) { + if (hash) { + var skinurl = "http://textures.minecraft.net/texture/" + hash; + exp.get_skin(skinurl, function(err, img) { + if (err) { + logging.error("error while downloading skin"); + callback(err, null); + } else { + fs.writeFile(outpath, img, 'binary', function(err){ + if (err) { + logging.log(err); + } + callback(null, img); + }); + } + }); + } else { + callback(null, null); + } +}; + module.exports = exp; \ No newline at end of file diff --git a/modules/renders.js b/modules/renders.js new file mode 100644 index 0000000..ebc19cd --- /dev/null +++ b/modules/renders.js @@ -0,0 +1,202 @@ +// Skin locations are based on the work of Confuser, with 1.8 updates by Jake0oo0 +// https://github.com/confuser/serverless-mc-skin-viewer +// Permission to use & distribute https://github.com/confuser/serverless-mc-skin-viewer/blob/master/LICENSE + +var helpers = require('./helpers'); +var logging = require('./logging'); +var fs = require('fs'); +var Canvas = require('canvas'); +var Image = Canvas.Image; +var exp = {}; + +// draws the helmet on to the +skin_canvas+ +// using the skin from the +model_ctx+ at the +scale+ +exp.draw_helmet = function(skin_canvas, model_ctx, scale) { + //Helmet - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 40*scale, 8*scale, 8*scale, 8*scale, 10*scale, 13/1.2*scale, 8*scale, 8*scale); + //Helmet - Right + model_ctx.setTransform(1,0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 32*scale, 8*scale, 8*scale, 8*scale, 2*scale, 3/1.2*scale, 8*scale, 8*scale); + //Helmet - Top + model_ctx.setTransform(-1,0.5,1,0.5,0,0); + model_ctx.scale(-1,1); + model_ctx.drawImage(skin_canvas, 40*scale, 0, 8*scale, 8*scale, -3*scale, 5*scale, 8*scale, 8*scale); +}; + +// draws the head on to the +skin_canvas+ +// using the skin from the +model_ctx+ at the +scale+ +exp.draw_head = function(skin_canvas, model_ctx, scale) { + //Head - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 8*scale, 8*scale, 8*scale, 8*scale, 10*scale, 13/1.2*scale, 8*scale, 8*scale); + //Head - Right + model_ctx.setTransform(1,0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 0, 8*scale, 8*scale, 8*scale, 2*scale, 3/1.2*scale, 8*scale, 8*scale); + //Head - Top + model_ctx.setTransform(-1,0.5,1,0.5,0,0); + model_ctx.scale(-1,1); + model_ctx.drawImage(skin_canvas, 8*scale, 0, 8*scale, 8*scale, -3*scale, 5*scale, 8*scale, 8*scale); +}; + +// draws the body on to the +skin_canvas+ +// using the skin from the +model_ctx+ at the +scale+ +// parts are labeled as if drawn from the skin's POV +exp.draw_body = function(skin_canvas, model_ctx, scale) { + if (skin_canvas.height == 32 * scale) { + logging.log("old skin"); + //Left Leg + //Left Leg - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.scale(-1,1); + model_ctx.drawImage(skin_canvas, 4*scale, 20*scale, 4*scale, 12*scale, -16*scale, 34.4/1.2*scale, 4*scale, 12*scale); + + //Right Leg + //Right Leg - Right + model_ctx.setTransform(1,0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 0*scale, 20*scale, 4*scale, 12*scale, 4*scale, 26.4/1.2*scale, 4*scale, 12*scale); + //Right Leg - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 4*scale, 20*scale, 4*scale, 12*scale, 8*scale, 34.4/1.2*scale, 4*scale, 12*scale); + + //Arm Left + //Arm Left - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.scale(-1,1); + model_ctx.drawImage(skin_canvas, 44*scale, 20*scale, 4*scale, 12*scale, -20*scale, 20/1.2*scale, 4*scale, 12*scale); + //Arm Left - Top + model_ctx.setTransform(-1,0.5,1,0.5,0,0); + model_ctx.drawImage(skin_canvas, 44*scale, 16*scale, 4*scale, 4*scale, 0, 16*scale, 4*scale, 4*scale); + + //Body + //Body - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 20*scale, 20*scale, 8*scale, 12*scale, 8*scale, 20/1.2*scale, 8*scale, 12*scale); + + //Arm Right + //Arm Right - Right + model_ctx.setTransform(1,0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 40*scale, 20*scale, 4*scale, 12*scale, 0, 16/1.2*scale, 4*scale, 12*scale); + //Arm Right - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 44*scale, 20*scale, 4*scale, 12*scale, 4*scale, 20/1.2*scale, 4*scale, 12*scale); + //Arm Right - Top + model_ctx.setTransform(-1,0.5,1,0.5,0,0); + model_ctx.scale(-1,1); + model_ctx.drawImage(skin_canvas, 44*scale, 16*scale, 4*scale, 4*scale, -16*scale, 16*scale, 4*scale, 4*scale); + } else { + logging.log("new skin"); + //Left Leg + //Left Leg - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 20*scale, 52*scale, 4*scale, 12*scale, 12*scale, 34.4/1.2*scale, 4*scale, 12*scale); + + //Right Leg + //Right Leg - Right + model_ctx.setTransform(1,0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 0, 20*scale, 4*scale, 12*scale, 4*scale, 26.4/1.2*scale, 4*scale, 12*scale); + //Right Leg - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 4*scale, 20*scale, 4*scale, 12*scale, 8*scale, 34.4/1.2*scale, 4*scale, 12*scale); + + //Arm Left + //Arm Left - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 36*scale, 52*scale, 4*scale, 12*scale, 16*scale, 20/1.2*scale, 4*scale, 12*scale); + //Arm Left - Top + model_ctx.setTransform(-1,0.5,1,0.5,0,0); + model_ctx.drawImage(skin_canvas, 36*scale, 48*scale, 4*scale, 4*scale, 0, 16*scale, 4*scale, 4*scale); + + //Body + //Body - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 20*scale, 20*scale, 8*scale, 12*scale, 8*scale, 20/1.2*scale, 8*scale, 12*scale); + + //Arm Right + //Arm Right - Right + model_ctx.setTransform(1,0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 40*scale, 20*scale, 4*scale, 12*scale, 0, 16/1.2*scale, 4*scale, 12*scale); + //Arm Right - Front + model_ctx.setTransform(1,-0.5,0,1.2,0,0); + model_ctx.drawImage(skin_canvas, 44*scale, 20*scale, 4*scale, 12*scale, 4*scale, 20/1.2*scale, 4*scale, 12*scale); + //Arm Right - Top + model_ctx.setTransform(-1,0.5,1,0.5,0,0); + model_ctx.scale(-1,1); + model_ctx.drawImage(skin_canvas, 44*scale, 16*scale, 4*scale, 4*scale, -16*scale, 16*scale, 4*scale, 4*scale); + } +}; + +// sets up the necessary components to draw the skin model +// uses the +img+ skin from the +uuid+ with options of drawing +// the +helm+ and the +body+ +// callback contains error, image buffer +exp.draw_model = function(uuid, img, scale, helm, body, callback) { + var image = new Image(); + + image.onerror = function(err) { + logging.error("render error: " + err); + callback(err, null); + }; + + image.onload = function() { + var width = 64 * scale; + var original_height = (image.height == 32 ? 32 : 64); + var height = original_height * scale; + var model_canvas = new Canvas(20 * scale, (body ? 44.8 : 17.6) * scale); + var skin_canvas = new Canvas(width, height); + var model_ctx = model_canvas.getContext('2d'); + var skin_ctx = skin_canvas.getContext('2d'); + + skin_ctx.drawImage(image,0,0,64,original_height); + //Scale it + scale_image(skin_ctx.getImageData(0,0,64,original_height), skin_ctx, 0, 0, scale); + if (body) { + logging.log("drawing body"); + exp.draw_body(skin_canvas, model_ctx, scale); + } + logging.log("drawing head"); + exp.draw_head(skin_canvas, model_ctx, scale); + if (helm) { + logging.log("drawing helmet"); + exp.draw_helmet(skin_canvas, model_ctx, scale); + } + + model_canvas.toBuffer(function(err, buf){ + if (err) { + logging.log("error creating buffer: " + err); + } + callback(err, buf); + }); + }; + + image.src = img; +}; + +// helper method to open a render from +renderpath+ +// callback contains error, image buffer +exp.open_render = function(renderpath, callback) { + fs.readFile(renderpath, function (err, buf) { + if (err) { + logging.error("error while opening skin file: " + err); + } + callback(err, buf); + }); +}; + +// scales an image from the +imagedata+ onto the +context+ +// scaled by a factor of +scale+ with options +d_x+ and +d_y+ +function scale_image(imageData, context, d_x, d_y, scale) { + var width = imageData.width; + var height = imageData.height; + context.clearRect(0,0,width,height); //Clear the spot where it originated from + for(y=0; y config.max_scale) { + // Preventing from OOM crashes. + res.status(422).send("422 Invalid Scale"); + return; + } else if (!helpers.uuid_valid(uuid)) { + res.status(422).send("422 Invalid UUID"); + return; + } + + // strip dashes + uuid = uuid.replace(/-/g, ""); + + try { + helpers.get_render(uuid, scale, helm, body, function(err, status, hash, image) { + logging.log(uuid + " - " + human_status[status]); + if (err) { + logging.error(err); + } + etag = hash && hash.substr(0, 32) || "none"; + var matches = req.get("If-None-Match") == '"' + etag + '"'; + if (image) { + var http_status = 200; + if (matches) { + http_status = 304; + } else if (err) { + http_status = 503; + } + logging.log("matches: " + matches); + logging.log("Etag: " + req.get("If-None-Match")); + logging.log("status: " + http_status); + sendimage(http_status, status, image); + } else { + logging.log("image not found, using default."); + handle_default(404, status); + } + }); + } catch(e) { + logging.error("Error!"); + logging.error(e); + handle_default(500, status); + } + + + // default alex/steve images can be rendered, but + // custom images will not be + function handle_default(http_status, img_status) { + if (def && def != "steve" && def != "alex") { + res.writeHead(301, { + 'Cache-Control': 'max-age=' + config.browser_cache_time + ', public', + 'Response-Time': new Date() - start, + 'X-Storage-Type': human_status[img_status], + 'Access-Control-Allow-Origin': '*', + 'Location': def + }); + res.end(); + } else { + def = def || skins.default_skin(uuid); + fs.readFile("public/images/" + def + "_skin.png", function (err, buf) { + if (err) { + // errored while loading the default image, continuing with null image + logging.error("error loading default render image: " + err); + } + // we render the default skins, but not custom images + renders.draw_model(uuid, buf, scale, helm, body, function(err, def_img) { + if (err) { + logging.log("error while rendering default image: " + err); + } + sendimage(http_status, img_status, def_img); + }); + }); + } + } + + function sendimage(http_status, img_status, image) { + res.writeHead(http_status, { + 'Content-Type': 'image/png', + 'Cache-Control': 'max-age=' + config.browser_cache_time + ', public', + 'Response-Time': new Date() - start, + 'X-Storage-Type': human_status[img_status], + 'Access-Control-Allow-Origin': '*', + 'Etag': '"' + etag + '"' + }); + res.end(http_status == 304 ? null : image); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/skins/renders/.gitkeep b/skins/renders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/skins/skins/.gitkeep b/skins/skins/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/test/test.js b/test/test.js index 9b4fbb4..605ab7c 100644 --- a/test/test.js +++ b/test/test.js @@ -7,6 +7,7 @@ var logging = require("../modules/logging"); var config = require("../modules/config"); var skins = require("../modules/skins"); var cache = require("../modules/cache"); +var renders = require("../modules/renders"); // we don't want tests to fail because of slow internet config.http_timeout *= 3; @@ -112,7 +113,6 @@ describe("Crafatar", function() { done(); }); }); - describe("Errors", function() { it("should time out on uuid info download", function(done) { var original_timeout = config.http_timeout; @@ -205,6 +205,25 @@ describe("Crafatar", function() { }); }); + describe("Networking: Render", function() { + it("should not fail (username, 64x64 skin)", function(done) { + helpers.get_render("Jake0oo0", 6, true, true, function(err, hash, img) { + assert.strictEqual(err, null); + done(); + }); + }); + }); + + describe("Networking: Render", function() { + it("should not fail (username, 32x64 skin)", function(done) { + helpers.get_render("md_5", 6, true, true, function(err, hash, img) { + assert.strictEqual(err, null); + done(); + }); + }); + }); + + describe("Errors", function() { before(function() { cache.get_redis().flushall(); diff --git a/views/index.jade b/views/index.jade index fea4cd8..5cf2440 100644 --- a/views/index.jade +++ b/views/index.jade @@ -63,12 +63,44 @@ block content | Replace mark.green id | with a Mojang UUID or username to get the related skin. - | You are redirected to the textures URL, or the default image is served.
+ | The user's skin will be returned, or the default image is served.
| You can use the default parameter here as well. .code | #{domain}/skins/ mark.green id + a(id="renders", class="anchor") + a(href="renders") + h3 3D Renders + p + | Crafatar also provides support for 3D renders of Minecraft skins. + | Replace + mark.green id + | with a Mojang UUID or username to get an render for the skin. + .code + | #{domain}/renders/head/ + mark.green id + .code + | #{domain}/renders/body/ + mark.green id + | The default parameter can also be used here. Using alex or steve will create a + | render with the same parameters. A custom image will not be rendered. A UUID or username + | without a skin, will produce a render based on the input id, or the default parameter. + | Using the helm parameter is also allowed, which will be overlayed onto the head. + | The head render type will return only a render of the skin's head, while the + | body render will return a render of the entire skin. + + a(id="#render-parameters", class="#render-anchor") + a(href="#render-parameters") + h3 Render Parameters + a(id="scale", class="anchor") + a(href="#scale") + h4 scale + p + | The scale factor of the image #{config.min_scale} - #{config.max_scale}.
+ | Default is #{config.default_scale}. The actual size differs between the type of render. + + a(id="http-headers", class="anchor") a(href="#http-headers") h3 HTTP headers @@ -126,7 +158,7 @@ block content .code #{domain}/avatars/853c80ef3c3749fdaa49938b674adae6 p Jeb's avatar, 64 × 64 .code #{domain}/avatars/853c80ef3c3749fdaa49938b674adae6?size=64 - p Jeb's avatar, 64 × 64, with helm + p Jeb's avatar, 64 × 64, with helmet .code #{domain}/avatars/853c80ef3c3749fdaa49938b674adae6?size=64&helm p Jeb's avatar, or fall back to steve .code #{domain}/avatars/853c80ef3c3749fdaa49938b674adae6?default=steve @@ -138,6 +170,10 @@ block content .code #{domain}/skins/853c80ef3c3749fdaa49938b674adae6 p Jeb's skin by username .code #{domain}/skins/jeb_ + p Render of Jeb's Head + .code #{domain}/renders/head/853c80ef3c3749fdaa49938b674adae6 + p Render of Jeb's Body, with helmet, by username + .code #{domain}/renders/body/jeb_?helm .col-md-2.center .sideface.redstone_sheep(title="redstone_sheep") .sideface.Jake0oo0(title="Jake0oo0")