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