Hi! This is Peter Li, a backend engineer in Anymind DOOH Team. I’m excited to share the design of DOOH video player and how we solve a problem with WebAssembly.
About DOOH
Our DOOH system includes a camera above a digital signboard that leverages on computer vision to understand human traffic to determine advertisement viewability and non-personally identifiable audience data for gender, age and expression attributes.
This information is used to alternate and deliver advertising creatives along with measuring advertising effectiveness, providing more relevant advertising based on audience demographics. Additionally, to protect the privacy of individuals, video from the camera is not transferred into the storage server, and only text-based attributes are processed.
Tech stack
Architecture of DOOH devices
We divide core features into different modules. Let me introduce some key features in DOOH Devices.
- Video Player(Chrome): play mp4/jpg creatives and always on foreground
- Browser Runner Service: manage Chrome process and check if Chrome is on foreground
- Face Recognition Service: detect face data from camera and send to Browser Manager Service
- Device Manager Service: check the status of device and run some cron tasks
- Browser Manager Service: our core service which will handle the playlist and send data to backend
Languages
- Services:
- Golang
- Video Player:
- Javascript
- WebAssembly(Golang)
Main issue in video player
In our design, we have multiple devices in one location just like the picture above, and we want to synchronize each device to make sure that all the devices should play the same video or switch videos at exactly the same time.
In our previous implementation, we implement our video player 100% by Javascript. The video player will read playlist, synchronize with current time, and decide which video should be played in which second. But we always have a problem that there is always a gap up to 1 second between each device.
Here is a pseudo-code of our previous video player:
setInterval(function() {
// get the video should be displayed
const videoIdShouldBePlayed = getVideoFromPlaylist();
// if video is still playing, ignore it
if (videoIdShouldBePlayed === playingVideoId) {
return
}
// get the start time
const startAt = getStartAt();
// then play this video
playVideo(videoIdShouldBePlayed, startAt)
}, 50)
Investigate the problem
From the pseudo-code, we can see that the setInterval()
loop is running
every 50ms, which means new video should be played in maximum 50ms
after previous video is finished. But why we can observe a gap up to
1 second? After some testing, we found out that the problem is
Javascript event loop.
JavaScript has a single thread concurrency model based on an event loop, which is responsible for executing the code, collecting and processing events, and executing queued sub-tasks.
The job of Event Loop is to monitor the Call Stack and the Callback Queue. If the Call Stack is empty, the Event Loop will take the first event from the queue and will push it to the Call Stack, which effectively runs it.
However, this single thread model will have a problem that we can not guarantee that the function will be executed every exactly 50ms. According to MDN:
The timeout can also fire later than expected if the page (or the OS/browser) is busy with other tasks.
In other words, it is just meant that the function will be executed after a given delay, and once the browser’s thread is free to execute it.
How we solve the problem with WebAssembly
WebAssembly is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.
WebAssembly supports multiple languages like C, Golang, Rust. Since most of our tech stacks are using golang, we also selected golang to build the WebAssembly binary.
As we mentioned above, our performance bottleneck is the timer, and Javascript
cannot provide an accurate timer because of the single thread event loop architecture.
However, in golang, we can use time.Timer
to solve the problem easily.
According to official document:
A Time represents an instant in time with nanosecond precision.
So we can simply make a timer controller like this
func (w *WebManager) Start() {
remaining := w.playCurrentVideo()
timer := time.NewTimer(remaining)
for {
<- timer.C
// now it's time to switch video!
remaining = w.playCurrentVideo()
timer.Reset(remaining)
}
}
WebManager.Start()
function can be called any time. When it’s being called,
playCurrentVideo()
will control the html to play the correct video in the right
second, and return the remaining time of this video in milliseconds.
Then we can start a timer and wait for the channel to be triggered.
In line <- timer.C
, when it’s triggered, then we know it’s the correct time
to start playing a new video! Then just reset the timer to the new remaining time
and keep looping.
Conclusion
As a result, WebAssembly helped us to solve the problem. Now each device can be synchronized perfectly. Here’s the summary of advantages and disadvantages of WebAssembly:
- Pros
- Binary, easy to obfuscate and hide important logics
- Memory safe, running in a sandbox
- Sharing same Web APIs with JavaScript
- Cons
- Limited data types are supported (check here)
- Binary size is larger than JS code