Home | History | Annotate | Download | only in promote
      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&hellip;';
    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>&nbsp;';
    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