Let's Do The Timeout Again!

Using JavaScript to create special effects in Theatre

by Chris Uehlinger (@Uehreka)

Inspiration

Demo

Beat Matching in JS

let theVid = document.getElementById('science');
let lastTapTime = Date.now();
let tapTimes = [];

// Initialize with tapTimes that would average to 110bpm
for(i=0; i < 5; i++){
  tapTimes[i] = 0.545454545454;
}

window.addEventListener('keydown', () => {
  let timeSinceLastTap = (Date.now() - lastTapTime) / 1000;
  tapTimes.unshift(timeSinceLastTap);
  tapTimes.pop();

  let avgInterval = tapTimes.reduce((avg, interval) => {
    return avg + interval/tapTimes.length
  }, 0);

  let beatsPerMinute = 60 / avgInterval;
  theVid.playbackRate = beatsPerMinute / 110;
  lastTapTime = Date.now();
});

Monitor Footage

Seriously.js

Seriously.js Example

const seriously = new Seriously();
let source = seriously.source('#remoteVideo'); // A <video> element
let filmgrain = seriously.effect('filmgrain');
let target = seriously.target('#target'); // A <canvas> element

// Send the source to the filmgrain filter
filmgrain.source = source;

// Render the output of the filmgrain filter to the canvas
target.source = filmgrain;

// Call this to start the effect
seriously.go(function (now) {
  filmgrain.time = now / 200; // Helps randomize the filmgrain
});

Seriously.js Webcam Example

const seriously = new Seriously();
let source = seriously.source('camera'); // The user's webcam feed
let filmgrain = seriously.effect('filmgrain');
let target = seriously.target('#target'); // A <canvas> element

// Send the webcam feed to the filmgrain filter
filmgrain.source = source;

// Render the output of the filmgrain filter to the canvas
target.source = filmgrain;

// Call this to start the effect
seriously.go(function (now) {
  filmgrain.time = now / 200; // Helps randomize the filmgrain
});

Cue list

let playlist = [
{
  "description": "Act 1 Intro",
  "type": "VIDEO",
  "href": "/act-1-intro/intro.mp4"
},
{
  "description": "Indianapolis House",
  "type": "PAGE",
  "href":"/first-act/index.html"
},
{
  "description": "Switch to Church",
  "type": "GO"
},
{
  "description": "Switch Back to Indianapolis House",
  "type": "GO"
},
{
  "description": "Turn Off Projection",
  "type": "OFF"
},
{
  "description": "Act 1->2 Transition",
  "type": "PAGE",
  "href": "/1-2-transition/index.html"
},
{
  "description": "Turn off projector",
  "type": "OFF"
},
{
  "description": "Voiceover 1",
  "type": "PAGE",
  "href": "/act-2-voiceovers/vo1.html"
},
{
  "description": "Live Feed",
  "type": "LIVE",
  "luma": false
},
{
  "description": "Voiceover 3",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo-3.mp4"
},
{
  "description": "Voiceover 4",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo4.mp4"
},
{
  "description": "Voiceover 5",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo-5.mp4"
},
{
  "description": "Voiceover 6",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo6.mp4"
},
{
  "description": "Voiceover 7",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo7.mp4"
},
{
  "description": "Voiceover 8",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo8.mp4"
},
{
  "description": "Voiceover 9",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo9.mp4"
},
{
  "description": "Voiceover 10",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo10.mp4"
},
{
  "description": "Voiceover 11",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo11.mp4"
},
{
  "description": "Voiceover 12",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo12.mp4"
},
{
  "description": "Voiceover 13",
  "type": "VIDEO",
  "href": "/act-2-voiceovers/final-cuts/vo13.mp4"
},
{
  "description": "Live Feed",
  "type": "LIVE",
  "luma": true
},
{
  "description": "End Live Feed",
  "type": "OFF"
},
{
  "description": "Act 3 Intro",
  "type": "VIDEO",
  "href": "/act-3/act-3-intro.mp4"
},
{
  "description": "Turn off projector",
  "type": "OFF"
},
{
  "description": "Act 3 Outro",
  "type": "FINAL_MONTAGE",
},
{
  "description": "Turn Off Projector",
  "type": "OFF"
}
];

Cue-linger Control Panel Page

$scope.sendStage = (cue) => {
  $scope.model.currentStaged = cue
  socket.emit('staging:control', cue);
}

$scope.sendCue = (cue) => {
  $scope.model.currentPlaying = cue;
  $scope.model.currentStaged = null;
  socket.emit('play:control', cue);
}

$scope.sendRefresh = () => {
  socket.emit('refresh:control', {});
}

$scope.bail = () => {
  $scope.sendCue({
    description: 'BAILED',
    type: 'OFF'
  });
};

Cue-linger Server Side

io.on('connection', function (socket) {
socket.on('staging:control', function (data) {
  io.emit('staging:display', data);
});
socket.on('play:control', function (data) {
  io.emit('play:display', data);
});
socket.on('refresh:control', function (data) {
  io.emit('refresh:display', data);
});
});

