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