• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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&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