• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1page.title=Device Art Generator
2page.image=/images/device-art-ex-crop.jpg
3page.metaDescription=为了更好看的宣传图片和改善视觉语境,拖放你的应用程序的屏幕截图到真实设备的图稿。
4
5@jd:body
6
7<p>你可以使用 Device Art Generator 方便快捷地将应用截图嵌入到真实设备的效果图中。这样,当用户在你的网站上或其他宣传材料中看到你的应用截图时,就能更加直观地了解应用的内容环境</p>
8
9  <p class="note"><strong>注意</strong>:请勿将此处生成的图片用作 Google Play 应用商品详情中的 1024x500 置顶大图或屏幕截图</p>
10
11
12
13<div class="supported-browser">
14
15<div class="layout-content-row">
16  <div class="layout-content-col span-3">
17    <h4>第 1 步</h4>
18    <p>将屏幕截图从桌面拖动到右侧的设备上生成效果图。</p>
19  </div>
20  <div class="layout-content-col span-10">
21    <ul class="device-list primary"></ul>
22    <a href="#" id="archive-expando">旧款设备</a>
23    <ul class="device-list archive"></ul>
24  </div>
25</div>
26
27
28
29<div class="layout-content-row">
30  <div class="layout-content-col span-3">
31      <h4>第 2 步</h4>
32      <p>你可以对此效果图进行优化,然后将其拖动到桌面上保存。</p>
33    <p id="frame-customizations">
34      <input type="checkbox" id="output-shadow" checked="checked" class="form-field-checkbutton">
35      <label for="output-shadow">阴影</label><br>
36      <input type="checkbox" id="output-glare" checked="checked" class="form-field-checkbutton">
37      <label for="output-glare">屏幕反光</label><br><br>
38      <a class="button" id="rotate-button">旋转</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">无输入图片。</div>
45  </div>
46</div>
47
48</div>
49
50<div class="unsupported-browser" style="display: none">
51  <p class="warning"><strong>错误</strong>:此页面需要使用<span id="unsupported-browser-reason">特定功能</span>才能打开,但你的网络浏览器不支持这些功能。要继续,请在受支持的网络浏览器(如 <strong>Google Chrome</strong>)中打开此页面。</p>
52    <a href="https://www.google.com/chrome/" class="button">下载 Google Chrome</a>
53  <br><br>
54</div>
55
56<style>
57  h4 {
58    text-transform: uppercase;
59  }
60
61  .device-list {
62    padding: 1em 0 0 0;
63    margin: 0;
64  }
65
66  .device-list li {
67    display: inline-block;
68    vertical-align: bottom;
69    margin: 0;
70    margin-right: 20px;
71    text-align: center;
72  }
73
74  .device-list li .thumb-container {
75    display: inline-block;
76  }
77
78  .device-list li .thumb-container img {
79    margin-bottom: 8px;
80    opacity: 0.6;
81
82    -webkit-transition: -webkit-transform 0.2s, opacity 0.2s;
83       -moz-transition:    -moz-transform 0.2s, opacity 0.2s;
84            transition:         transform 0.2s, opacity 0.2s;
85  }
86
87  .device-list li.drag-hover .thumb-container img {
88    opacity: 1;
89
90    -webkit-transform: scale(1.1);
91       -moz-transform: scale(1.1);
92            transform: scale(1.1);
93  }
94
95  .device-list li .device-details {
96    font-size: 13px;
97    line-height: 16px;
98    color: #888;
99  }
100
101  .device-list li .device-url {
102    font-weight: bold;
103  }
104
105  #archive-expando {
106    display: block;
107    font-size: 13px;
108    font-weight: bold;
109    color: #333;
110    text-transform: uppercase;
111    margin-top: 16px;
112    padding-top: 16px;
113    padding-left: 28px;
114    border-top: 1px solid transparent;
115    background: transparent url({@docRoot}assets/images/styles/disclosure_down.png)
116                no-repeat scroll 0 8px;
117    -webkit-transition: border 0.2s;
118       -moz-transition: border 0.2s;
119            transition: border 0.2s;
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  .device-list.archive {
128    max-height: 0;
129    overflow: hidden;
130    opacity: 0;
131
132    -webkit-transition: max-height 0.2s, opacity 0.2s;
133       -moz-transition: max-height 0.2s, opacity 0.2s;
134            transition: max-height 0.2s, opacity 0.2s;
135  }
136
137  .device-list.archive.expanded {
138    opacity: 1;
139    max-height: 300px;
140  }
141
142  #output {
143    color: #f44;
144    font-style: italic;
145  }
146
147  #output img {
148    max-height: 500px;
149  }
150</style>
151<script>
152  // Global variables
153  var g_currentImage;
154  var g_currentDevice;
155  var g_currentObjectURL;
156  var g_currentBlob;
157
158  // Global constants
159  var MSG_INVALID_INPUT_IMAGE = 'Invalid screenshot provided. Screenshots must be PNG files '
160      + 'matching the target device\'s screen aspect ratio in either portrait or landscape.';
161  var MSG_NO_INPUT_IMAGE = '将屏幕截图(.PNG)从桌面拖动到以上所列设备。';
162  var MSG_GENERATING_IMAGE = 'Generating device art&hellip;';
163
164  var MAX_DISPLAY_HEIGHT = 126; // XOOM, to fit into 200px wide
165
166  // Device manifest.
167  var DEVICES = [
168    {
169      id: 'nexus_5',
170      title: 'Nexus 5',
171      url: 'http://www.google.com/nexus/5/',
172      physicalSize: 5,
173      physicalHeight: 5.43,
174      density: 'XXHDPI',
175      landRes: ['shadow', 'back', 'fore'],
176      landOffset: [436,306],
177      portRes: ['shadow', 'back', 'fore'],
178      portOffset: [304,436],
179      portSize: [1080,1920],
180    },
181    {
182      id: 'nexus_6',
183      title: 'Nexus 6',
184      url: 'http://www.google.com/nexus/6/',
185      physicalSize: 6,
186      physicalHeight: 6.27,
187      density: '560DPI',
188      landRes: ['shadow', 'back', 'fore'],
189      landOffset: [489,327],
190      portRes: ['shadow', 'back', 'fore'],
191      portOffset: [327,489],
192      portSize: [1440, 2560],
193    },
194    {
195      id: 'nexus_7',
196      title: 'Nexus 7',
197      url: 'http://www.google.com/nexus/7/',
198      physicalSize: 7,
199      physicalHeight: 8,
200      actualResolution: [1200,1920],
201      density: 'XHDPI',
202      landRes: ['shadow', 'back', 'fore'],
203      landOffset: [326,245],
204      portRes: ['shadow', 'back', 'fore'],
205      portOffset: [244,326],
206      portSize: [800,1280]
207    },
208    {
209      id: 'nexus_9',
210      title: 'Nexus 9',
211      url: 'http://www.google.com/nexus/9/',
212      physicalSize: 9,
213      physicalHeight: 8.98,
214      actualResolution: [1536,2048],
215      density: 'XHDPI',
216      landRes: ['shadow', 'back', 'fore'],
217      landOffset: [514,350],
218      portRes: ['shadow', 'back', 'fore'],
219      portOffset: [348,514],
220      portSize: [1536,2048],
221    },
222    {
223      id: 'nexus_10',
224      title: 'Nexus 10',
225      url: 'http://www.google.com/nexus/10/',
226      physicalSize: 10,
227      physicalHeight: 7,
228      actualResolution: [1600,2560],
229      density: 'XHDPI',
230      landRes: ['shadow', 'back', 'fore'],
231      landOffset: [227,217],
232      portRes: ['shadow', 'back', 'fore'],
233      portOffset: [217,223],
234      portSize: [800,1280],
235      archived: true
236    },
237    {
238      id: 'nexus_7_2012',
239      title: 'Nexus 7 (2012)',
240      url: 'http://www.google.com/nexus/7/',
241      physicalSize: 7,
242      physicalHeight: 7.81,
243      density: '213dpi',
244      landRes: ['shadow', 'back', 'fore'],
245      landOffset: [315,270],
246      portRes: ['shadow', 'back', 'fore'],
247      portOffset: [264,311],
248      portSize: [800,1280],
249      archived: true
250    },
251    {
252      id: 'nexus_4',
253      title: 'Nexus 4',
254      url: 'http://www.google.com/nexus/4/',
255      physicalSize: 4.7,
256      physicalHeight: 5.27,
257      density: 'XHDPI',
258      landRes: ['shadow', 'back', 'fore'],
259      landOffset: [349,214],
260      portRes: ['shadow', 'back', 'fore'],
261      portOffset: [213,350],
262      portSize: [768,1280],
263      archived: true
264    },
265  ];
266
267  DEVICES = DEVICES.sort(function(x, y) { return x.physicalSize - y.physicalSize; });
268
269  var MAX_HEIGHT = 0;
270  for (var i = 0; i < DEVICES.length; i++) {
271    MAX_HEIGHT = Math.max(MAX_HEIGHT, DEVICES[i].physicalHeight);
272  }
273
274  // Setup performed once the DOM is ready.
275  $(document).ready(function() {
276    if (!checkBrowser()) {
277      return;
278    }
279
280    polyfillCanvasToBlob();
281    setupUI();
282
283    // Set up Chrome drag-out
284    $.event.props.push("dataTransfer");
285    document.body.addEventListener('dragstart', function(e) {
286      var target = e.target;
287      if (target.classList.contains('dragout')) {
288        e.dataTransfer.setData('DownloadURL', target.dataset.downloadurl);
289      }
290    }, false);
291  });
292
293  /**
294   * Returns the device from DEVICES with the given id.
295   */
296  function getDeviceById(id) {
297    for (var i = 0; i < DEVICES.length; i++) {
298      if (DEVICES[i].id == id)
299        return DEVICES[i];
300    }
301    return;
302  }
303
304  /**
305   * Checks to make sure the browser supports this page. If not,
306   * updates the UI accordingly and returns false.
307   */
308  function checkBrowser() {
309    // Check for browser support
310    var browserSupportError = null;
311
312    // Must have <canvas>
313    var elem = document.createElement('canvas');
314    if (!elem.getContext || !elem.getContext('2d')) {
315      browserSupportError = 'HTML5 canvas.';
316    }
317
318    // Must have FileReader
319    if (!window.FileReader) {
320      browserSupportError = 'desktop file access';
321    }
322
323    if (browserSupportError) {
324      $('.supported-browser').hide();
325
326      $('#unsupported-browser-reason').html(browserSupportError);
327      $('.unsupported-browser').show();
328      return false;
329    }
330
331    return true;
332  }
333
334  function setupUI() {
335    $('#output').html(MSG_NO_INPUT_IMAGE);
336
337    $('#frame-customizations').hide();
338
339    $('#output-shadow, #output-glare').click(function() {
340      createFrame();
341    });
342
343    // Build device list.
344    $.each(DEVICES, function() {
345      var resolution = this.actualResolution || this.portSize;
346      var scaleFactorText = '';
347      if (resolution[0] != this.portSize[0]) {
348        scaleFactorText = '<br>等比例缩小至' + (100 * (this.portSize[0] / resolution[0])).toFixed(0) +
349            '% 输出';
350      } else {
351        scaleFactorText = '<br>&nbsp;';
352      }
353
354      $('<li>')
355          .append($('<div>')
356              .addClass('thumb-container')
357              .append($('<img>')
358                  .attr('src', '../../../../../distribute/tools/promote/device-art-resources/' + this.id + '/thumb.png')
359                  .attr('height',
360                      Math.floor(MAX_DISPLAY_HEIGHT * this.physicalHeight / MAX_HEIGHT))))
361          .append($('<div>')
362              .addClass('device-details')
363              .html((this.url
364                  ? ('<a class="device-url" href="' + this.url + '">' + this.title + '</a>')
365                  : this.title) +
366                  '<br>' +  this.physicalSize + '" @ ' + this.density +
367                  '<br>' + (resolution[0] + 'x' + resolution[1]) + scaleFactorText))
368          .data('deviceId', this.id)
369          .appendTo(this.archived ? '.device-list.archive' : '.device-list.primary');
370    });
371
372    // Set up "older devices" expando.
373    $('#archive-expando').click(function() {
374      if ($(this).hasClass('expanded')) {
375        $(this).removeClass('expanded');
376        $('.device-list.archive').removeClass('expanded');
377      } else {
378        $(this).addClass('expanded');
379        $('.device-list.archive').addClass('expanded');
380      }
381      return false;
382    });
383
384    // Set up drag and drop.
385    $('.device-list li')
386        .live('dragover', function(evt) {
387          $(this).addClass('drag-hover');
388          evt.dataTransfer.dropEffect = 'link';
389          evt.preventDefault();
390        })
391        .live('dragleave', function(evt) {
392          $(this).removeClass('drag-hover');
393        })
394        .live('drop', function(evt) {
395          $('#output').empty().html(MSG_GENERATING_IMAGE);
396          $(this).removeClass('drag-hover');
397          g_currentDevice = getDeviceById($(this).closest('li').data('deviceId'));
398          evt.preventDefault();
399          loadImageFromFileList(evt.dataTransfer.files, function(data) {
400            if (data == null) {
401              $('#output').html(MSG_INVALID_INPUT_IMAGE);
402              return;
403            }
404            loadImageFromUri(data.uri, function(img) {
405              g_currentFilename = data.name;
406              g_currentImage = img;
407              createFrame();
408              // Send the event to Analytics
409              ga('send', 'event', 'Distribute', 'Create Device Art', g_currentDevice.title);
410            });
411          });
412        });
413
414    // Set up rotate button.
415    $('#rotate-button').click(function() {
416      if (!g_currentImage) {
417        return;
418      }
419
420      var w = g_currentImage.naturalHeight;
421      var h = g_currentImage.naturalWidth;
422      var canvas = $('<canvas>')
423          .attr('width', w)
424          .attr('height', h)
425          .get(0);
426
427      var ctx = canvas.getContext('2d');
428      ctx.rotate(-Math.PI / 2);
429      ctx.translate(-h, 0);
430      ctx.drawImage(g_currentImage, 0, 0);
431
432      loadImageFromUri(canvas.toDataURL('image/png'), function(img) {
433        g_currentImage = img;
434        createFrame();
435      });
436    });
437  }
438
439  /**
440   * Generates the frame from the current selections (g_currentImage and g_currentDevice).
441   */
442  function createFrame() {
443    var port;
444
445    var aspect1 = g_currentImage.naturalWidth / g_currentImage.naturalHeight;
446    var aspect2 = g_currentDevice.portSize[0] / g_currentDevice.portSize[1];
447
448    if (aspect1 == aspect2) {
449      port = true;
450    } else if (aspect1 == 1 / aspect2) {
451      port = false;
452    } else {
453      alert('The screenshot must have an aspect ratio of ' +
454          aspect2.toFixed(3) + ' or ' + (1 / aspect2).toFixed(3) +
455          ' (ideally ' + g_currentDevice.portSize[0] + 'x' + g_currentDevice.portSize[1] +
456          ' or ' + g_currentDevice.portSize[1] + 'x' + g_currentDevice.portSize[0] + ').');
457      $('#output').html(MSG_INVALID_INPUT_IMAGE);
458      return;
459    }
460
461    // Load image resources
462    var res = port ? g_currentDevice.portRes : g_currentDevice.landRes;
463    var resList = {};
464    for (var i = 0; i < res.length; i++) {
465      resList[res[i]] = '../../../../../distribute/tools/promote/device-art-resources/' + g_currentDevice.id + '/' +
466          (port ? 'port_' : 'land_') + res[i] + '.png'
467    }
468
469    var resourceImages = {};
470    loadImageResources(resList, function(r) {
471      resourceImages = r;
472      continueWithResources_();
473    });
474
475    function continueWithResources_() {
476      var width = resourceImages['back'].naturalWidth;
477      var height = resourceImages['back'].naturalHeight;
478      var offset = port ? g_currentDevice.portOffset : g_currentDevice.landOffset;
479      var size = port
480          ? g_currentDevice.portSize
481          : [g_currentDevice.portSize[1], g_currentDevice.portSize[0]];
482
483      var canvas = document.createElement('canvas');
484      canvas.width = width;
485      canvas.height = height;
486
487      var ctx = canvas.getContext('2d');
488      if (resourceImages['shadow'] && $('#output-shadow').is(':checked')) {
489        ctx.drawImage(resourceImages['shadow'], 0, 0);
490      }
491      ctx.drawImage(resourceImages['back'], 0, 0);
492      ctx.fillStyle = '#000';
493      ctx.fillRect(offset[0], offset[1], size[0], size[1]);
494      ctx.drawImage(g_currentImage, offset[0], offset[1], size[0], size[1]);
495      if (resourceImages['fore'] && $('#output-glare').is(':checked')) {
496        ctx.drawImage(resourceImages['fore'], 0, 0);
497      }
498
499      window.URL = window.URL || window.webkitURL;
500      if (canvas.toBlob && window.URL.createObjectURL) {
501        if (g_currentObjectURL) {
502          window.URL.revokeObjectURL(g_currentObjectURL);
503          g_currentObjectURL = null;
504        }
505        if (g_currentBlob) {
506          if (g_currentBlob.close) {
507            g_currentBlob.close();
508          }
509          g_currentBlob = null;
510        }
511
512        canvas.toBlob(function(blob) {
513          if (!blob) {
514            continueWithFinalUrl_(canvas.toDataURL('image/png'));
515            return;
516          }
517          g_currentBlob = blob;
518          g_currentObjectURL = window.URL.createObjectURL(blob);
519          continueWithFinalUrl_(g_currentObjectURL);
520        }, 'image/png');
521      } else {
522        continueWithFinalUrl_(canvas.toDataURL('image/png'));
523      }
524    }
525
526    function continueWithFinalUrl_(imageUrl) {
527      var filename = g_currentFilename
528          ? g_currentFilename.replace(/^(.+?)(\.\w+)?$/, '$1_framed.png')
529          : 'framed_screenshot.png';
530
531      var $link = $('<a>')
532          .attr('download', filename)
533          .attr('href', imageUrl)
534          .append($('<img>')
535              .addClass('dragout')
536              .attr('src', imageUrl)
537              .attr('draggable', true)
538              .attr('data-downloadurl', ['image/png', filename, imageUrl].join(':')))
539          .appendTo($('#output').empty());
540
541      $('#frame-customizations').show();
542    }
543  }
544
545  /**
546   * Loads an image from a data URI. The callback will be called with the <img> once
547   * it loads.
548   */
549  function loadImageFromUri(uri, callback) {
550    callback = callback || function(){};
551
552    var img = document.createElement('img');
553    img.src = uri;
554    img.onload = function() {
555      callback(img);
556    };
557    img.onerror = function() {
558      callback(null);
559    }
560  }
561
562  /**
563   * Loads a set of images (organized by ID). Once all images are loaded, the callback
564   * is triggered with a dictionary of <img>'s, organized by ID.
565   */
566  function loadImageResources(images, callback) {
567    var imageResources = {};
568
569    var checkForCompletion_ = function() {
570      for (var id in images) {
571        if (!(id in imageResources))
572          return;
573      }
574      (callback || function(){})(imageResources);
575      callback = null;
576    };
577
578    for (var id in images) {
579      var img = document.createElement('img');
580      img.src = images[id];
581      (function(img, id) {
582        img.onload = function() {
583          imageResources[id] = img;
584          checkForCompletion_();
585        };
586        img.onerror = function() {
587          imageResources[id] = null;
588          checkForCompletion_();
589        }
590      })(img, id);
591    }
592  }
593
594  /**
595   * Loads the first valid image from a FileList (e.g. drag + drop source), as a data URI. This
596   * method will throw an alert() in case of errors and call back with null.
597   *
598   * @param {FileList} fileList The FileList to load.
599   * @param {Function} callback The callback to fire once image loading is done (or fails).
600   * @return Returns an object containing 'uri' representing the loaded image. There will also be
601   *      a 'name' field indicating the file name, if one is available.
602   */
603  function loadImageFromFileList(fileList, callback) {
604    fileList = fileList || [];
605
606    var file = null;
607    for (var i = 0; i < fileList.length; i++) {
608      if (fileList[i].type.toLowerCase().match(/^image\/(png|jpeg|jpg)/)) {
609        file = fileList[i];
610        break;
611      }
612    }
613
614    if (!file) {
615      alert('Please use a valid screenshot file (PNG or JPEG format).');
616      callback(null);
617      return;
618    }
619
620    var fileReader = new FileReader();
621
622    // Closure to capture the file information.
623    fileReader.onload = function(e) {
624      callback({
625        uri: e.target.result,
626        name: file.name
627      });
628    };
629    fileReader.onerror = function(e) {
630      switch(e.target.error.code) {
631        case e.target.error.NOT_FOUND_ERR:
632          alert('File not found.');
633          break;
634        case e.target.error.NOT_READABLE_ERR:
635          alert('File is not readable.');
636          break;
637        case e.target.error.ABORT_ERR:
638          break; // noop
639        default:
640          alert('An error occurred reading this file.');
641      }
642      callback(null);
643    };
644    fileReader.onabort = function(e) {
645      alert('File read cancelled.');
646      callback(null);
647    };
648
649    fileReader.readAsDataURL(file);
650  }
651
652  /**
653   * Adds a simple version of Canvas.toBlob if toBlob isn't available.
654   */
655  function polyfillCanvasToBlob() {
656    if (!HTMLCanvasElement.prototype.toBlob && window.Blob) {
657      HTMLCanvasElement.prototype.toBlob = function(callback, mimeType, quality) {
658        if (typeof callback != 'function') {
659          throw new TypeError('Function expected');
660        }
661        var dataURL = this.toDataURL(mimeType, quality);
662        mimeType = dataURL.split(';')[0].split(':')[1];
663        var bs = window.atob(dataURL.split(',')[1]);
664        if (dataURL == 'data:,' || !bs.length) {
665          callback(null);
666          return;
667        }
668        for (var ui8arr = new Uint8Array(bs.length), i = 0; i < bs.length; ++i) {
669          ui8arr[i] = bs.charCodeAt(i);
670        }
671        callback(new Blob([ui8arr.buffer /* req'd for Safari */ || ui8arr], {type: mimeType}));
672      };
673    }
674  }
675</script>
676