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