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 class="device-list primary"></ul> 22 <a href="#" id="archive-expando">Older devices</a> 23 <ul class="device-list archive"></ul> 24 </div> 25 </div> 26 27 <hr> 28 29 <div class="layout-content-row"> 30 <div class="layout-content-col span-3"> 31 <h4>Step 2</h4> 32 <p>Customize the generated image and drag it to your desktop to save.</p> 33 <p id="frame-customizations"> 34 <input type="checkbox" id="output-shadow" checked="checked" class="form-field-checkbutton"> 35 <label for="output-shadow">Shadow</label><br> 36 <input type="checkbox" id="output-glare" checked="checked" class="form-field-checkbutton"> 37 <label for="output-glare">Screen Glare</label><br><br> 38 <a class="button" id="rotate-button">Rotate</a> 39 </p> 40 </div> 41 <div class="layout-content-col span-10"> 42 <!-- position:relative fixes an issue where dragging an image out of a inline-block container 43 produced no drag feedback image in Chrome 28. --> 44 <div id="output" style="position:relative">No input image.</div> 45 </div> 46 </div> 47 48 </div> 49 50 <div class="unsupported-browser" style="display: none"> 51 <p class="warning"><strong>Error:</strong> This page requires 52 <span id="unsupported-browser-reason">certain features</span>, which your web browser 53 doesn't support. To continue, navigate to this page on a supported web browser, such as 54 <strong>Google Chrome</strong>.</p> 55 <a href="https://www.google.com/chrome/" class="button">Get Google Chrome</a> 56 <br><br> 57 </div> 58 59 <style> 60 h4 { 61 text-transform: uppercase; 62 } 63 64 .device-list { 65 padding: 0; 66 margin: 0; 67 } 68 69 .device-list li { 70 display: inline-block; 71 vertical-align: bottom; 72 margin: 0; 73 margin-right: 20px; 74 text-align: center; 75 } 76 77 .device-list li .thumb-container { 78 display: inline-block; 79 } 80 81 .device-list li .thumb-container img { 82 margin-bottom: 8px; 83 opacity: 0.6; 84 85 -webkit-transition: -webkit-transform 0.2s, opacity 0.2s; 86 -moz-transition: -moz-transform 0.2s, opacity 0.2s; 87 transition: transform 0.2s, opacity 0.2s; 88 } 89 90 .device-list li.drag-hover .thumb-container img { 91 opacity: 1; 92 93 -webkit-transform: scale(1.1); 94 -moz-transform: scale(1.1); 95 transform: scale(1.1); 96 } 97 98 .device-list li .device-details { 99 font-size: 13px; 100 line-height: 16px; 101 color: #888; 102 } 103 104 .device-list li .device-url { 105 font-weight: bold; 106 } 107 108 #archive-expando { 109 display: block; 110 font-size: 13px; 111 font-weight: bold; 112 color: #333; 113 text-transform: uppercase; 114 margin-top: 16px; 115 padding-top: 16px; 116 padding-left: 28px; 117 border-top: 1px solid transparent; 118 background: transparent url({@docRoot}assets/images/styles/disclosure_down.png) 119 no-repeat scroll 0 8px; 120 -webkit-transition: border 0.2s; 121 -moz-transition: border 0.2s; 122 transition: border 0.2s; 123 } 124 125 #archive-expando.expanded { 126 background-image: url({@docRoot}assets/images/styles/disclosure_up.png); 127 border-top: 1px solid #ccc; 128 } 129 130 .device-list.archive { 131 max-height: 0; 132 overflow: hidden; 133 opacity: 0; 134 135 -webkit-transition: max-height 0.2s, opacity 0.2s; 136 -moz-transition: max-height 0.2s, opacity 0.2s; 137 transition: max-height 0.2s, opacity 0.2s; 138 } 139 140 .device-list.archive.expanded { 141 opacity: 1; 142 max-height: 300px; 143 } 144 145 #output { 146 color: #f44; 147 font-style: italic; 148 } 149 150 #output img { 151 max-height: 500px; 152 } 153 </style> 154 <script> 155 // Global variables 156 var g_currentImage; 157 var g_currentDevice; 158 var g_currentObjectURL; 159 var g_currentBlob; 160 161 // Global constants 162 var MSG_INVALID_INPUT_IMAGE = 'Invalid screenshot provided. Screenshots must be PNG files ' 163 + 'matching the target device\'s screen aspect ratio in either portrait or landscape.'; 164 var MSG_NO_INPUT_IMAGE = 'Drag a screenshot (in PNG format) from your desktop onto a ' 165 + 'target device above.' 166 var MSG_GENERATING_IMAGE = 'Generating device art…'; 167 168 var MAX_DISPLAY_HEIGHT = 126; // XOOM, to fit into 200px wide 169 170 // Device manifest. 171 var DEVICES = [ 172 { 173 id: 'nexus_5', 174 title: 'Nexus 5', 175 url: 'http://www.google.com/nexus/5/', 176 physicalSize: 5, 177 physicalHeight: 5.43, 178 density: 'XXHDPI', 179 landRes: ['shadow', 'back', 'fore'], 180 landOffset: [436,306], 181 portRes: ['shadow', 'back', 'fore'], 182 portOffset: [304,436], 183 portSize: [1080,1920], 184 }, 185 { 186 id: 'nexus_7', 187 title: 'Nexus 7', 188 url: 'http://www.google.com/nexus/7/', 189 physicalSize: 7, 190 physicalHeight: 8, 191 actualResolution: [1200,1920], 192 density: 'XHDPI', 193 landRes: ['shadow', 'back', 'fore'], 194 landOffset: [326,245], 195 portRes: ['shadow', 'back', 'fore'], 196 portOffset: [244,326], 197 portSize: [800,1280] 198 }, 199 { 200 id: 'nexus_10', 201 title: 'Nexus 10', 202 url: 'http://www.google.com/nexus/10/', 203 physicalSize: 10, 204 physicalHeight: 7, 205 actualResolution: [1600,2560], 206 density: 'XHDPI', 207 landRes: ['shadow', 'back', 'fore'], 208 landOffset: [227,217], 209 portRes: ['shadow', 'back', 'fore'], 210 portOffset: [217,223], 211 portSize: [800,1280] 212 }, 213 { 214 id: 'xoom', 215 title: 'Motorola XOOM', 216 url: 'http://www.google.com/phone/detail/motorola-xoom', 217 physicalSize: 10, 218 physicalHeight: 6.61, 219 density: 'MDPI', 220 landRes: ['shadow', 'back', 'fore'], 221 landOffset: [218,191], 222 portRes: ['shadow', 'back', 'fore'], 223 portOffset: [199,200], 224 portSize: [800,1280], 225 archived: true 226 }, 227 { 228 id: 'nexus_7_2012', 229 title: 'Nexus 7 (2012)', 230 url: 'http://www.google.com/nexus/7/', 231 physicalSize: 7, 232 physicalHeight: 7.81, 233 density: '213dpi', 234 landRes: ['shadow', 'back', 'fore'], 235 landOffset: [315,270], 236 portRes: ['shadow', 'back', 'fore'], 237 portOffset: [264,311], 238 portSize: [800,1280], 239 archived: true 240 }, 241 { 242 id: 'nexus_4', 243 title: 'Nexus 4', 244 url: 'http://www.google.com/nexus/4/', 245 physicalSize: 4.7, 246 physicalHeight: 5.27, 247 density: 'XHDPI', 248 landRes: ['shadow', 'back', 'fore'], 249 landOffset: [349,214], 250 portRes: ['shadow', 'back', 'fore'], 251 portOffset: [213,350], 252 portSize: [768,1280], 253 archived: true 254 }, 255 { 256 id: 'galaxy_nexus', 257 title: 'Galaxy Nexus', 258 url: 'http://www.android.com/devices/detail/galaxy-nexus', 259 physicalSize: 4.65, 260 physicalHeight: 5.33, 261 density: 'XHDPI', 262 landRes: ['shadow', 'back', 'fore'], 263 landOffset: [371,199], 264 portRes: ['shadow', 'back', 'fore'], 265 portOffset: [216,353], 266 portSize: [720,1280], 267 archived: true 268 }, 269 { 270 id: 'nexus_s', 271 title: 'Nexus S', 272 url: 'http://www.google.com/phone/detail/nexus-s', 273 physicalSize: 4.0, 274 physicalHeight: 4.88, 275 density: 'HDPI', 276 landRes: ['shadow', 'back', 'fore'], 277 landOffset: [247,135], 278 portRes: ['shadow', 'back', 'fore'], 279 portOffset: [134,247], 280 portSize: [480,800], 281 archived: true 282 } 283 ]; 284 285 DEVICES = DEVICES.sort(function(x, y) { return x.physicalSize - y.physicalSize; }); 286 287 var MAX_HEIGHT = 0; 288 for (var i = 0; i < DEVICES.length; i++) { 289 MAX_HEIGHT = Math.max(MAX_HEIGHT, DEVICES[i].physicalHeight); 290 } 291 292 // Setup performed once the DOM is ready. 293 $(document).ready(function() { 294 if (!checkBrowser()) { 295 return; 296 } 297 298 polyfillCanvasToBlob(); 299 setupUI(); 300 301 // Set up Chrome drag-out 302 $.event.props.push("dataTransfer"); 303 document.body.addEventListener('dragstart', function(e) { 304 var target = e.target; 305 if (target.classList.contains('dragout')) { 306 e.dataTransfer.setData('DownloadURL', target.dataset.downloadurl); 307 } 308 }, false); 309 }); 310 311 /** 312 * Returns the device from DEVICES with the given id. 313 */ 314 function getDeviceById(id) { 315 for (var i = 0; i < DEVICES.length; i++) { 316 if (DEVICES[i].id == id) 317 return DEVICES[i]; 318 } 319 return; 320 } 321 322 /** 323 * Checks to make sure the browser supports this page. If not, 324 * updates the UI accordingly and returns false. 325 */ 326 function checkBrowser() { 327 // Check for browser support 328 var browserSupportError = null; 329 330 // Must have <canvas> 331 var elem = document.createElement('canvas'); 332 if (!elem.getContext || !elem.getContext('2d')) { 333 browserSupportError = 'HTML5 canvas.'; 334 } 335 336 // Must have FileReader 337 if (!window.FileReader) { 338 browserSupportError = 'desktop file access'; 339 } 340 341 if (browserSupportError) { 342 $('.supported-browser').hide(); 343 344 $('#unsupported-browser-reason').html(browserSupportError); 345 $('.unsupported-browser').show(); 346 return false; 347 } 348 349 return true; 350 } 351 352 function setupUI() { 353 $('#output').html(MSG_NO_INPUT_IMAGE); 354 355 $('#frame-customizations').hide(); 356 357 $('#output-shadow, #output-glare').click(function() { 358 createFrame(); 359 }); 360 361 // Build device list. 362 $.each(DEVICES, function() { 363 var resolution = this.actualResolution || this.portSize; 364 var scaleFactorText = ''; 365 if (resolution[0] != this.portSize[0]) { 366 scaleFactorText = '<br>' + (100 * (this.portSize[0] / resolution[0])).toFixed(0) + 367 '% size output'; 368 } else { 369 scaleFactorText = '<br> '; 370 } 371 372 $('<li>') 373 .append($('<div>') 374 .addClass('thumb-container') 375 .append($('<img>') 376 .attr('src', 'device-art-resources/' + this.id + '/thumb.png') 377 .attr('height', 378 Math.floor(MAX_DISPLAY_HEIGHT * this.physicalHeight / MAX_HEIGHT)))) 379 .append($('<div>') 380 .addClass('device-details') 381 .html((this.url 382 ? ('<a class="device-url" href="' + this.url + '">' + this.title + '</a>') 383 : this.title) + 384 '<br>' + this.physicalSize + '" @ ' + this.density + 385 '<br>' + (resolution[0] + 'x' + resolution[1]) + scaleFactorText)) 386 .data('deviceId', this.id) 387 .appendTo(this.archived ? '.device-list.archive' : '.device-list.primary'); 388 }); 389 390 // Set up "older devices" expando. 391 $('#archive-expando').click(function() { 392 if ($(this).hasClass('expanded')) { 393 $(this).removeClass('expanded'); 394 $('.device-list.archive').removeClass('expanded'); 395 } else { 396 $(this).addClass('expanded'); 397 $('.device-list.archive').addClass('expanded'); 398 } 399 return false; 400 }); 401 402 // Set up drag and drop. 403 $('.device-list li') 404 .live('dragover', function(evt) { 405 $(this).addClass('drag-hover'); 406 evt.dataTransfer.dropEffect = 'link'; 407 evt.preventDefault(); 408 }) 409 .live('dragleave', function(evt) { 410 $(this).removeClass('drag-hover'); 411 }) 412 .live('drop', function(evt) { 413 $('#output').empty().html(MSG_GENERATING_IMAGE); 414 $(this).removeClass('drag-hover'); 415 g_currentDevice = getDeviceById($(this).closest('li').data('deviceId')); 416 evt.preventDefault(); 417 loadImageFromFileList(evt.dataTransfer.files, function(data) { 418 if (data == null) { 419 $('#output').html(MSG_INVALID_INPUT_IMAGE); 420 return; 421 } 422 loadImageFromUri(data.uri, function(img) { 423 g_currentFilename = data.name; 424 g_currentImage = img; 425 createFrame(); 426 // Send the event to Analytics 427 _gaq.push(['_trackEvent', 'Distribute', 'Create Device Art', g_currentDevice.title]); 428 }); 429 }); 430 }); 431 432 // Set up rotate button. 433 $('#rotate-button').click(function() { 434 if (!g_currentImage) { 435 return; 436 } 437 438 var w = g_currentImage.naturalHeight; 439 var h = g_currentImage.naturalWidth; 440 var canvas = $('<canvas>') 441 .attr('width', w) 442 .attr('height', h) 443 .get(0); 444 445 var ctx = canvas.getContext('2d'); 446 ctx.rotate(-Math.PI / 2); 447 ctx.translate(-h, 0); 448 ctx.drawImage(g_currentImage, 0, 0); 449 450 loadImageFromUri(canvas.toDataURL('image/png'), function(img) { 451 g_currentImage = img; 452 createFrame(); 453 }); 454 }); 455 } 456 457 /** 458 * Generates the frame from the current selections (g_currentImage and g_currentDevice). 459 */ 460 function createFrame() { 461 var port; 462 463 var aspect1 = g_currentImage.naturalWidth / g_currentImage.naturalHeight; 464 var aspect2 = g_currentDevice.portSize[0] / g_currentDevice.portSize[1]; 465 466 if (aspect1 == aspect2) { 467 port = true; 468 } else if (aspect1 == 1 / aspect2) { 469 port = false; 470 } else { 471 alert('The screenshot must have an aspect ratio of ' + 472 aspect2.toFixed(3) + ' or ' + (1 / aspect2).toFixed(3) + 473 ' (ideally ' + g_currentDevice.portSize[0] + 'x' + g_currentDevice.portSize[1] + 474 ' or ' + g_currentDevice.portSize[1] + 'x' + g_currentDevice.portSize[0] + ').'); 475 $('#output').html(MSG_INVALID_INPUT_IMAGE); 476 return; 477 } 478 479 // Load image resources 480 var res = port ? g_currentDevice.portRes : g_currentDevice.landRes; 481 var resList = {}; 482 for (var i = 0; i < res.length; i++) { 483 resList[res[i]] = 'device-art-resources/' + g_currentDevice.id + '/' + 484 (port ? 'port_' : 'land_') + res[i] + '.png' 485 } 486 487 var resourceImages = {}; 488 loadImageResources(resList, function(r) { 489 resourceImages = r; 490 continueWithResources_(); 491 }); 492 493 function continueWithResources_() { 494 var width = resourceImages['back'].naturalWidth; 495 var height = resourceImages['back'].naturalHeight; 496 var offset = port ? g_currentDevice.portOffset : g_currentDevice.landOffset; 497 var size = port 498 ? g_currentDevice.portSize 499 : [g_currentDevice.portSize[1], g_currentDevice.portSize[0]]; 500 501 var canvas = document.createElement('canvas'); 502 canvas.width = width; 503 canvas.height = height; 504 505 var ctx = canvas.getContext('2d'); 506 if (resourceImages['shadow'] && $('#output-shadow').is(':checked')) { 507 ctx.drawImage(resourceImages['shadow'], 0, 0); 508 } 509 ctx.drawImage(resourceImages['back'], 0, 0); 510 ctx.fillStyle = '#000'; 511 ctx.fillRect(offset[0], offset[1], size[0], size[1]); 512 ctx.drawImage(g_currentImage, offset[0], offset[1], size[0], size[1]); 513 if (resourceImages['fore'] && $('#output-glare').is(':checked')) { 514 ctx.drawImage(resourceImages['fore'], 0, 0); 515 } 516 517 window.URL = window.URL || window.webkitURL; 518 if (canvas.toBlob && window.URL.createObjectURL) { 519 if (g_currentObjectURL) { 520 window.URL.revokeObjectURL(g_currentObjectURL); 521 g_currentObjectURL = null; 522 } 523 if (g_currentBlob) { 524 if (g_currentBlob.close) { 525 g_currentBlob.close(); 526 } 527 g_currentBlob = null; 528 } 529 530 canvas.toBlob(function(blob) { 531 if (!blob) { 532 continueWithFinalUrl_(canvas.toDataURL('image/png')); 533 return; 534 } 535 g_currentBlob = blob; 536 g_currentObjectURL = window.URL.createObjectURL(blob); 537 continueWithFinalUrl_(g_currentObjectURL); 538 }, 'image/png'); 539 } else { 540 continueWithFinalUrl_(canvas.toDataURL('image/png')); 541 } 542 } 543 544 function continueWithFinalUrl_(imageUrl) { 545 var filename = g_currentFilename 546 ? g_currentFilename.replace(/^(.+?)(\.\w+)?$/, '$1_framed.png') 547 : 'framed_screenshot.png'; 548 549 var $link = $('<a>') 550 .attr('download', filename) 551 .attr('href', imageUrl) 552 .append($('<img>') 553 .addClass('dragout') 554 .attr('src', imageUrl) 555 .attr('draggable', true) 556 .attr('data-downloadurl', ['image/png', filename, imageUrl].join(':'))) 557 .appendTo($('#output').empty()); 558 559 $('#frame-customizations').show(); 560 } 561 } 562 563 /** 564 * Loads an image from a data URI. The callback will be called with the <img> once 565 * it loads. 566 */ 567 function loadImageFromUri(uri, callback) { 568 callback = callback || function(){}; 569 570 var img = document.createElement('img'); 571 img.src = uri; 572 img.onload = function() { 573 callback(img); 574 }; 575 img.onerror = function() { 576 callback(null); 577 } 578 } 579 580 /** 581 * Loads a set of images (organized by ID). Once all images are loaded, the callback 582 * is triggered with a dictionary of <img>'s, organized by ID. 583 */ 584 function loadImageResources(images, callback) { 585 var imageResources = {}; 586 587 var checkForCompletion_ = function() { 588 for (var id in images) { 589 if (!(id in imageResources)) 590 return; 591 } 592 (callback || function(){})(imageResources); 593 callback = null; 594 }; 595 596 for (var id in images) { 597 var img = document.createElement('img'); 598 img.src = images[id]; 599 (function(img, id) { 600 img.onload = function() { 601 imageResources[id] = img; 602 checkForCompletion_(); 603 }; 604 img.onerror = function() { 605 imageResources[id] = null; 606 checkForCompletion_(); 607 } 608 })(img, id); 609 } 610 } 611 612 /** 613 * Loads the first valid image from a FileList (e.g. drag + drop source), as a data URI. This 614 * method will throw an alert() in case of errors and call back with null. 615 * 616 * @param {FileList} fileList The FileList to load. 617 * @param {Function} callback The callback to fire once image loading is done (or fails). 618 * @return Returns an object containing 'uri' representing the loaded image. There will also be 619 * a 'name' field indicating the file name, if one is available. 620 */ 621 function loadImageFromFileList(fileList, callback) { 622 fileList = fileList || []; 623 624 var file = null; 625 for (var i = 0; i < fileList.length; i++) { 626 if (fileList[i].type.toLowerCase().match(/^image\/(png|jpeg|jpg)/)) { 627 file = fileList[i]; 628 break; 629 } 630 } 631 632 if (!file) { 633 alert('Please use a valid screenshot file (PNG or JPEG format).'); 634 callback(null); 635 return; 636 } 637 638 var fileReader = new FileReader(); 639 640 // Closure to capture the file information. 641 fileReader.onload = function(e) { 642 callback({ 643 uri: e.target.result, 644 name: file.name 645 }); 646 }; 647 fileReader.onerror = function(e) { 648 switch(e.target.error.code) { 649 case e.target.error.NOT_FOUND_ERR: 650 alert('File not found.'); 651 break; 652 case e.target.error.NOT_READABLE_ERR: 653 alert('File is not readable.'); 654 break; 655 case e.target.error.ABORT_ERR: 656 break; // noop 657 default: 658 alert('An error occurred reading this file.'); 659 } 660 callback(null); 661 }; 662 fileReader.onabort = function(e) { 663 alert('File read cancelled.'); 664 callback(null); 665 }; 666 667 fileReader.readAsDataURL(file); 668 } 669 670 /** 671 * Adds a simple version of Canvas.toBlob if toBlob isn't available. 672 */ 673 function polyfillCanvasToBlob() { 674 if (!HTMLCanvasElement.prototype.toBlob && window.Blob) { 675 HTMLCanvasElement.prototype.toBlob = function(callback, mimeType, quality) { 676 if (typeof callback != 'function') { 677 throw new TypeError('Function expected'); 678 } 679 var dataURL = this.toDataURL(mimeType, quality); 680 mimeType = dataURL.split(';')[0].split(':')[1]; 681 var bs = window.atob(dataURL.split(',')[1]); 682 if (dataURL == 'data:,' || !bs.length) { 683 callback(null); 684 return; 685 } 686 for (var ui8arr = new Uint8Array(bs.length), i = 0; i < bs.length; ++i) { 687 ui8arr[i] = bs.charCodeAt(i); 688 } 689 callback(new Blob([ui8arr.buffer /* req'd for Safari */ || ui8arr], {type: mimeType})); 690 }; 691 } 692 } 693 </script> 694