| page.title=Device Art Generator |
| page.image=/images/device-art-ex-crop.jpg |
| page.metaDescription=Drag and drop screenshots of your app into real device artwork, for better looking promotional images and improved visual context. |
| meta.tags="disttools, promoting, deviceart, marketing" |
| page.tags="device, deviceart, nexus, assets" |
| Xnonavpage=true |
| |
| @jd:body |
| |
| <p>The device art generator enables you to quickly wrap app screenshots in real device artwork. This provides better visual context for your app screenshots on your website or in other promotional materials</p> |
| |
| <p class="note"><strong>Note</strong>: Do <em>not</em> use graphics created here in your 1024x500 |
| feature image or screenshots for your Google Play app listing.</p> |
| |
| <hr> |
| |
| <div class="supported-browser"> |
| |
| <div class="layout-content-row"> |
| <div class="layout-content-col span-3"> |
| <h4>Step 1</h4> |
| <p>Drag a screenshot from your desktop onto a device to the right.</p> |
| </div> |
| <div class="layout-content-col span-10"> |
| <ul class="device-list primary"></ul> |
| <a href="#" id="archive-expando">Older devices</a> |
| <ul class="device-list archive"></ul> |
| </div> |
| </div> |
| |
| <hr> |
| |
| <div class="layout-content-row"> |
| <div class="layout-content-col span-3"> |
| <h4>Step 2</h4> |
| <p>Customize the generated image and drag it to your desktop to save.</p> |
| <p id="frame-customizations"> |
| <input type="checkbox" id="output-shadow" checked="checked" class="form-field-checkbutton"> |
| <label for="output-shadow">Shadow</label><br> |
| <input type="checkbox" id="output-glare" checked="checked" class="form-field-checkbutton"> |
| <label for="output-glare">Screen Glare</label><br><br> |
| <a class="button" id="rotate-button">Rotate</a> |
| </p> |
| </div> |
| <div class="layout-content-col span-10"> |
| <!-- position:relative fixes an issue where dragging an image out of a inline-block container |
| produced no drag feedback image in Chrome 28. --> |
| <div id="output" style="position:relative">No input image.</div> |
| </div> |
| </div> |
| |
| </div> |
| |
| <div class="unsupported-browser" style="display: none"> |
| <p class="warning"><strong>Error:</strong> This page requires |
| <span id="unsupported-browser-reason">certain features</span>, which your web browser |
| doesn't support. To continue, navigate to this page on a supported web browser, such as |
| <strong>Google Chrome</strong>.</p> |
| <a href="https://www.google.com/chrome/" class="button">Get Google Chrome</a> |
| <br><br> |
| </div> |
| |
| <style> |
| h4 { |
| text-transform: uppercase; |
| } |
| |
| .device-list { |
| padding: 0; |
| margin: 0; |
| } |
| |
| .device-list li { |
| display: inline-block; |
| vertical-align: bottom; |
| margin: 0; |
| margin-right: 20px; |
| text-align: center; |
| } |
| |
| .device-list li .thumb-container { |
| display: inline-block; |
| } |
| |
| .device-list li .thumb-container img { |
| margin-bottom: 8px; |
| opacity: 0.6; |
| |
| -webkit-transition: -webkit-transform 0.2s, opacity 0.2s; |
| -moz-transition: -moz-transform 0.2s, opacity 0.2s; |
| transition: transform 0.2s, opacity 0.2s; |
| } |
| |
| .device-list li.drag-hover .thumb-container img { |
| opacity: 1; |
| |
| -webkit-transform: scale(1.1); |
| -moz-transform: scale(1.1); |
| transform: scale(1.1); |
| } |
| |
| .device-list li .device-details { |
| font-size: 13px; |
| line-height: 16px; |
| color: #888; |
| } |
| |
| .device-list li .device-url { |
| font-weight: bold; |
| } |
| |
| #archive-expando { |
| display: block; |
| font-size: 13px; |
| font-weight: bold; |
| color: #333; |
| text-transform: uppercase; |
| margin-top: 16px; |
| padding-top: 16px; |
| padding-left: 28px; |
| border-top: 1px solid transparent; |
| background: transparent url({@docRoot}assets/images/styles/disclosure_down.png) |
| no-repeat scroll 0 8px; |
| -webkit-transition: border 0.2s; |
| -moz-transition: border 0.2s; |
| transition: border 0.2s; |
| } |
| |
| #archive-expando.expanded { |
| background-image: url({@docRoot}assets/images/styles/disclosure_up.png); |
| border-top: 1px solid #ccc; |
| } |
| |
| .device-list.archive { |
| max-height: 0; |
| overflow: hidden; |
| opacity: 0; |
| |
| -webkit-transition: max-height 0.2s, opacity 0.2s; |
| -moz-transition: max-height 0.2s, opacity 0.2s; |
| transition: max-height 0.2s, opacity 0.2s; |
| } |
| |
| .device-list.archive.expanded { |
| opacity: 1; |
| max-height: 300px; |
| } |
| |
| #output { |
| color: #f44; |
| font-style: italic; |
| } |
| |
| #output img { |
| max-height: 500px; |
| } |
| </style> |
| <script> |
| // Global variables |
| var g_currentImage; |
| var g_currentDevice; |
| var g_currentObjectURL; |
| var g_currentBlob; |
| |
| // Global constants |
| var MSG_INVALID_INPUT_IMAGE = 'Invalid screenshot provided. Screenshots must be PNG files ' |
| + 'matching the target device\'s screen aspect ratio in either portrait or landscape.'; |
| var MSG_NO_INPUT_IMAGE = 'Drag a screenshot (in PNG format) from your desktop onto a ' |
| + 'target device above.' |
| var MSG_GENERATING_IMAGE = 'Generating device art…'; |
| |
| var MAX_DISPLAY_HEIGHT = 126; // XOOM, to fit into 200px wide |
| |
| // Device manifest. |
| var DEVICES = [ |
| { |
| id: 'nexus_5', |
| title: 'Nexus 5', |
| url: 'http://www.google.com/nexus/5/', |
| physicalSize: 5, |
| physicalHeight: 5.43, |
| density: 'XXHDPI', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [436,306], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [304,436], |
| portSize: [1080,1920], |
| }, |
| { |
| id: 'nexus_7', |
| title: 'Nexus 7', |
| url: 'http://www.google.com/nexus/7/', |
| physicalSize: 7, |
| physicalHeight: 8, |
| actualResolution: [1200,1920], |
| density: 'XHDPI', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [326,245], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [244,326], |
| portSize: [800,1280] |
| }, |
| { |
| id: 'nexus_10', |
| title: 'Nexus 10', |
| url: 'http://www.google.com/nexus/10/', |
| physicalSize: 10, |
| physicalHeight: 7, |
| actualResolution: [1600,2560], |
| density: 'XHDPI', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [227,217], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [217,223], |
| portSize: [800,1280] |
| }, |
| { |
| id: 'xoom', |
| title: 'Motorola XOOM', |
| url: 'http://www.google.com/phone/detail/motorola-xoom', |
| physicalSize: 10, |
| physicalHeight: 6.61, |
| density: 'MDPI', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [218,191], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [199,200], |
| portSize: [800,1280], |
| archived: true |
| }, |
| { |
| id: 'nexus_7_2012', |
| title: 'Nexus 7 (2012)', |
| url: 'http://www.google.com/nexus/7/', |
| physicalSize: 7, |
| physicalHeight: 7.81, |
| density: '213dpi', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [315,270], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [264,311], |
| portSize: [800,1280], |
| archived: true |
| }, |
| { |
| id: 'nexus_4', |
| title: 'Nexus 4', |
| url: 'http://www.google.com/nexus/4/', |
| physicalSize: 4.7, |
| physicalHeight: 5.27, |
| density: 'XHDPI', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [349,214], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [213,350], |
| portSize: [768,1280], |
| archived: true |
| }, |
| { |
| id: 'galaxy_nexus', |
| title: 'Galaxy Nexus', |
| url: 'http://www.android.com/devices/detail/galaxy-nexus', |
| physicalSize: 4.65, |
| physicalHeight: 5.33, |
| density: 'XHDPI', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [371,199], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [216,353], |
| portSize: [720,1280], |
| archived: true |
| }, |
| { |
| id: 'nexus_s', |
| title: 'Nexus S', |
| url: 'http://www.google.com/phone/detail/nexus-s', |
| physicalSize: 4.0, |
| physicalHeight: 4.88, |
| density: 'HDPI', |
| landRes: ['shadow', 'back', 'fore'], |
| landOffset: [247,135], |
| portRes: ['shadow', 'back', 'fore'], |
| portOffset: [134,247], |
| portSize: [480,800], |
| archived: true |
| } |
| ]; |
| |
| DEVICES = DEVICES.sort(function(x, y) { return x.physicalSize - y.physicalSize; }); |
| |
| var MAX_HEIGHT = 0; |
| for (var i = 0; i < DEVICES.length; i++) { |
| MAX_HEIGHT = Math.max(MAX_HEIGHT, DEVICES[i].physicalHeight); |
| } |
| |
| // Setup performed once the DOM is ready. |
| $(document).ready(function() { |
| if (!checkBrowser()) { |
| return; |
| } |
| |
| polyfillCanvasToBlob(); |
| setupUI(); |
| |
| // Set up Chrome drag-out |
| $.event.props.push("dataTransfer"); |
| document.body.addEventListener('dragstart', function(e) { |
| var target = e.target; |
| if (target.classList.contains('dragout')) { |
| e.dataTransfer.setData('DownloadURL', target.dataset.downloadurl); |
| } |
| }, false); |
| }); |
| |
| /** |
| * Returns the device from DEVICES with the given id. |
| */ |
| function getDeviceById(id) { |
| for (var i = 0; i < DEVICES.length; i++) { |
| if (DEVICES[i].id == id) |
| return DEVICES[i]; |
| } |
| return; |
| } |
| |
| /** |
| * Checks to make sure the browser supports this page. If not, |
| * updates the UI accordingly and returns false. |
| */ |
| function checkBrowser() { |
| // Check for browser support |
| var browserSupportError = null; |
| |
| // Must have <canvas> |
| var elem = document.createElement('canvas'); |
| if (!elem.getContext || !elem.getContext('2d')) { |
| browserSupportError = 'HTML5 canvas.'; |
| } |
| |
| // Must have FileReader |
| if (!window.FileReader) { |
| browserSupportError = 'desktop file access'; |
| } |
| |
| if (browserSupportError) { |
| $('.supported-browser').hide(); |
| |
| $('#unsupported-browser-reason').html(browserSupportError); |
| $('.unsupported-browser').show(); |
| return false; |
| } |
| |
| return true; |
| } |
| |
| function setupUI() { |
| $('#output').html(MSG_NO_INPUT_IMAGE); |
| |
| $('#frame-customizations').hide(); |
| |
| $('#output-shadow, #output-glare').click(function() { |
| createFrame(); |
| }); |
| |
| // Build device list. |
| $.each(DEVICES, function() { |
| var resolution = this.actualResolution || this.portSize; |
| var scaleFactorText = ''; |
| if (resolution[0] != this.portSize[0]) { |
| scaleFactorText = '<br>' + (100 * (this.portSize[0] / resolution[0])).toFixed(0) + |
| '% size output'; |
| } else { |
| scaleFactorText = '<br> '; |
| } |
| |
| $('<li>') |
| .append($('<div>') |
| .addClass('thumb-container') |
| .append($('<img>') |
| .attr('src', 'device-art-resources/' + this.id + '/thumb.png') |
| .attr('height', |
| Math.floor(MAX_DISPLAY_HEIGHT * this.physicalHeight / MAX_HEIGHT)))) |
| .append($('<div>') |
| .addClass('device-details') |
| .html((this.url |
| ? ('<a class="device-url" href="' + this.url + '">' + this.title + '</a>') |
| : this.title) + |
| '<br>' + this.physicalSize + '" @ ' + this.density + |
| '<br>' + (resolution[0] + 'x' + resolution[1]) + scaleFactorText)) |
| .data('deviceId', this.id) |
| .appendTo(this.archived ? '.device-list.archive' : '.device-list.primary'); |
| }); |
| |
| // Set up "older devices" expando. |
| $('#archive-expando').click(function() { |
| if ($(this).hasClass('expanded')) { |
| $(this).removeClass('expanded'); |
| $('.device-list.archive').removeClass('expanded'); |
| } else { |
| $(this).addClass('expanded'); |
| $('.device-list.archive').addClass('expanded'); |
| } |
| return false; |
| }); |
| |
| // Set up drag and drop. |
| $('.device-list li') |
| .live('dragover', function(evt) { |
| $(this).addClass('drag-hover'); |
| evt.dataTransfer.dropEffect = 'link'; |
| evt.preventDefault(); |
| }) |
| .live('dragleave', function(evt) { |
| $(this).removeClass('drag-hover'); |
| }) |
| .live('drop', function(evt) { |
| $('#output').empty().html(MSG_GENERATING_IMAGE); |
| $(this).removeClass('drag-hover'); |
| g_currentDevice = getDeviceById($(this).closest('li').data('deviceId')); |
| evt.preventDefault(); |
| loadImageFromFileList(evt.dataTransfer.files, function(data) { |
| if (data == null) { |
| $('#output').html(MSG_INVALID_INPUT_IMAGE); |
| return; |
| } |
| loadImageFromUri(data.uri, function(img) { |
| g_currentFilename = data.name; |
| g_currentImage = img; |
| createFrame(); |
| // Send the event to Analytics |
| _gaq.push(['_trackEvent', 'Distribute', 'Create Device Art', g_currentDevice.title]); |
| }); |
| }); |
| }); |
| |
| // Set up rotate button. |
| $('#rotate-button').click(function() { |
| if (!g_currentImage) { |
| return; |
| } |
| |
| var w = g_currentImage.naturalHeight; |
| var h = g_currentImage.naturalWidth; |
| var canvas = $('<canvas>') |
| .attr('width', w) |
| .attr('height', h) |
| .get(0); |
| |
| var ctx = canvas.getContext('2d'); |
| ctx.rotate(-Math.PI / 2); |
| ctx.translate(-h, 0); |
| ctx.drawImage(g_currentImage, 0, 0); |
| |
| loadImageFromUri(canvas.toDataURL('image/png'), function(img) { |
| g_currentImage = img; |
| createFrame(); |
| }); |
| }); |
| } |
| |
| /** |
| * Generates the frame from the current selections (g_currentImage and g_currentDevice). |
| */ |
| function createFrame() { |
| var port; |
| |
| var aspect1 = g_currentImage.naturalWidth / g_currentImage.naturalHeight; |
| var aspect2 = g_currentDevice.portSize[0] / g_currentDevice.portSize[1]; |
| |
| if (aspect1 == aspect2) { |
| port = true; |
| } else if (aspect1 == 1 / aspect2) { |
| port = false; |
| } else { |
| alert('The screenshot must have an aspect ratio of ' + |
| aspect2.toFixed(3) + ' or ' + (1 / aspect2).toFixed(3) + |
| ' (ideally ' + g_currentDevice.portSize[0] + 'x' + g_currentDevice.portSize[1] + |
| ' or ' + g_currentDevice.portSize[1] + 'x' + g_currentDevice.portSize[0] + ').'); |
| $('#output').html(MSG_INVALID_INPUT_IMAGE); |
| return; |
| } |
| |
| // Load image resources |
| var res = port ? g_currentDevice.portRes : g_currentDevice.landRes; |
| var resList = {}; |
| for (var i = 0; i < res.length; i++) { |
| resList[res[i]] = 'device-art-resources/' + g_currentDevice.id + '/' + |
| (port ? 'port_' : 'land_') + res[i] + '.png' |
| } |
| |
| var resourceImages = {}; |
| loadImageResources(resList, function(r) { |
| resourceImages = r; |
| continueWithResources_(); |
| }); |
| |
| function continueWithResources_() { |
| var width = resourceImages['back'].naturalWidth; |
| var height = resourceImages['back'].naturalHeight; |
| var offset = port ? g_currentDevice.portOffset : g_currentDevice.landOffset; |
| var size = port |
| ? g_currentDevice.portSize |
| : [g_currentDevice.portSize[1], g_currentDevice.portSize[0]]; |
| |
| var canvas = document.createElement('canvas'); |
| canvas.width = width; |
| canvas.height = height; |
| |
| var ctx = canvas.getContext('2d'); |
| if (resourceImages['shadow'] && $('#output-shadow').is(':checked')) { |
| ctx.drawImage(resourceImages['shadow'], 0, 0); |
| } |
| ctx.drawImage(resourceImages['back'], 0, 0); |
| ctx.fillStyle = '#000'; |
| ctx.fillRect(offset[0], offset[1], size[0], size[1]); |
| ctx.drawImage(g_currentImage, offset[0], offset[1], size[0], size[1]); |
| if (resourceImages['fore'] && $('#output-glare').is(':checked')) { |
| ctx.drawImage(resourceImages['fore'], 0, 0); |
| } |
| |
| window.URL = window.URL || window.webkitURL; |
| if (canvas.toBlob && window.URL.createObjectURL) { |
| if (g_currentObjectURL) { |
| window.URL.revokeObjectURL(g_currentObjectURL); |
| g_currentObjectURL = null; |
| } |
| if (g_currentBlob) { |
| if (g_currentBlob.close) { |
| g_currentBlob.close(); |
| } |
| g_currentBlob = null; |
| } |
| |
| canvas.toBlob(function(blob) { |
| if (!blob) { |
| continueWithFinalUrl_(canvas.toDataURL('image/png')); |
| return; |
| } |
| g_currentBlob = blob; |
| g_currentObjectURL = window.URL.createObjectURL(blob); |
| continueWithFinalUrl_(g_currentObjectURL); |
| }, 'image/png'); |
| } else { |
| continueWithFinalUrl_(canvas.toDataURL('image/png')); |
| } |
| } |
| |
| function continueWithFinalUrl_(imageUrl) { |
| var filename = g_currentFilename |
| ? g_currentFilename.replace(/^(.+?)(\.\w+)?$/, '$1_framed.png') |
| : 'framed_screenshot.png'; |
| |
| var $link = $('<a>') |
| .attr('download', filename) |
| .attr('href', imageUrl) |
| .append($('<img>') |
| .addClass('dragout') |
| .attr('src', imageUrl) |
| .attr('draggable', true) |
| .attr('data-downloadurl', ['image/png', filename, imageUrl].join(':'))) |
| .appendTo($('#output').empty()); |
| |
| $('#frame-customizations').show(); |
| } |
| } |
| |
| /** |
| * Loads an image from a data URI. The callback will be called with the <img> once |
| * it loads. |
| */ |
| function loadImageFromUri(uri, callback) { |
| callback = callback || function(){}; |
| |
| var img = document.createElement('img'); |
| img.src = uri; |
| img.onload = function() { |
| callback(img); |
| }; |
| img.onerror = function() { |
| callback(null); |
| } |
| } |
| |
| /** |
| * Loads a set of images (organized by ID). Once all images are loaded, the callback |
| * is triggered with a dictionary of <img>'s, organized by ID. |
| */ |
| function loadImageResources(images, callback) { |
| var imageResources = {}; |
| |
| var checkForCompletion_ = function() { |
| for (var id in images) { |
| if (!(id in imageResources)) |
| return; |
| } |
| (callback || function(){})(imageResources); |
| callback = null; |
| }; |
| |
| for (var id in images) { |
| var img = document.createElement('img'); |
| img.src = images[id]; |
| (function(img, id) { |
| img.onload = function() { |
| imageResources[id] = img; |
| checkForCompletion_(); |
| }; |
| img.onerror = function() { |
| imageResources[id] = null; |
| checkForCompletion_(); |
| } |
| })(img, id); |
| } |
| } |
| |
| /** |
| * Loads the first valid image from a FileList (e.g. drag + drop source), as a data URI. This |
| * method will throw an alert() in case of errors and call back with null. |
| * |
| * @param {FileList} fileList The FileList to load. |
| * @param {Function} callback The callback to fire once image loading is done (or fails). |
| * @return Returns an object containing 'uri' representing the loaded image. There will also be |
| * a 'name' field indicating the file name, if one is available. |
| */ |
| function loadImageFromFileList(fileList, callback) { |
| fileList = fileList || []; |
| |
| var file = null; |
| for (var i = 0; i < fileList.length; i++) { |
| if (fileList[i].type.toLowerCase().match(/^image\/(png|jpeg|jpg)/)) { |
| file = fileList[i]; |
| break; |
| } |
| } |
| |
| if (!file) { |
| alert('Please use a valid screenshot file (PNG or JPEG format).'); |
| callback(null); |
| return; |
| } |
| |
| var fileReader = new FileReader(); |
| |
| // Closure to capture the file information. |
| fileReader.onload = function(e) { |
| callback({ |
| uri: e.target.result, |
| name: file.name |
| }); |
| }; |
| fileReader.onerror = function(e) { |
| switch(e.target.error.code) { |
| case e.target.error.NOT_FOUND_ERR: |
| alert('File not found.'); |
| break; |
| case e.target.error.NOT_READABLE_ERR: |
| alert('File is not readable.'); |
| break; |
| case e.target.error.ABORT_ERR: |
| break; // noop |
| default: |
| alert('An error occurred reading this file.'); |
| } |
| callback(null); |
| }; |
| fileReader.onabort = function(e) { |
| alert('File read cancelled.'); |
| callback(null); |
| }; |
| |
| fileReader.readAsDataURL(file); |
| } |
| |
| /** |
| * Adds a simple version of Canvas.toBlob if toBlob isn't available. |
| */ |
| function polyfillCanvasToBlob() { |
| if (!HTMLCanvasElement.prototype.toBlob && window.Blob) { |
| HTMLCanvasElement.prototype.toBlob = function(callback, mimeType, quality) { |
| if (typeof callback != 'function') { |
| throw new TypeError('Function expected'); |
| } |
| var dataURL = this.toDataURL(mimeType, quality); |
| mimeType = dataURL.split(';')[0].split(':')[1]; |
| var bs = window.atob(dataURL.split(',')[1]); |
| if (dataURL == 'data:,' || !bs.length) { |
| callback(null); |
| return; |
| } |
| for (var ui8arr = new Uint8Array(bs.length), i = 0; i < bs.length; ++i) { |
| ui8arr[i] = bs.charCodeAt(i); |
| } |
| callback(new Blob([ui8arr.buffer /* req'd for Safari */ || ui8arr], {type: mimeType})); |
| }; |
| } |
| } |
| </script> |