Friday, August 10, 2007

Build a better gallery skimmer - part 1


Apple's web developers could do better. This is the first entry in a big dumb effort to implement a faster and more efficient version of Apple's new skimming functionality than their current .mac gallery implementation.

You have probably seen Apple's new . This is really cool. Surprisingly, though, the implementation is rather pedestrian. 15 seconds with firebug and it's clear - Apple sure doesn't care about doing this efficiently. Maybe they saved a few weeks developing this, but that demo site takes almost 1 minute to load - on my PowerBook G4 over DSL. But, if they did care about speeding it up and just ran out of time, wouldn't they have at least stripped all the whitespace and comments out of their javascript? Here are the last 128 lines from the main gallery.js:



/*

require('core');

Gallery.WebWidgetPanel = Mac.FormView.extend({
isPanel: true,
outlets: ["widgetCode", "photo", "reflection", "flash", "lTitle", 'lInstructionalText', 'lCopySnippetLink', 'lDoneButton'],
widgetCode: ".widgetCode?",
widgetTag: "",

lTitle : Mac.LabelView.extend({localize:true}).outletFor(".lTitle?"),
lInstructionalText : Mac.LabelView.extend({localize:true}).outletFor(".lInstructionalText?"),
lCopySnippetLink : Mac.LabelView.extend({localize:true}).outletFor(".lCopySnippetLink?"),
lDoneButton : Mac.LabelView.extend({
localize:true,
toolTip: "_VisitorExperience.AlbumOptions.WebWidget.Done.Button.Tooltip"
}).outletFor(".lDoneButton?"),


flash: ".flash?",

getSmallest: function() {
if (Gallery.albumController.get("videoSmall"))
{
return Gallery.albumController.get("videoSmall");
}
else
if (Gallery.albumController.get("videoMedium"))
{
return Gallery.albumController.get("videoMedium");
}
else
{
return Gallery.albumController.get("videoLarge");
}


},

generateData: function ()
{

if(Gallery.detailView().isVideo && Gallery.detailView().showOnIndex) {
var height= Gallery.albumController.get("thumbImageHeight") + 16;
var width = 320;
var noScript = false;
var widgetID = 'galleryWidget_' + Math.floor(Math.random() * 100000);
var widgetAlbumTitle = Gallery.albumController.get("title");
var widgetAlbumURL = Gallery.albumController.get("url");
var widgetPosterImg = Gallery.albumController.get("medium");
var widgetMovieURL = this.getSmallest();
var styleTxt ="<style type=\"text/css\" media=\"screen\">.widget_header a { color: white ! important; display: block; text-align: center; font-size: 11px ! important; font-family: \"Helvetica Neue\", helvetica; text-decoration:none ! important; background:transparent url('http://gallery.mac.com/g/flash/gall_link_arrow.png') no-repeat scroll top right; padding-top: 3px; padding-bottom: 0px; padding-right: 16px; margin-right: 0px; height: 12px; padding-left: 5px; text-overflow: ellipsis; overflow: hidden; } .widget_header:hover a { text-decoration: underline ! important; color: white; background-image: url('http://gallery.mac.com/g/flash/gall_link_arrow_on.png'); } .widget_header { overflow: hidden; text-overflow: ellipsis; background: black url('http://gallery.mac.com/g/flash/gallery_widget_bg.png') repeat-x scroll top center; height: 20px; width: 100%; display: block; top: 0px; z-index: 4; cursor: pointer; } .widget_header a{ } .movie_div { display:block; z-index: 1; position: absolute; top: 20px; left: 0px; cursor: pointer; background-color: #000; } .movie_play_badge { width: 40px; height: 30px; position: absolute; left: 50%; margin-left: -20px; display: block; top: 50%; margin-top: -23px; background: transparent url('http://gallery.mac.com/g/flash/movie_play_bg.png') no-repeat top center; filter: progid:DXImageTransform.Microsoft.AlphaImageLoader(src='http://gallery.mac.com/g/flash/movie_play_bg.png'); } .qt_ctrls { background: transparent url('http://gallery.mac.com/g/flash/qt_control_left.gif') no-repeat top left; height: 16px; text-align: right; display:block; margin: 0px; }</style>";

var htmlTxt = '<div id=\"'+ widgetID+ '\" class=\"shared_movie\" style=\"text-align: center; width: ' + width + 'px; display:block; height: '+ (height + 22) + 'px; position: relative;\"><div class=\"widget_header\"><img src=\"http://gallery.mac.com/g/flash/gallery_logo.png\" width=\"21\" height=\"19\" alt=\".Mac Gallery\" style=\"float: left\" /><a title=\"' + widgetAlbumTitle + '\" href=\"'+ widgetAlbumURL + '\" target=\"_blank\">' + widgetAlbumTitle + '</a></div><div id=\"movieDiv\" class=\"movie_div\" ><a id=\"movieLink\" class=\"movie_link\" href=\"'+ widgetAlbumURL + '/' + widgetMovieURL + '\" target=\"_blank\"><img id=\"playBadge\" class=\"movie_play_badge\" src=\"http://gallery.mac.com/g/flash/movie_play_badge.png\" border=\"0\" width=\"40\" height=\"30\" alt=\"Play Movie\" /><img class=\"movie_poster\" id=\"moviePoster\" src=\"' + widgetPosterImg + '" width=\"320\" alt=\"\" border=\"0\" style=\"padding: 0px; margin: 0px\"/></a><div class=\"qt_ctrls\" id=\"qtCtrls\"><img src=\"http://gallery.mac.com/g/flash/qt_ctrl_right.gif\" width=\"57\" height=\"16\" alt=\"\" ></div></div></div>';


var scriptTxt = "<script src=\"http://gallery.mac.com/g/flash/gallery_moviewidget.js\" type=\"text/javascript\" charset=\"utf-8\"></script><script type=\"text/javascript\" charset=\"utf-8\"> initWidget('"+ widgetAlbumURL +"','" + widgetMovieURL + "', 320, "+ height +", '"+ widgetID +"', true);</script>";

var source = Gallery.albumController.get("path");

this.flash.innerHTML = styleTxt + htmlTxt;

this.widgetTag = styleTxt + htmlTxt + scriptTxt;
this.widgetCode.value = this.widgetTag;

var links = this.flash.getElementsByTagName('a');
for (i=0; i<links.length; i++){
links[i].removeAttribute('href');
links[i].setStyle({cursor: 'default'});
}

} else {
this.widgetTag = this.createSnippetCode();
this.flash.innerHTML = this.createSnippetCode(true);
this.widgetCode.value = this.createSnippetCode();
}
},

createSnippetCode: function(previewBool) {
var height = 260;
var width = 320;
var source = Gallery.albumController.get("path");
if (previewBool) {
previewTxt = 'preview=true&';
} else {
previewTxt = '';
}
var flashvars = previewTxt + 'feed=' + source + '?webdav-method=truthget&depth=infinity&feedfmt=atom&widgetWidth=' + width + '&widgetHeight=' + height;
var widgetTag;
widgetTag = '<object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" codebase="http://fpdownload.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=8,0,0,0" width="'+width+'" height="'+height+'" id="photowidget" align="middle">';
widgetTag += '<param name="allowScriptAccess" value="always" />';
widgetTag += '<param name="movie" value="http://' + HOST + '/g/flash/photowidget.swf?def" />';
widgetTag += '<param name="quality" value="high" />';
widgetTag += '<param name="bgcolor" value="#000000" />';
widgetTag += '<param name="wmode" value="opaque" />';
widgetTag += '<param name="flashVars" value="' + flashvars + '" />';
widgetTag += '<embed src="http://' + HOST + '/g/flash/photowidget.swf?abab" quality="high" bgcolor="#000000" width="' + width + '" height="' + height + '" wmode="opaque" name="photowidget" align="middle" allowScriptAccess="always" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflashplayer" flashVars="' + flashvars + '" />';
widgetTag += '</object>';
return widgetTag;



},

copyToClipboard: function()
{
var flashcopier = 'flashcopier';
if(!document.getElementById(flashcopier)) {
var divholder = document.createElement('div');
divholder.id = flashcopier;
document.body.appendChild(divholder);
}
document.getElementById(flashcopier).innerHTML = '';
var divinfo = '<embed src="/g/flash/_clipboard.swf" FlashVars="clipboard='+escape(this.widgetTag)+'" width="0" height="0" type="application/x-shockwave-flash"></embed>';
document.getElementById(flashcopier).innerHTML = divinfo;
new Effect.Highlight(this.widgetCode, {duration: .5, startcolor: "#70b0ff", endcolor: "#ffffff" });
},

commitForm: function() {
this.set("isVisible", false);
this.style.display = '';
}

});

*/


