1 page.title=Device Art Generator 2 @jd:body 3 4 <p>The device art generator allows you to quickly wrap your app screenshots in real device artwork. 5 This provides better visual context for your app screenshots on your web site or in other 6 promotional materials.</p> 7 8 <p class="note"><strong>Note</strong>: Do <em>not</em> use graphics created here in your 1024x500 9 feature image or screenshots for your Google Play app listing.</p> 10 11 <hr> 12 13 <div class="supported-browser"> 14 15 <div class="layout-content-row"> 16 <div class="layout-content-col span-3"> 17 <h4>Step 1</h4> 18 <p>Drag a screenshot from your desktop onto a device to the right.</p> 19 </div> 20 <div class="layout-content-col span-10"> 21 <ul id="device-list"></ul> 22 </div> 23 </div> 24 25 <hr> 26 27 <div class="layout-content-row"> 28 <div class="layout-content-col span-3"> 29 <h4>Step 2</h4> 30 <p>Customize the generated image and drag it to your desktop to save.</p> 31 <p id="frame-customizations"> 32 <input type="checkbox" id="output-shadow" checked="checked" class="form-field-checkbutton"> 33 <label for="output-shadow">Shadow</label><br> 34 <input type="checkbox" id="output-glare" checked="checked" class="form-field-checkbutton"> 35 <label for="output-glare">Screen Glare</label><br><br> 36 <a class="button" id="rotate-button">Rotate</a> 37 </p> 38 </div> 39 <div class="layout-content-col span-10"> 40 <div id="output">No input image.</div> 41 </div> 42 </div> 43 44 </div> 45 46 <div class="unsupported-browser" style="display: none"> 47 <p class="warning"><strong>Error:</strong> This page requires 48 <span id="unsupported-browser-reason">certain features</span>, which your web browser 49 doesn't support. To continue, navigate to this page on a supported web browser, such as 50 <strong>Google Chrome</strong>.</p> 51 <a href="https://www.google.com/chrome/" class="button">Get Google Chrome</a> 52 <br><br> 53 </div> 54 55 <style> 56 h4 { 57 text-transform: uppercase; 58 } 59 60 #device-list { 61 padding: 0; 62 margin: 0; 63 } 64 65 #device-list li { 66 display: inline-block; 67 vertical-align: bottom; 68 margin: 0; 69 margin-right: 20px; 70 text-align: center; 71 } 72 73 #device-list li .thumb-container { 74 display: inline-block; 75 } 76 77 #device-list li .thumb-container img { 78 margin-bottom: 8px; 79 opacity: 0.6; 80 81 -webkit-transition: -webkit-transform 0.2s, opacity 0.2s; 82 -moz-transition: -moz-transform 0.2s, opacity 0.2s; 83 transition: transform 0.2s, opacity 0.2s; 84 } 85 86 #device-list li.drag-hover .thumb-container img { 87 opacity: 1; 88 89 -webkit-transform: scale(1.1); 90 -moz-transform: scale(1.1); 91 transform: scale(1.1); 92 } 93 94 #device-list li .device-details { 95 font-size: 13px; 96 line-height: 16px; 97 color: #888; 98 } 99 100 #device-list li .device-url { 101 font-weight: bold; 102 } 103 104 #output { 105 color: #f44; 106 font-style: italic; 107 } 108 109 #output img { 110 max-height: 500px; 111 } 112 </style> 113 <script> 114 // Global variables 115 var g_currentImage; 116 var g_currentDevice; 117 118 // Global constants 119 var MSG_INVALID_INPUT_IMAGE = 'Invalid screenshot provided. Screenshots must be PNG files ' 120 + 'matching the target device\'s screen resolution in either portrait or landscape.'; 121 var MSG_NO_INPUT_IMAGE = 'Drag a screenshot (in PNG format) from your desktop onto a ' 122 + 'target device above.' 123 var MSG_GENERATING_IMAGE = 'Generating device art…'; 124 125 var MAX_DISPLAY_HEIGHT = 126; // XOOM, to fit into 200px wide 126 127 // Device manifest. 128 var DEVICES = [ 129 { 130 id: 'nexus_7', 131 title: 'Nexus 7', 132 url: 'http://www.android.com/devices/detail/nexus-7', 133 physicalSize: 7, 134 physicalHeight: 7.81, 135 density: 213, 136 landRes: ['shadow', 'back', 'fore'], 137 landOffset: [363,260], 138 portRes: ['shadow', 'back', 'fore'], 139 portOffset: [265,341], 140 portSize: [800,1280], 141 }, 142 { 143 id: 'xoom', 144 title: 'Motorola XOOM', 145 url: 'http://www.google.com/phone/detail/motorola-xoom', 146 physicalSize: 10, 147 physicalHeight: 6.61, 148 density: 160, 149 landRes: ['shadow', 'back', 'fore'], 150 landOffset: [218,191], 151 portRes: ['shadow', 'back', 'fore'], 152 portOffset: [199,200], 153 portSize: [800,1280], 154 }, 155 { 156 id: 'galaxy_nexus', 157 title: 'Galaxy Nexus', 158 url: 'http://www.android.com/devices/detail/galaxy-nexus', 159 physicalSize: 4.65, 160 physicalHeight: 5.33, 161 density: 320, 162 landRes: ['shadow', 'back', 'fore'], 163 landOffset: [371,199], 164 portRes: ['shadow', 'back', 'fore'], 165 portOffset: [216,353], 166 portSize: [720,1280], 167 }, 168 { 169 id: 'nexus_s', 170 title: 'Nexus S', 171 url: 'http://www.google.com/phone/detail/nexus-s', 172 physicalSize: 4.0, 173 physicalHeight: 4.88, 174 density: 240, 175 landRes: ['shadow', 'back', 'fore'], 176 landOffset: [247,135], 177 portRes: ['shadow', 'back', 'fore'], 178 portOffset: [134,247], 179 portSize: [480,800], 180 } 181 ]; 182 183 DEVICES = DEVICES.sort(function(x, y) { return x.physicalSize - y.physicalSize; }); 184 185 var MAX_HEIGHT = 0; 186 for (var i = 0; i < DEVICES.length; i++) { 187 MAX_HEIGHT = Math.max(MAX_HEIGHT, DEVICES[i].physicalHeight); 188 } 189 190 // Setup performed once the DOM is ready. 191 $(document).ready(function() { 192 if (!checkBrowser()) { 193 return; 194 } 195 196 setupUI(); 197 198 // Set up Chrome drag-out 199 $.event.props.push("dataTransfer"); 200 document.body.addEventListener('dragstart', function(e) { 201 var a = e.target; 202 if (a.classList.contains('dragout')) { 203 e.dataTransfer.setData('DownloadURL', a.dataset.downloadurl); 204 } 205 }, false); 206 }); 207 208 /** 209 * Returns the device from DEVICES with the given id. 210 */ 211 function getDeviceById(id) { 212 for (var i = 0; i < DEVICES.length; i++) { 213 if (DEVICES[i].id == id) 214 return DEVICES[i]; 215 } 216 return; 217 } 218 219 /** 220 * Checks to make sure the browser supports this page. If not, 221 * updates the UI accordingly and returns false. 222 */ 223 function checkBrowser() { 224 // Check for browser support 225 var browserSupportError = null; 226 227 // Must have <canvas> 228 var elem = document.createElement('canvas'); 229 if (!elem.getContext || !elem.getContext('2d')) { 230 browserSupportError = 'HTML5 canvas.'; 231 } 232 233 // Must have FileReader 234 if (!window.FileReader) { 235 browserSupportError = 'desktop file access'; 236 } 237 238 if (browserSupportError) { 239 $('.supported-browser').hide(); 240 241 $('#unsupported-browser-reason').html(browserSupportError); 242 $('.unsupported-browser').show(); 243 return false; 244 } 245 246 return true; 247 } 248 249 function setupUI() { 250 $('#output').html(MSG_NO_INPUT_IMAGE); 251 252 $('#frame-customizations').hide(); 253 254 $('#output-shadow, #output-glare').click(function() { 255 createFrame(g_currentDevice, g_currentImage); 256 }); 257 258 // Build device list. 259 $.each(DEVICES, function() { 260 $('<li>') 261 .append($('<div>') 262 .addClass('thumb-container') 263 .append($('<img>') 264 .attr('src', 'device-art-resources/' + this.id + '/thumb.png') 265 .attr('height', 266 Math.floor(MAX_DISPLAY_HEIGHT * this.physicalHeight / MAX_HEIGHT)))) 267 .append($('<div>') 268 .addClass('device-details') 269 .html((this.url 270 ? ('<a class="device-url" href="' + this.url + '">' + this.title + '</a>') 271 : this.title) + 272 '<br>' + this.physicalSize + '" @ ' + this.density + 'dpi' + 273 '<br>' + this.portSize[0] + 'x' + this.portSize[1])) 274 .data('deviceId', this.id) 275 .appendTo('#device-list'); 276 }); 277 278 // Set up drag and drop. 279 $('#device-list li') 280 .live('dragover', function(evt) { 281 $(this).addClass('drag-hover'); 282 evt.dataTransfer.dropEffect = 'link'; 283 evt.preventDefault(); 284 }) 285 .live('dragleave', function(evt) { 286 $(this).removeClass('drag-hover'); 287 }) 288 .live('drop', function(evt) { 289 $('#output').empty().html(MSG_GENERATING_IMAGE); 290 $(this).removeClass('drag-hover'); 291 g_currentDevice = getDeviceById($(this).closest('li').data('deviceId')); 292 evt.preventDefault(); 293 loadImageFromFileList(evt.dataTransfer.files, function(data) { 294 if (data == null) { 295 $('#output').html(MSG_INVALID_INPUT_IMAGE); 296 return; 297 } 298 loadImageFromUri(data.uri, function(img) { 299 g_currentFilename = data.name; 300 g_currentImage = img; 301 createFrame(); 302 }); 303 }); 304 }); 305 306 // Set up rotate button. 307 $('#rotate-button').click(function() { 308 if (!g_currentImage) { 309 return; 310 } 311 312 var w = g_currentImage.naturalHeight; 313 var h = g_currentImage.naturalWidth; 314 var canvas = $('<canvas>') 315 .attr('width', w) 316 .attr('height', h) 317 .get(0); 318 319 var ctx = canvas.getContext('2d'); 320 ctx.rotate(-Math.PI / 2); 321 ctx.translate(-h, 0); 322 ctx.drawImage(g_currentImage, 0, 0); 323 324 loadImageFromUri(canvas.toDataURL(), function(img) { 325 g_currentImage = img; 326 createFrame(); 327 }); 328 }); 329 } 330 331 /** 332 * Generates the frame from the current selections (g_currentImage and g_currentDevice). 333 */ 334 function createFrame() { 335 var port; 336 if (g_currentImage.naturalWidth == g_currentDevice.portSize[0] && 337 g_currentImage.naturalHeight == g_currentDevice.portSize[1]) { 338 if (!g_currentDevice.portRes) { 339 alert('No portrait frame is currently available for this device.'); 340 $('#output').html(MSG_NO_INPUT_IMAGE); 341 return; 342 } 343 port = true; 344 } else if (g_currentImage.naturalWidth == g_currentDevice.portSize[1] && 345 g_currentImage.naturalHeight == g_currentDevice.portSize[0]) { 346 if (!g_currentDevice.landRes) { 347 alert('No landscape frame is currently available for this device.'); 348 $('#output').html(MSG_NO_INPUT_IMAGE); 349 return; 350 } 351 port = false; 352 } else { 353 alert('Screenshots for ' + g_currentDevice.title + ' must be ' + 354 g_currentDevice.portSize[0] + 'x' + g_currentDevice.portSize[1] + 355 ' or ' + 356 g_currentDevice.portSize[1] + 'x' + g_currentDevice.portSize[0] + '.'); 357 $('#output').html(MSG_INVALID_INPUT_IMAGE); 358 return; 359 } 360 361 // Load image resources 362 var res = port ? g_currentDevice.portRes : g_currentDevice.landRes; 363 var resList = {}; 364 for (var i = 0; i < res.length; i++) { 365 resList[res[i]] = 'device-art-resources/' + g_currentDevice.id + '/' + 366 (port ? 'port_' : 'land_') + res[i] + '.png' 367 } 368 369 var resourceImages = {}; 370 loadImageResources(resList, function(r) { 371 resourceImages = r; 372 continuation_(); 373 }); 374 375 function continuation_() { 376 var width = resourceImages['back'].naturalWidth; 377 var height = resourceImages['back'].naturalHeight; 378 var offset = port ? g_currentDevice.portOffset : g_currentDevice.landOffset; 379 380 var canvas = document.createElement('canvas'); 381 canvas.width = width; 382 canvas.height = height; 383 384 var ctx = canvas.getContext('2d'); 385 if (resourceImages['shadow'] && $('#output-shadow').is(':checked')) { 386 ctx.drawImage(resourceImages['shadow'], 0, 0); 387 } 388 ctx.drawImage(resourceImages['back'], 0, 0); 389 ctx.drawImage(g_currentImage, offset[0], offset[1]); 390 if (resourceImages['fore'] && $('#output-glare').is(':checked')) { 391 ctx.drawImage(resourceImages['fore'], 0, 0); 392 } 393 394 var dataUrl = canvas.toDataURL(); 395 var filename = g_currentFilename 396 ? ('framed_' + g_currentFilename) 397 : 'framed_screenshot.png'; 398 399 var $link = $('<a>') 400 .attr('download', filename) 401 .attr('href', dataUrl) 402 .attr('draggable', true) 403 .attr('data-downloadurl', ['image/png', filename, dataUrl].join(':')) 404 .append($('<img>').attr('src', dataUrl)) 405 .appendTo($('#output').empty()); 406 407 $('#frame-customizations').show(); 408 } 409 } 410 411 /** 412 * Loads an image from a data URI. The callback will be called with the <img> once 413 * it loads. 414 */ 415 function loadImageFromUri(uri, callback) { 416 callback = callback || function(){}; 417 418 var img = document.createElement('img'); 419 img.src = uri; 420 img.onload = function() { 421 callback(img); 422 }; 423 img.onerror = function() { 424 callback(null); 425 } 426 } 427 428 /** 429 * Loads a set of images (organized by ID). Once all images are loaded, the callback 430 * is triggered with a dictionary of <img>'s, organized by ID. 431 */ 432 function loadImageResources(images, callback) { 433 var imageResources = {}; 434 435 var checkForCompletion_ = function() { 436 for (var id in images) { 437 if (!(id in imageResources)) 438 return; 439 } 440 (callback || function(){})(imageResources); 441 callback = null; 442 }; 443 444 for (var id in images) { 445 var img = document.createElement('img'); 446 img.src = images[id]; 447 (function(img, id) { 448 img.onload = function() { 449 imageResources[id] = img; 450 checkForCompletion_(); 451 }; 452 img.onerror = function() { 453 imageResources[id] = null; 454 checkForCompletion_(); 455 } 456 })(img, id); 457 } 458 } 459 460 /** 461 * Loads the first valid image from a FileList (e.g. drag + drop source), as a data URI. This 462 * method will throw an alert() in case of errors and call back with null. 463 * 464 * @param {FileList} fileList The FileList to load. 465 * @param {Function} callback The callback to fire once image loading is done (or fails). 466 * @return Returns an object containing 'uri' representing the loaded image. There will also be 467 * a 'name' field indicating the file name, if one is available. 468 */ 469 function loadImageFromFileList(fileList, callback) { 470 fileList = fileList || []; 471 472 var file = null; 473 for (var i = 0; i < fileList.length; i++) { 474 if (fileList[i].type.toLowerCase().match(/^image\/png/)) { 475 file = fileList[i]; 476 break; 477 } 478 } 479 480 if (!file) { 481 alert('Please use a valid screenshot file (PNG format).'); 482 callback(null); 483 return; 484 } 485 486 var fileReader = new FileReader(); 487 488 // Closure to capture the file information. 489 fileReader.onload = function(e) { 490 callback({ 491 uri: e.target.result, 492 name: file.name 493 }); 494 }; 495 fileReader.onerror = function(e) { 496 switch(e.target.error.code) { 497 case e.target.error.NOT_FOUND_ERR: 498 alert('File not found.'); 499 break; 500 case e.target.error.NOT_READABLE_ERR: 501 alert('File is not readable.'); 502 break; 503 case e.target.error.ABORT_ERR: 504 break; // noop 505 default: 506 alert('An error occurred reading this file.'); 507 } 508 callback(null); 509 }; 510 fileReader.onabort = function(e) { 511 alert('File read cancelled.'); 512 callback(null); 513 }; 514 515 fileReader.readAsDataURL(file); 516 } 517 </script> 518