Run the demo

This sample demonstrates how you can write a relatively full-featured photobooth app in very little code using Conductance. The finished app has around 250 lines of code all-in. That includes both client & server parts as well as the HTML.

The app uses the fairly new Media Capture and Streams API to access your webcam. At the moment this API only works in recent desktop & mobile versions of Chrome, Firefox and Opera, but unfortunately not Safari or IE.

The code demonstrates a whole bunch of Conductance techniques:

  • Sequential UI flows
  • Finding good boundaries for separation of concerns
  • Factoring out customization options
  • Stratifying asynchronous APIs
  • Modal dialogs
  • Writing live HTML widgets with Mechanisms
  • Input Validation with Observables
  • Calling external programs on the server-side (to generate thumbnails)
  • Interfacing with external web APIs (for sending email)
  • Mirroring server state efficiently using sequences

While the application could have been written as just two files (one *.app file and one *.api file), we've deliberately split it up into smaller files, to demonstrate how you would normally structure a larger application.

index.app The main client-side program file

This file is the main entrypoint into our app.

index.app
= require(['mho:std', 'mho:app', './mediacapture', './gallery', './camera']);
@mainContent .. @appendContent([
  @PageHeader('Conductance Snapshots'),
  @Gallery
]);
if (!@hasMediaCaptureSupport()) {
  @mainContent .. @appendContent(
    `<h3>Sorry, this app requires a browser with 'getUserMedia' support, 
         such as Google Chrome or Firefox</h3>`);
  hold();
}
while (true) {
  @mainContent .. @appendContent(
    @Btn('lg default', `$@Icon('off') Open Camera`)
  ) {
    |button|
    button .. @wait('click');
  }
  var stream = @getCameraMediaStream();
  @runCameraUI(@mainContent, stream);
  stream.stop();
}

settings.sjs Application-wide settings

Collection of settings for customizing the application behaviour.

settings.sjs
// number of snapshots to show in the gallery
exports.GALLERY_COUNT = 12;

mediacapture.sjs HTML5 media capture helpers

mediacapture.sjs
= require(['mho:std', 'mho:app', './camera']);
// de-prefix navigator.getUserMedia
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia ||
                         navigator.mozGetUserMedia || navigator.msGetUserMedia;
// stratified version of 'getUserMedia':
function getUserMedia(constraints) {
  waitfor (var rv, success) {
    navigator.getUserMedia(constraints, 
                           stream -> resume(stream, true),
                           error  -> resume(error, false));
  }
  if (!success) throw rv;
  return rv;
};
// hasMediaCaptureSupport: check for api support
exports.hasMediaCaptureSupport = function() { return !!navigator.getUserMedia };
// getCameraMediaStream: wait for access to camera while displaying 
// message to user
exports.getCameraMediaStream = function() {
  try {
    // display a message while we wait for the user to grant camera access:
    @mainContent .. @appendContent(
      `<h3>Waiting for camera access</h3>
        <p>Your browser might be asking you to allow camera access at this point.</p>
      `) {
      ||
      return getUserMedia({video:true});
    }
  }
  catch(e) {
    // the user rejected our request to access the camera (or there is no camera)
    @mainContent .. @appendContent(
      `<h3>Error accessing camera (${e.name||e.toString()})</h3>
       <button class='btn btn-default'>Retry</button>`) {
      |header,button|
      button .. @wait('click');
    }
    // user clicked 'retry', so try again:
    return exports.getCameraMediaStream();    
  }
}

camera.sjs Camera capture UI

camera.sjs
= require(['mho:std', 'mho:app', './email']);
// helper to retrieve an image from canvas as an ArrayBuffer
function getImage(canvas) {
  //https://code.google.com/p/chromium/issues/detail?id=67587
  //take apart data URL
  var parts = canvas.toDataURL('image/png').match(/data:([^;]*)(;base64)?,([0-9A-Za-z+/]+)/);
  return @base64ToArrayBuffer(parts[3]);
}
// run under camera ui at DOM parent `parent` with mediastream `stream` 
exports.runCameraUI = function(parentstream) {
  parent .. @appendContent(
    @Row(`
         <div class='col-md-6'>
           <video autoplay width="100%"></video>
           <div class='overlay'>
             $@Btn('sm danger', `$@Icon('off') Close Camera`, {command:'stop'})
             $@Btn('sm default', `$@Icon('camera') Take Snapshot`, {command: 'snapshot'})
           </div>
         </div>
         <div class='col-md-6' style='visibility:hidden'>
           <canvas style='width:100%;'></canvas>
           <div style='position:absolute;top:0px;margin:10px'>
             $@Btn('sm default', `$@Icon('upload') Upload`, {command:'upload'})
             $@Btn('sm default', `$@Icon('envelope') Email...`, {command:'email'})
           </div>
         </div>
       </div>
     `) .. 
      @CSS(`
             .overlay { position:absolute; top:0px;margin:10px; }
      `)) {
    |ui|
  
    var video = ui.querySelector('video');
    var canvas = ui.querySelector('canvas');
    video.src = window.URL.createObjectURL(stream);
    while (true) {
      var command = (ui.querySelectorAll('[command]') .. 
                     @wait('click')).currentTarget.getAttribute('command');
      switch (command) {
        case 'snapshot':
          canvas.setAttribute('height', video.height);
          canvas.width = video.videoWidth;
          canvas.height = video.videoHeight;
          canvas.getContext('2d').drawImage(video,0,0);
          // show canvas:
          canvas.parentNode.style.visibility = 'visible';
          ui.querySelectorAll('[command]') .. @each { |x| x.classList.remove('disabled') };
          break;
        case 'upload':
          @withAPI('./snapshots.api') {
            |api|
            api.saveImage(getImage(canvas));
          }
          break;
        case 'email':
          @emailImage(getImage(canvas));
          break;
        case 'stop':
          return;
      }
    }
  }
}

