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   }
    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&hellip;';
    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>&nbsp;';
    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