That's right, it's one big comment!


Next up, images. It requests a lot of them. A LOT of them. Here is a screenshot from firebug:



That's right, 137 images. Most of these (let's say 100) are 160x160 images for making the skim gallery effect. Looking into the gallery code, the image is swapped by changing the src on an image. This approach has some drawbacks:


  • You're eating your net latency once for each image (divided by ~4, as the browser will request more than one at a time). Even if you're latency is only 100ms, you've got 100 images / 4 threads * 100ms = 2500ms. So, two and a half seconds eaten up in overhead. This gets worse the farther away you are from the server (light is only so fast, after all).

  • You can do better than changing the source



A common web developer tool is to use one bigger image as a background to a smaller element and shift the position of the background to achieve some effect. Example: . We can use this here. Instead of 20 images for each gallery, create one image per gallery that's 160px x 3200px, then shift it to display whatever image we want. So, for 5 galleries, you're downloading 5 images instead of 100. This will cut out a lot of latency.

But is it faster? Well, how about an example. I took 5 gallery images and constructed a film strip image. I built a test page/script - same 5 image - one method uses the filmstrip and background offset, the other uses an image src.

Here is the example. The top image uses the film strip approach. The bottom uses Apple's mechinism.

I used firebug's profiler to compare the performance:



big dumb dev - .23ms.41ms average
apple - 2.1ms average