email.sjs Client-side UI for sending email

email.sjs
= require(['mho:std', 'mho:app', './utils']);
// helper to send email given address
function sendEmail(addrimg) {
  @withAPI('./snapshots.api') {
    |api|
    api.sendImage(addr, img);
  }
}
// send email; prompting for address first
exports.emailImage = function(img) {
  var addr = @ObservableVar('');
  var valid = addr .. @transform(@isValidEmail);
  @doModal({title: 'Send email with snapshot',
            body: `Enter email address: ${@TextInput(addr.. @Autofocus}`,
            footer: [@Btn('primary', 'Send') .. @Attrib('command','send') .. @Enabled(valid), 
                     @Btn('default', 'Cancel') .. @Attrib('command', 'cancel')]
           }) {
    |dialog|
    var command = (dialog.querySelectorAll('[command]') .. @wait('click')).currentTarget.getAttribute('command');
    if (command == 'send')
        spawn sendEmail(addr.get(), img);
    // else just bail
  }
}

utils.sjs Utility functions shared between client- and server-side

utils.sjs
= require(['mho:std']);
exports.isValidEmail = str -> 
  /^([0-9a-zA-Z]([-.w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-w]*[0-9a-zA-Z].)+[a-zA-Z]{2,9})$/.test(str);

gallery.sjs Client-side UI for live photo gallery

gallery.sjs
= require(['mho:std', 'mho:app', './settings']);
exports.Gallery = 
  @Row() .. 
  @Mechanism(function() {
    @withAPI('./snapshots.api') {
      |api|
      this.innerHTML = '';
      var count = 0;
      
      api.imageStream() .. @each {
        |name|
        this .. @appendContent(`<div class='col-xs-3 col-sm-2'><a href='./captures/${name}'  target='_blank'>
                                  <img src='./captures/small-${name}' width='100%'>
                                </a></div>`);
        if (++count > @GALLERY_COUNT) this.firstChild .. @removeNode;
      }
    }
  }) .. @CSS("div { padding-bottom:30px;}");

snapshots.api Server-side API

The server-side API uses GraphicsMagick to generate thumbnails. So this must be installed on the machine where conductance is running.

To send emails, the API uses the Mandrill webservice. This is a free service for up to 12k emails per month.

snapshots.api
= require(['mho:std', './utils', './settings']);
//make sure that the directory 'captures' exists:
var directory = "./captures/" .. @url.normalize(module.id) .. @url.toPath;
if (!@fs.exists(directory))
  @fs.mkdir(directory);
var existing_images = @fs.readdir(directory) .. 
  @filter(name -> name.indexOf('small-') === 0) .. 
  @map(name -> name.substring(6)) .. 
  @sortBy(x -> -parseInt(x)) .. 
  @take(@GALLERY_COUNT) ..
  @reverse;
var recent_images = @ObservableVar(existing_images);
// Live stream of images, buffering last @GALLERY_COUNT images:
exports.imageStream = ->  
  @combine(recent_images .. @first,
           recent_images .. @changes .. @transform(imgs -> imgs[imgs.length-1])) ..
  @buffer(@GALLERY_COUNT, {drop:true});
// Save an image to 'capture' directory:
exports.saveImage = function(blob) {
  var filename = "#{Date.now()}.png";
  @fs.writeFile(directory+filename, blob);
  // create a thumbnail using graphicsmagick:
  @childProcess.run('gm', 
                    ['convert', 
                     directory+filename, 
                     '-thumbnail', '120x90', 
                     '-gravity', 'center', '-extent', '120x90',
                     "#{directory}small-#{filename}"
                    ]);
  recent_images.modify(function(images) {
    images = images.concat(filename);
    if (images.length > @GALLERY_COUNT)
      images.shift();
    return images;
  });
}
// Send an email using the mandrill api:
exports.sendImage = function(emailblob) {
  if (!@isValidEmail(email)) return;
  try {
    @http.post('https://mandrillapp.com/api/1.0/messages/send.json',
               { key: 'XXXXXXXXXXXXXXXXXXXX',
               message: {
                 text: "Hello, world.",
                 subject: "Hello from Oni Conductance. Here is your snapshot.",
                 from_email: "noreply@conductance.io", from_name: "Oni Conductance",
                 to: [{email: email}],
                 attachments: [
                   {
                     type: 'image/png', name: 'snapshot.png',
                     content: blob.toString('base64')
                   }
                 ]
               }
             } .. JSON.stringify);
  }
  catch (e) { console.log("Error sending mail: #{e.status}#{e.data}"); }
}