How We Built InkSlop: The Tech Stack

FastAPI, Celery, SvelteKit, Lark, Kokoro, SQLite, and a few deliberate choices about what not to build. Here is the full stack and the reasoning behind each layer.

Andy Woods
Illustration of stacked software layers connected by pipes

InkSlop is a platform that takes a text description of a video and renders a finished file. That deceptively simple description covers a lot of moving parts: a custom DSL with its own grammar and parser, multiple TTS engines, an FFmpeg rendering pipeline, a job queue, an auth layer, a file storage system, and a frontend that ties it together. Here is what the stack looks like and why we made the choices we did.

Backend: FastAPI and Python 3.13

The API layer is FastAPI on Python 3.13. FastAPI's async-native design matters specifically for endpoints that wait on external services (DeepInfra for Qwen3 TTS, Google Imagen for AI images, the Celery broker). Waiting on I/O without blocking threads is important when rendering jobs are long and multiple users are submitting work concurrently.

We are on Python 3.13 rather than an earlier version partly for the free-threaded build experiment and partly because several dependencies (notably the type system improvements) made upgrading worthwhile.

Rate limiting uses SlowAPI, which wraps Starlette middleware. Every endpoint has a rate limit defined by operation cost rather than a blanket request limit. Read endpoints are more permissive than write operations, and generation endpoints are the most restricted.

Job Queue: Celery with Redis

Video rendering does not happen in the API process. When a request comes in, the API validates it, reserves credits, and dispatches a Celery task. The Celery worker handles TTS generation, asset fetching, and the FFmpeg render. The API returns immediately with a job ID and the client polls for status.

The broker and result backend are both Redis. We are not running a separate message queue service. Redis does both jobs adequately at our scale and simplifies the deployment.

Celery task groups (chords) handle videos that require multiple TTS segments. Each segment generates in a parallel task, and the render task waits for all of them before assembling the timeline. This cuts render time significantly for longer scripts compared to sequential TTS generation.

One non-obvious constraint: Celery workers use process-based forking, which creates problems with CUDA context sharing. Kokoro TTS is therefore forced to run on CPU in every worker, regardless of whether a GPU is available. The performance is acceptable for the volume we handle, and the alternative (thread-safe GPU sharing in a fork-based worker pool) is considerably more complex.

The DSL Parser: Lark

The InkSlop DSL is a tag-based markup language for describing video. It is parsed with Lark, a Python parsing library that takes a grammar definition and produces an AST.

The grammar is a few dozen lines. Tags can be block-level (wrapping content) or self-closing. Parameters are typed: strings, numbers, percentages, time values, and booleans all parse to appropriate Python types rather than raw strings. This means the renderer works with validated, structured data rather than parsing strings a second time.

The reason for building a DSL rather than using JSON or YAML is LLM compatibility. LLMs generate the DSL very reliably from a grammar spec and a description of the desired video. Structured but readable formats prompt better than JSON for creative tasks, and the grammar is simple enough that the model rarely produces invalid syntax.

Lark also produces a proper AST with graphviz export support, which was useful during development for visualising parse results.

TTS: Three Engines

Kokoro (local, CPU): open source, Apache 2.0, 82M parameters. Runs in the Celery worker process. Produces word-level timing data directly from the model, which InkSlop uses for subtitle sync without a separate transcription step. Cost is pure compute.

Qwen3 TTS (DeepInfra API): higher quality, supports voice cloning, costs API credits. Since Qwen3 does not produce word-level timing data, we run faster-whisper on the generated audio to recover timing for subtitle generation. This adds latency but the quality improvement justifies it for users who choose that engine.

The TTS factory pattern means adding a new engine requires implementing one interface and registering a name. The rest of the pipeline does not care which engine was used.

Rendering: FFmpeg

The timeline generator takes the parsed DSL and produces a data structure describing every clip, audio segment, and effect with calculated timestamps. FFmpeg handles the actual render, invoked directly from the Celery worker.

Motion effects (zoom, pan, tilt, shake) are implemented as FFmpeg filter chains. The zoom effect pre-scales to 8000px to avoid aliasing, which is CPU-heavy. This is why zoom and shake add extra credits: they are genuinely more expensive to compute.

Database: PostgreSQL via SQLModel

PostgreSQL via SQLModel. SQLModel is a thin layer over SQLAlchemy that adds Pydantic-style type annotation to model definitions, keeping schema definitions and API validation types in sync without duplicating them.

PostgreSQL handles the concurrent write load from multiple Celery workers settling credits and updating job status without contention. The SQLAlchemy abstraction means queries are largely database-agnostic, which keeps the option open to scale to a managed PostgreSQL service without rewriting data access code.

Storage: Cloudflare R2

Files are stored in Cloudflare R2, an S3-compatible object store. Two buckets: one for user-uploaded files, one for rendered output and library assets.

Files are served through a capability token system rather than presigned URLs. Each file has a 128-character hex token. The token is the auth: anyone with the token can access the file, no session required. This makes sharing and embedding straightforward without exposing user identity.

Auth: Logto

Authentication is handled by Logto, an OIDC provider. JWTs are validated in the API on every request. The user's email is passed from the SvelteKit proxy to the API via a request header after token validation.

We are not running our own auth server. The complexity/risk tradeoff for auth is strongly in favour of a dedicated service.

Frontend: SvelteKit 2, Svelte 5, Vite 8

The frontend is SvelteKit with Svelte 5's new runes reactivity model. Vite 8 handles the build. TypeScript 6 throughout.

The frontend is a proxy to the backend: API calls go to /api/proxy/[...path] which the SvelteKit server proxies to the FastAPI backend, adding the auth header from the validated session.

Svelte 5's $state, $derived, and $effect runes are a meaningful improvement over the Options API for complex reactive state. The billing store, video generation state, and DSL editor state are all runes-based stores.

Billing: Polar.sh

Billing is through Polar.sh. Credits are tracked internally in the database with a reserve-then-settle pattern for generation tasks: credits are reserved when a job is submitted and settled (adjusted to actual cost) when it completes. This prevents users from submitting jobs they cannot afford and avoids overcharging for jobs that finish cheaper than estimated.

Polar handles subscription management, checkout, and payment processing. We call their metering API when credits are consumed so usage data is visible in the Polar dashboard.

What We Would Do Differently

PostgreSQL handles concurrent writes from parallel Celery workers well. Connection pooling via SQLAlchemy keeps the number of open connections manageable even under rendering load.

The Celery CPU-only constraint for Kokoro is a limitation we are working around rather than solving. A proper solution would be a separate, stateful TTS service that shares a GPU across requests rather than loading a model per worker process. That is the next significant infrastructure change.

Everything else in the stack has worked well for its purpose. FastAPI and Celery handle the async workload cleanly. Lark is reliable and the grammar has been stable. The file token system has been simpler to operate than presigned URLs in every way.

If you want to see the system from the outside, create a video and watch the job queue do its thing.

Related articles

Ready to create your own videos?