1 page.title=Device Art Generator
3 <div id="butterbar-wrapper" >
5 <div id="butterbar-message">
6 <a target="_blank" href="https://docs.google.com/a/google.com/forms/d/1EHLPGqhbxj2HungHRRN4_0K9TGpc-Izy-u46vBDgS8Q/viewform">
7 Take the Android Developer Survey</a>
12 <p>The device art generator allows you to quickly wrap your app screenshots in real device artwork.
13 This provides better visual context for your app screenshots on your web site or in other
14 promotional materials.</p>
16 <p class="note"><strong>Note</strong>: Do <em>not</em> use graphics created here in your 1024x500
17 feature image or screenshots for your Google Play app listing.</p>
21 <div class="supported-browser">
23 <div class="layout-content-row">
24 <div class="layout-content-col span-3">
26 <p>Drag a screenshot from your desktop onto a device to the right.</p>
28 <div class="layout-content-col span-10">
29 <ul class="device-list primary"></ul>
30 <a href="#" id="archive-expando">Older devices</a>
31 <ul class="device-list archive"></ul>
37 <div class="layout-content-row">
38 <div class="layout-content-col span-3">
40 <p>Customize the generated image and drag it to your desktop to save.</p>
41 <p id="frame-customizations">
42 <input type="checkbox" id="output-shadow" checked="checked" class="form-field-checkbutton">
43 <label for="output-shadow">Shadow</label><br>
44 <input type="checkbox" id="output-glare" checked="checked" class="form-field-checkbutton">
45 <label for="output-glare">Screen Glare</label><br><br>
46 <a class="button" id="rotate-button">Rotate</a>
49 <div class="layout-content-col span-10">
50 <div id="output">No input image.</div>
56 <div class="unsupported-browser" style="display: none">
57 <p class="warning"><strong>Error:</strong> This page requires
58 <span id="unsupported-browser-reason">certain features</span>, which your web browser
59 doesn't support. To continue, navigate to this page on a supported web browser, such as
60 <strong>Google Chrome</strong>.</p>
61 <a href="https://www.google.com/chrome/" class="button">Get Google Chrome</a>
67 text-transform: uppercase;
76 display: inline-block;
77 vertical-align: bottom;
83 .device-list li .thumb-container {
84 display: inline-block;
87 .device-list li .thumb-container img {
91 -webkit-transition: -webkit-transform 0.2s, opacity 0.2s;
92 -moz-transition: -moz-transform 0.2s, opacity 0.2s;
93 transition: transform 0.2s, opacity 0.2s;
96 .device-list li.drag-hover .thumb-container img {
99 -webkit-transform: scale(1.1);
100 -moz-transform: scale(1.1);
101 transform: scale(1.1);
104 .device-list li .device-details {
110 .device-list li .device-url {
119 text-transform: uppercase;
123 border-top: 1px solid transparent;
124 background: transparent url({@docRoot}assets/images/styles/disclosure_down.png)
125 no-repeat scroll 0 8px;
128 #archive-expando.expanded {
129 background-image: url({@docRoot}assets/images/styles/disclosure_up.png);
130 border-top: 1px solid #ccc;
148 var MSG_INVALID_INPUT_IMAGE = 'Invalid screenshot provided. Screenshots must be PNG files '
149 + 'matching the target device\'s screen aspect ratio in either portrait or landscape.';
150 var MSG_NO_INPUT_IMAGE = 'Drag a screenshot (in PNG format) from your desktop onto a '
151 + 'target device above.'
152 var MSG_GENERATING_IMAGE = 'Generating device art…';
154 var MAX_DISPLAY_HEIGHT = 126; // XOOM, to fit into 200px wide
161 url: 'http://www.google.com/nexus/4/',
163 physicalHeight: 5.23,
165 landRes: ['shadow', 'back', 'fore'],
166 landOffset: [349,214],
167 portRes: ['shadow', 'back', 'fore'],
168 portOffset: [213,350],
174 url: 'http://www.google.com/nexus/7/',
177 actualResolution: [1200,1920],
179 landRes: ['shadow', 'back', 'fore'],
180 landOffset: [326,245],
181 portRes: ['shadow', 'back', 'fore'],
182 portOffset: [244,326],
188 url: 'http://www.google.com/nexus/10/',
191 actualResolution: [1600,2560],
193 landRes: ['shadow', 'back', 'fore'],
194 landOffset: [227,217],
195 portRes: ['shadow', 'back', 'fore'],
196 portOffset: [217,223],
201 title: 'Motorola XOOM',
202 url: 'http://www.google.com/phone/detail/motorola-xoom',
204 physicalHeight: 6.61,
206 landRes: ['shadow', 'back', 'fore'],
207 landOffset: [218,191],
208 portRes: ['shadow', 'back', 'fore'],
209 portOffset: [199,200],
210 portSize: [800,1280],
215 title: 'Nexus 7 (2012)',
216 url: 'http://www.google.com/nexus/7/',
218 physicalHeight: 7.81,
220 landRes: ['shadow', 'back', 'fore'],
221 landOffset: [315,270],
222 portRes: ['shadow', 'back', 'fore'],
223 portOffset: [264,311],
224 portSize: [800,1280],
229 title: 'Galaxy Nexus',
230 url: 'http://www.android.com/devices/detail/galaxy-nexus',
232 physicalHeight: 5.33,
234 landRes: ['shadow', 'back', 'fore'],
235 landOffset: [371,199],
236 portRes: ['shadow', 'back', 'fore'],
237 portOffset: [216,353],
238 portSize: [720,1280],
244 url: 'http://www.google.com/phone/detail/nexus-s',
246 physicalHeight: 4.88,
248 landRes: ['shadow', 'back', 'fore'],
249 landOffset: [247,135],
250 portRes: ['shadow', 'back', 'fore'],
251 portOffset: [134,247],
257 DEVICES = DEVICES.sort(function(x, y) { return x.physicalSize - y.physicalSize; });
260 for (var i = 0; i < DEVICES.length; i++) {
261 MAX_HEIGHT = Math.max(MAX_HEIGHT, DEVICES[i].physicalHeight);
264 // Setup performed once the DOM is ready.
265 $(document).ready(function() {
266 if (!checkBrowser()) {
272 // Set up Chrome drag-out
273 $.event.props.push("dataTransfer");
274 document.body.addEventListener('dragstart', function(e) {
276 if (a.classList.contains('dragout')) {
277 e.dataTransfer.setData('DownloadURL', a.dataset.downloadurl);
283 * Returns the device from DEVICES with the given id.
285 function getDeviceById(id) {
286 for (var i = 0; i < DEVICES.length; i++) {
287 if (DEVICES[i].id == id)
294 * Checks to make sure the browser supports this page. If not,
295 * updates the UI accordingly and returns false.
297 function checkBrowser() {
298 // Check for browser support
299 var browserSupportError = null;
301 // Must have <canvas>
302 var elem = document.createElement('canvas');
303 if (!elem.getContext || !elem.getContext('2d')) {
304 browserSupportError = 'HTML5 canvas.';
307 // Must have FileReader
308 if (!window.FileReader) {
309 browserSupportError = 'desktop file access';
312 if (browserSupportError) {
313 $('.supported-browser').hide();
315 $('#unsupported-browser-reason').html(browserSupportError);
316 $('.unsupported-browser').show();
324 $('#output').html(MSG_NO_INPUT_IMAGE);
326 $('#frame-customizations').hide();
327 $('.device-list.archive').hide();
329 $('#output-shadow, #output-glare').click(function() {
333 // Build device list.
334 $.each(DEVICES, function() {
335 var resolution = this.actualResolution || this.portSize;
336 var scaleFactorText = '';
337 if (resolution[0] != this.portSize[0]) {
338 scaleFactorText = '<br>' + (100 * (this.portSize[0] / resolution[0])).toFixed(0) +
341 scaleFactorText = '<br> ';
346 .addClass('thumb-container')
348 .attr('src', 'device-art-resources/' + this.id + '/thumb.png')
350 Math.floor(MAX_DISPLAY_HEIGHT * this.physicalHeight / MAX_HEIGHT))))
352 .addClass('device-details')
354 ? ('<a class="device-url" href="' + this.url + '">' + this.title + '</a>')
356 '<br>' + this.physicalSize + '" @ ' + this.density +
357 '<br>' + (resolution[0] + 'x' + resolution[1]) + scaleFactorText))
358 .data('deviceId', this.id)
359 .appendTo(this.archived ? '.device-list.archive' : '.device-list.primary');
362 // Set up "older devices" expando.
363 $('#archive-expando').click(function() {
364 if ($(this).hasClass('expanded')) {
365 $(this).removeClass('expanded');
366 $('.device-list.archive').hide();
368 $(this).addClass('expanded');
369 $('.device-list.archive').show();
374 // Set up drag and drop.
376 .live('dragover', function(evt) {
377 $(this).addClass('drag-hover');
378 evt.dataTransfer.dropEffect = 'link';
379 evt.preventDefault();
381 .live('dragleave', function(evt) {
382 $(this).removeClass('drag-hover');
384 .live('drop', function(evt) {
385 $('#output').empty().html(MSG_GENERATING_IMAGE);
386 $(this).removeClass('drag-hover');
387 g_currentDevice = getDeviceById($(this).closest('li').data('deviceId'));
388 evt.preventDefault();
389 loadImageFromFileList(evt.dataTransfer.files, function(data) {
391 $('#output').html(MSG_INVALID_INPUT_IMAGE);
394 loadImageFromUri(data.uri, function(img) {
395 g_currentFilename = data.name;
396 g_currentImage = img;
398 // Send the event to Analytics
399 _gaq.push(['_trackEvent', 'Distribute', 'Create Device Art', g_currentDevice.title]);
404 // Set up rotate button.
405 $('#rotate-button').click(function() {
406 if (!g_currentImage) {
410 var w = g_currentImage.naturalHeight;
411 var h = g_currentImage.naturalWidth;
412 var canvas = $('<canvas>')
417 var ctx = canvas.getContext('2d');
418 ctx.rotate(-Math.PI / 2);
419 ctx.translate(-h, 0);
420 ctx.drawImage(g_currentImage, 0, 0);
422 loadImageFromUri(canvas.toDataURL(), function(img) {
423 g_currentImage = img;
430 * Generates the frame from the current selections (g_currentImage and g_currentDevice).
432 function createFrame() {
435 var aspect1 = g_currentImage.naturalWidth / g_currentImage.naturalHeight;
436 var aspect2 = g_currentDevice.portSize[0] / g_currentDevice.portSize[1];
438 if (aspect1 == aspect2) {
440 } else if (aspect1 == 1 / aspect2) {
443 alert('The screenshot must have an aspect ratio of ' +
444 aspect2.toFixed(3) + ' or ' + (1 / aspect2).toFixed(3) +
445 ' (ideally ' + g_currentDevice.portSize[0] + 'x' + g_currentDevice.portSize[1] +
446 ' or ' + g_currentDevice.portSize[1] + 'x' + g_currentDevice.portSize[0] + ').');
447 $('#output').html(MSG_INVALID_INPUT_IMAGE);
451 // Load image resources
452 var res = port ? g_currentDevice.portRes : g_currentDevice.landRes;
454 for (var i = 0; i < res.length; i++) {
455 resList[res[i]] = 'device-art-resources/' + g_currentDevice.id + '/' +
456 (port ? 'port_' : 'land_') + res[i] + '.png'
459 var resourceImages = {};
460 loadImageResources(resList, function(r) {
465 function continuation_() {
466 var width = resourceImages['back'].naturalWidth;
467 var height = resourceImages['back'].naturalHeight;
468 var offset = port ? g_currentDevice.portOffset : g_currentDevice.landOffset;
470 ? g_currentDevice.portSize
471 : [g_currentDevice.portSize[1], g_currentDevice.portSize[0]];
473 var canvas = document.createElement('canvas');
474 canvas.width = width;
475 canvas.height = height;
477 var ctx = canvas.getContext('2d');
478 if (resourceImages['shadow'] && $('#output-shadow').is(':checked')) {
479 ctx.drawImage(resourceImages['shadow'], 0, 0);
481 ctx.drawImage(resourceImages['back'], 0, 0);
482 ctx.fillStyle = '#000';
483 ctx.fillRect(offset[0], offset[1], size[0], size[1]);
484 ctx.drawImage(g_currentImage, offset[0], offset[1], size[0], size[1]);
485 if (resourceImages['fore'] && $('#output-glare').is(':checked')) {
486 ctx.drawImage(resourceImages['fore'], 0, 0);
489 var dataUrl = canvas.toDataURL();
490 var filename = g_currentFilename
491 ? ('framed_' + g_currentFilename)
492 : 'framed_screenshot.png';
495 .attr('download', filename)
496 .attr('href', dataUrl)
497 .attr('draggable', true)
498 .attr('data-downloadurl', ['image/png', filename, dataUrl].join(':'))
499 .append($('<img>').attr('src', dataUrl))
500 .appendTo($('#output').empty());
502 $('#frame-customizations').show();
507 * Loads an image from a data URI. The callback will be called with the <img> once
510 function loadImageFromUri(uri, callback) {
511 callback = callback || function(){};
513 var img = document.createElement('img');
515 img.onload = function() {
518 img.onerror = function() {
524 * Loads a set of images (organized by ID). Once all images are loaded, the callback
525 * is triggered with a dictionary of <img>'s, organized by ID.
527 function loadImageResources(images, callback) {
528 var imageResources = {};
530 var checkForCompletion_ = function() {
531 for (var id in images) {
532 if (!(id in imageResources))
535 (callback || function(){})(imageResources);
539 for (var id in images) {
540 var img = document.createElement('img');
541 img.src = images[id];
543 img.onload = function() {
544 imageResources[id] = img;
545 checkForCompletion_();
547 img.onerror = function() {
548 imageResources[id] = null;
549 checkForCompletion_();
556 * Loads the first valid image from a FileList (e.g. drag + drop source), as a data URI. This
557 * method will throw an alert() in case of errors and call back with null.
559 * @param {FileList} fileList The FileList to load.
560 * @param {Function} callback The callback to fire once image loading is done (or fails).
561 * @return Returns an object containing 'uri' representing the loaded image. There will also be
562 * a 'name' field indicating the file name, if one is available.
564 function loadImageFromFileList(fileList, callback) {
565 fileList = fileList || [];
568 for (var i = 0; i < fileList.length; i++) {
569 if (fileList[i].type.toLowerCase().match(/^image\/png/)) {
576 alert('Please use a valid screenshot file (PNG format).');
581 var fileReader = new FileReader();
583 // Closure to capture the file information.
584 fileReader.onload = function(e) {
586 uri: e.target.result,
590 fileReader.onerror = function(e) {
591 switch(e.target.error.code) {
592 case e.target.error.NOT_FOUND_ERR:
593 alert('File not found.');
595 case e.target.error.NOT_READABLE_ERR:
596 alert('File is not readable.');
598 case e.target.error.ABORT_ERR:
601 alert('An error occurred reading this file.');
605 fileReader.onabort = function(e) {
606 alert('File read cancelled.');
610 fileReader.readAsDataURL(file);