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 <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