I recently released Kynetic, a full-stack, self-hostable whiteboard animation engine.
At its core, it is a local, private BYOC system. You (optionally) deploy your own Amazon S3 bucket, API Gateway etc to have private cloud storage, create your project using the online (self-hostable) editor, and use the Docker container on your own machine to render it.
I'm going to walk through how this project came to be, how I made it, and some tips I've learnt because of this project :)
Why did I make this?
I make videos over on YouTube, and I wanted to use a whiteboard animation for one of the videos.
My first thought was to try and find a free website online. Every single one I found was either behind a paywall or just not what I was looking for.
3b1b, an amazing maths channel on YouTube, uses manim. I tried looking into it (and did end up using it for my Fourier Transform video), but again, it wasn't whiteboard drawing animations.
So I decided that this could be an interesting project to make myself, and then came the first pitfall of the idea...
How do you actually make whiteboard animations?
I had no idea. Learning how to render videos using Python and ffmpeg wouldn't be very helpful, since programmatic editing already exists (like with manim). If I could find another open source library that supported hand-drawn animation styles generated in code, I could build my project off of it!
And find one I did: handanim
handanim is described on its README as "A Python library to create whiteboard-style, hand-drawn animations for educational videos, tutorials or data storytelling.". So essentially, exactly what I was looking for.
I played around with it for a bit, but I discovered two features that would make this library essentially complete for my use case. A way to draw in images, and a way to move those images.
Images were kind of already in handanim. But they didn't support colour, and seemed to only be the outlines.
So I forked handanim, and wrote a new drawable: VectorSVG. The entire PR is at https://github.com/subroy13/handanim/pull/5.
Using the library svgelements, I could parse an SVG file, and just follow the paths it dictated. This made it look a lot more like a drawing animation, just for almost any image I could imagine.
The second part of the PR was the TranslateToPersist animation. I noticed that using the existing translation animations just made them snap back to their original positions after, so I added a new animation to fix this.
It calculates how far the drawable should have moved based on the animation's progress, then applies that translation to its OpsSet, which is essentially its attributes.
And now, I had contributed to an open source whiteboard animation library, and I could now use it to make my renderer for Kynetic.
The next problem: how can you use handanim without having to write Python?
I wanted Kynetic to be designed end-to-end for an end user. All they'd have to do was design their animation in a GUI, export it, and type in one command to convert it to a fully fledged video.
So I needed some way to take in an external file (probably formatted in JSON), and have a program generate the drawables for handanim.
I first designed a schema using pydantic. This would be important to verify all drawables and transitions were actually formatted well. Making it was just a matter of going through all of handanim's drawable and transition library, and just making a class for each one.
Now, I could make the renderer. I would loop through all the nodes in the JSON document, and for each one, depending on what it was, add a drawable object to the scene. After that, everything was ready to go and I could just render it.
How can I make sure every user is able to run it?
handanim relies on many dependencies. svgelements for VectorSVG, pycairo, ffmpeg and more. Especially for CLI tools like ffmpeg, I needed to make sure that it could run the same on each computer.
Naturally, that led me to containerisation.
To containerise the Kynetic renderer, I would need to create an entry point to access the renderer through a command. I did this by simply making a main.py, which would parse sys.argv and render the project.
Next, the Dockerfile! handanim only needs Python, a few OS-dependent tools (like ffmpeg) and Python packages. I used poetry for the environment handling, so I had a pyproject.toml and poetry.lock that listed every Python dependency that would be needed.
So in order, I would need to install Python, download the OS-side tools, download poetry, configure it, and then expose the entrypoint I had defined earlier.
By then, the renderer itself was largely complete. There is one small feature missing however that I would come back to later: how it fetches remote assets.
How would remote fetching work?
Having remote storage would be important to make a local renderer able to use assets from a web UI. In a production pipeline, the container would run on something like ECS, and it would expect files to be in a service like S3. But, I deliberately kept rendering local because I wanted Kynetic to remain BYOC rather than turn the project into a hosted service with recurring infrastructure costs.
So I needed some way to be able to upload and download SVGs from S3 securely.
Initially, this architecture would be very simple. A Lambda function to generate a GET presigned URL, and a PUT presigned URL, and an API Gateway to expose them both.
The first substantial improvement to this system in hindsight is how it would work if everything was cloud-native (i.e, if the renderer ran on ECS or Fargate). I could simply have a frontend upload a JSON document (and accompanying assets) to S3, and use Event Triggers to trigger the container. This would completely eliminate the need for API Gateway and Lambda. However, for this combined BYOC and local pipeline, an API Gateway would still work.
The plan would be to have the frontend accept SVGs from an end user, call the API to obtain a PUT presigned URL, and use it to upload to S3. Then in the final JSON document, the assets would have a source labelled as remote, using s3://.
How does the renderer get remote assets?
I created an entirely new file, fetch.py, dedicated to downloading SVGs from S3. This was a simple requests task, and I eventually had it save to the OS's temporary storage provision.
The renderer would then scan for the s3:// in the JSON document, and if found, find the API key and URL in environment variables (allowing the system to work headlessly), or ask for it as input. Then when all the drawables are being added to the scene, it uses that fetch_from_s3 method to download the source image file.
How is the presigned URL actually generated?
This is the primary job of the two Lambda functions. The S3 boto3 client exposes a generate_presigned_url method for a given object key to be able to get a URL to use for GET/PUT requests.
As such, the system remained very simple until later down the road, where a few changes would have to be made.
How does an end user deploy all this to AWS themselves?
I personally find AWS CDK a huge time save for this. It effectively achieves the same thing as CloudFormation (in fact synthesising a stack is literally just turning it into a CFN template), but works in any programming language you are familiar with, like Python.
So all I had to do was initialise a CDK stack (cdk init), add my logical resources, and use cdk deploy.
At this point, I fully locked in the security options for the API. It would throttle after 10 requests per second, with a burst of 2. As a default, I think these are good options as the system is designed to be used by only one user or maybe a few. And the code is open source, so anyone who needs to can simply increase those limits.
How does fetching remote assets work on the frontend?
Now most of the backend stuff is done, and we need to design the frontend editor.
During development though, I would constantly run into a 403 Forbidden error with uploading SVGs. The API would be up and running (as a heartbeat request returns no error), but trying to use the presigned URL seemed to constantly fail.
From past experiences, I immediately looked to CORS as the blame. However, I had allowed all origins to make requests to the bucket, so it wasn't the problem.
Eventually, after analysing the Network tab in Chrome, it turned out the browser was sending a CORS preflight check.
This is a HTTP OPTIONS request and is usually sent before the actual GET request.
Plus, in an error the function wouldn't return CORS headers back. This is usually fine for programmatic requests, but for web browsers, it doesn't work.
I first tried to update both the get_url and put_url Lambda functions directly to address these two issues. I did this by returning an empty HTTP 204 if the request used OPTIONS, and returned CORS headers in errors.
This made it work on the frontend, but in the renderer, it broke.
By this point the docker container had been pushed to production and ready, so I wasn't exactly keen on updating the renderer to support OPTIONS requests and the other changes to be made.
So instead, I reverted the two Lambda functions to their original versions, and instead made two new Lambda functions with the updates, get_url_frontend and put_url_frontend.
Updating the Next.js typescript to use these new routes solved the problem once and for all :)
What would I take away from making this project?
There are quite a few things I took away from building Kynetic, but probably the biggest one was how important good abstractions are when building a system.
I started Kynetic thinking mostly about the end product: a GUI where I could design an animation and then render it. But fairly quickly, I realised that the editor and renderer didn't really need to know how each other worked. They just needed to agree on a format. Designing the JSON project format gave me that boundary, and meant I could develop the editor and renderer independently. Looking back, this was probably one of the most important architectural decisions I made.
I also learnt a lot about working with existing software rather than always writing everything from scratch. Using handanim meant I could focus on building Kynetic itself, but extending it with VectorSVG and TranslateToPersist meant I had to understand how its drawable and animation systems worked internally. Contributing those changes back upstream also showed me that sometimes the best solution to a problem isn't to work around an existing system, but to improve the system itself.
Another big lesson was that changes in one part of a system can have consequences somewhere completely different. The CORS issue was a good example of this. I initially changed the existing Lambda functions to work with browser requests, which fixed the frontend but broke assumptions made by the renderer. Rather than adding more complexity to the renderer, I separated the frontend-specific behaviour into its own functions. It was a fairly small change, but it made me think much more carefully about the boundaries between components.
I also learnt that the most complicated architecture isn't necessarily the best architecture. While an ECS or Fargate-based rendering system could make sense for a fully hosted service, it would add unnecessary complexity to Kynetic's BYOC and local-first design. Keeping the renderer as a Docker container meant that it could run locally while still being capable of fetching assets from cloud storage when needed.
Finally, Kynetic taught me that building a project from an initial idea to something actually usable involves a lot more than just writing the main feature. There were plenty of smaller problems along the way, like dependency management, Docker, API authentication, CORS, cloud infrastructure, file formats, and frontend/backend boundaries. Solving those problems was probably where I learnt the most.
The end of the project!
I actually started Kynetic way back in January. But with summer, the Build4Students Hackathon, Audiergon, and other projects happening in the middle, I didn't work on Kynetic as much. However for a while the last thing to do was simply make the editor.
After doing that, I managed to finish and push Kynetic 1.0 and it is ready for use!
I hope you enjoyed going through this post about how I built this project. I enjoyed learning more about systems architecture and building end-to-end pipelines, and I hope I've been able to pass that knowledge onto you!
Written by Hamd Waseem (15)

Top comments (0)