So, better than 4x improvement.

And this is where part 1 ends.

Next up - part 2 : how to build a film strip image.

13 comments:

said...

Good job, Steve (?!) Hehe.

said...

So essentially what we're doing now is loading one huge 182kb image which is three times as large as all 5 of the other images combined?

Seems a bit silly..

said...

Of course, the advantage of the skimmer using the "?derivative=square&source=web.jpg&type=square" images is that those same images are used in the Mosaic view... on need to reload them.

Now, as it happens that gets written out to {Image folder}/square.jpg (like web.jpg), so they're needlessly potentially blotting caching there.

It's quite clear from looking at the various assets (loaded from /g/images/gallery/) that they understand the "multiple images in one" trick, so obviously there's a reason for not using it on the skimmer.

said...

Wow, amazing considering the budget and amount of people Apple has at their disposal. Can't wait for your next installment. Keep it up.

said...

Chris, actually that is faster.

Even copying files on a hard drive, having one large file is much faster then 100s of smaller ones even if the total size is the same.

This is because there is a lot of header info/file table info that needs to be sent/written.

said...

I'm confused by your testing methodology. Shouldn't the results be .412ms average for you against 2.108ms for them? It looks like you used your min score rather than your average.

said...

@ryan
You're right, I copied the wrong number. The >4x is right (2ms vs. .4ms).

@chris - I put the example image together using a free paint program and it didn't do a very good job saving the image. I wrote an ImageMagick script to construct the film strip image, and the final image is about 10% smaller than the sum of the smaller images.

said...

There's one very critical flaw with your analysis - your method requires constructing one "film strip" image. So, either to execute your method, the customer has to construct their own film strip image, or Apple has to do so on the fly from the backend on their server. And since each strip has [N] number of photos, there's no telling how large that single image might be. Not a very realistic approach.

Now, removing the comments and white space, that's an excellent suggestion.

said...

I posted something similar over on my blog, but concentrating on the JS files and initial load time, not the skimming:

Quick Look at .Mac Web Gallery

said...

Hi, I'm new with javascript and I have a question:
How to make a ARRAY for the images and a CLASS with your code?
And MOOTOOLS could help improve your code or not?
Good job!

said...

thanks for this info. just used a modified version to do a chapter skimmer for a new RPG, looks freaking awesome!

RPG Chapter Skimmer

thanks again!

said...

Thanks for the good post, Steve. I used this technique for an app I'm working on and wrote some ideas about it here.

said...

This is very cool but......

Ideally you would want to show the images only once (1-5) going from left to right. In your example the first couple are shown again as you move the mouse further to the right.

I assume you factor in the film strip image height somehow?