Cue-linger Display Page

// First, find out which display this is
const urlParams = new URLSearchParams(window.location.search);
const displayId = +urlParams.get('id');

if(displayId === 0) {
  $('body').append(`<iframe class="live-feed-frame hidden" allow="microphone; camera" src="https://6836ac72.ngrok.io/index.html?host=true"></iframe>`);
}

let staging = null;
let playing = null;

var socket = io.connect(`https://${location.host}`);

socket.on('staging:display', function (cue) {
  console.log('staging', cue);
  staging = cue;

  if($('.staging').length > 0){
    if($('.staging').hasClass('live-feed-frame')) {
      $('.staging').removeClass('staging').addClass('hidden');
    } else {
      $('.staging').remove();
    }
  }

  if(displayId === 0) {
    switch (cue.type) {
      case TYPES.VIDEO:
        $('.cue-bag').append(`<video class="staging" src="https://${location.host}${cue.href}" muted></video>`);
        break;
      case TYPES.PAGE:
        $('.cue-bag').append(`<iframe class="staging" src="https://${location.host}${cue.href}"></iframe>`);
        break;
      case TYPES.GO: break;
      case TYPES.LIVE:
        $('.live-feed-frame').removeClass('hidden').addClass('staging');
        $('.live-feed-frame')[0].contentWindow.postMessage(JSON.stringify({ command: 'STOP' }), '*');
        break;
    }
  }

  switch(cue.type){
    case TYPES.OFF: break;
    case TYPES.FINAL_MONTAGE:
        $('.cue-bag').append(`<iframe class="staging" src="https://${location.host}/act-3-final/index.html?displayid=${displayId}" muted></iframe>`);
        break;
  }
});

socket.on('play:display', function (cue) {
  console.log('play', cue);
  playing = cue;
  staging = null;

  if(displayId === 0) {
    switch (cue.type) {
      case TYPES.VIDEO:
        $('.playing').removeClass('playing').addClass('done');
        $('.staging').removeClass('staging').addClass('playing');
        $('.playing')[0].play();
        $('.playing').on('ended', e => {
          $('.playing').remove();
        });
        break;
      case TYPES.PAGE:
        $('.playing').removeClass('playing').addClass('done');
        $('.staging').removeClass('staging').addClass('playing');
        $('.playing')[0].contentWindow.postMessage(JSON.stringify({ command: 'START' }), '*');
        break;
      case TYPES.GO:
        $('.playing')[0].contentWindow.postMessage(JSON.stringify({ command: 'NEXT' }), '*');
        break;
      case TYPES.LIVE:
        $('.playing').removeClass('playing').addClass('done');
        $('.staging').removeClass('staging').addClass('playing');
        $('.playing')[0].contentWindow.postMessage(JSON.stringify({ command: 'START', luma: cue.luma }), '*');
        break;
    }
  }

  switch(cue.type){
    case TYPES.OFF:
      $('.playing').removeClass('playing').addClass('done');
      break;
    case TYPES.FINAL_MONTAGE:
      $('.playing').removeClass('playing').addClass('done');
      $('.staging').removeClass('staging').addClass('playing');
      $('.playing')[0].contentWindow.postMessage(JSON.stringify({ command: 'START' }), '*');
      break;
  }

  if($('.done').length > 0){
    if($('.done').hasClass('live-feed-frame')) {
      $('.done').removeClass('done').addClass('hidden');
    } else {
      $('.done').remove();
    }
  }

});

socket.on('refresh:display', function () {
  location.reload();
});

WebRTC

  • RTC === "Real Time Communication"
  • A browser API with 2 purposes:
    • Get the user's webcam and microphone feed
    • Send streams of audio, video and data between users (directly, Peer2Peer)

getUserMedia

let constraints = {
  audio: true,
  video: {
    width: { min: 1024, ideal: 1280, max: 1920 },
    height: { min: 776, ideal: 720, max: 1080 },
    facingMode: 'user'
  }
};

let stream = await navigator.mediaDevices.getUserMedia(constraints);
let videoEl = document.getElementById('myVideo');

videoEl.srcObject = stream;

// Old (deprecated) syntax:
// video.src = URL.createObjectURL(stream);

Using the video stream

video {
  animation: video-effects 5s infinite;
}

@keyframes video-effects {
  0% {
    filter: blur(0px) hue-rotate(0deg);
    transform: rotate(0deg);
  }

  50% {
    filter: blur(10px) hue-rotate(180deg);
  }

  100% {
    filter: blur(0px) hue-rotate(360deg);
    transform: rotate(360deg);
  }
}

Using the Audio Stream

Thanks for watching!

  • @Uehreka on Twitter
  • @chrisuehlinger on GitHub
  • Lots of demos over at chrisuehlinger.com
  • Check the Slack for notes!