Every year, a few days before Dashain, Tihar or Mha Puja, the same thing happens to Nepali shop owners in London, Sydney and Kathmandu. They need a greeting post for Facebook and Instagram, with their shop name and logo on it, in Nepali that is actually spelled right. Most of them don't have a designer. Many end up sharing a generic image from WhatsApp, or not posting at all.
I build Post Marga, a social media scheduler for Nepalese businesses, and I wanted a free tool for exactly this moment. The result is a free festival poster maker for Nepalese businesses: type your business name, add a logo, pick a design, download a PNG or a short silent story video. No account.
The one hard rule I set was that everything renders in the browser. This post is about what that rule cost and how the pieces fit: Devanagari on canvas, a layer-based artboard shared by stills and video, and a canvas-to-MP4 pipeline with real browser fallbacks.
Why client-side?
Three reasons, in order of importance:
- The logo is someone's brand. A small business uploading its logo to a random free site is a fair thing to worry about. If the poster is drawn locally, the logo never needs to leave the device, and the page can honestly say so.
- No render servers. Festival traffic is spiky. Everyone wants a Laxmi Puja poster on the same evening. Rendering PNGs and especially video server-side would mean paying for capacity that sits idle most of the year.
- It's fast enough. A 1080x1920 frame is about 2 million pixels. Modern browsers can draw and encode that comfortably.
The only thing the server sees is an optional lead: the first download asks for an email (and saves the business name and phone if given) so I can send new designs before big festivals. The logo is not part of that request.
Here's how the logo is read. It is shrunk to at most 600px on its longest side and kept as a PNG data URL in memory (and localStorage, if it fits):
/** Reads a logo file into a small PNG data URL (it never leaves the browser). */ function readLogo(file: File) { return new Promise<Logo>((resolve, reject) => { const url = URL.createObjectURL(file) const image = new Image() image.onload = () => { const scale = Math.min(1, 600 / Math.max(image.naturalWidth, image.naturalHeight)) const canvas = document.createElement('canvas') canvas.width = Math.round(image.naturalWidth * scale) canvas.height = Math.round(image.naturalHeight * scale) canvas.getContext('2d')!.drawImage(image, 0, 0, canvas.width, canvas.height) URL.revokeObjectURL(url) resolve({ src: canvas.toDataURL('image/png'), width: canvas.width, height: canvas.height }) } image.onerror = () => reject(new Error('Could not read that logo. Try a PNG or JPG.')) image.src = url }) } (Trimmed slightly for the post.) Downscaling first keeps the data URL small enough for localStorage, so returning visitors don't have to re-add their logo.
One artboard model for everything
Each design is a plain data structure: an artboard with a width, height and a list of layers (rect, image, text), each with an id and sometimes a role like logo, headline or shop. The library covers over 40 occasions across 2026 and 2027, from Dashain, Tihar and Chhath to the Lhosars and national days, plus everyday business posts like grand openings and hiring.
Sizes are separate artboards: square feed (1080x1080), tall portrait (1080x1350) and full-screen story (1080x1920).
The renderer is Fabric.js, loaded on demand so the landing page doesn't ship it until someone actually downloads:
export async function renderArtboardPng(board: Artboard): Promise<Blob> { const stage = await createArtboardStage(board) // dynamic import('fabric') inside try { stage.render() const blob = await new Promise<Blob | null>((resolve) => stage.canvas.toCanvasElement(1).toBlob(resolve, 'image/png'), ) if (!blob) throw new Error('Could not create the image') return blob } finally { stage.dispose() } } The important design choice: the video uses the same stage as the PNG. Same fonts, same layout, same text objects. The video is just the PNG scene with each layer posed differently over time. That means anything I fix for stills is fixed for video too.
Devanagari on canvas: the pitfalls
Nepali is written in Devanagari, a script where letters combine. क + ् + ष becomes क्ष. Vowel signs attach to consonants, sometimes visually before them (the ि in कि). Getting this right is the job of the font's shaping engine (HarfBuzz in Chrome, CoreText in Safari), and canvas fillText uses it, as long as you hand it the whole string.
That's where things go wrong.
Pitfall 1: drawing character by character
Lots of canvas text code splits a string into characters to do per-letter styling, letter-spacing, typewriter effects or manual wrapping. Do that with Devanagari and every conjunct falls apart into its pieces, with dangling halant marks and vowel signs floating in the wrong place. A greeting like "न्हूदँया भिन्तुना" turns into something no Newar or Nepali reader would accept.
Rich-text editors on canvas have the same problem. In Fabric, giving a Textbox per-character styles makes it paint per character. So the rule in the renderer is: one Textbox, no character styles, never split the string.
// One Textbox, no per-character styles: Fabric then paints each line with a // single fillText, so Devanagari conjuncts stay shaped. Don't split the string. return new fabric.Textbox(textLayer.text, { left, top, width, fontSize: textLayer.fontSize, fontWeight: textLayer.fontWeight, fontFamily: textLayer.fontFamily, fill: textLayer.fill, textAlign: textLayer.align, lineHeight: 1.35, }) This also applies to animation. It's tempting to animate a headline letter by letter. For Devanagari, the smallest unit you animate should be a whole line (or at least a whole grapheme cluster, which Intl.Segmenter can give you). In the story video, every text layer fades and rises as one object.
Pitfall 2: drawing before the font arrives
Canvas doesn't wait for web fonts. If you call fillText before the face is loaded, you get the fallback font baked into your PNG, and with Devanagari that fallback might be a system font with different metrics, or tofu boxes.
CSS @font-face with unicode-range makes this trickier: the Devanagari subset of a font is a separate file, loaded only when Devanagari text appears in the DOM. A canvas isn't the DOM, so nothing triggers it. The fix is to ask for each face with the actual text it will draw, which makes the browser fetch the right subset:
export async function ensureFonts(layers: Array<{ fontFamily?: string; fontWeight?: number; text?: string }>) { const wanted = new Map<string, string>() for (const layer of layers) { if (!layer.fontFamily || typeof layer.text !== 'string') continue const key = `${layer.fontWeight ?? 700} 40px "${layer.fontFamily}"` wanted.set(key, (wanted.get(key) ?? '') + layer.text) } await Promise.all( [...wanted].map(([font, sample]) => document.fonts.load(font, sample || 'A').catch(() => [])), ) } The stage calls this before creating any objects.
Pitfall 3: Latin-only faces
Designs mix Latin display faces (Playfair Display, Great Vibes) with Devanagari faces (Noto Sans Devanagari, Baloo 2, Kalam, Yatra One, Rozha One). All are open-source fonts from Google Fonts, self-hosted. If someone types Nepali into a Latin-only face, the code swaps in Noto Sans Devanagari rather than letting the OS pick something random:
const hasDevanagari = (value: string) => /[\u0900-\u097F]/.test(value) export function fontForText(value: string, wanted: FontFamily): FontFamily { return hasDevanagari(value) && !templateFont(wanted).devanagari ? 'Noto Sans Devanagari' : wanted } From canvas to MP4
The story video takes a 9:16 design and animates it: the scene opens, then the logo, kicker, headline, Nepali line, English line and shop name arrive in turn, and the whole thing holds. It's silent and short. The free "Reveal" motion works out to about 4.3 seconds at 30 fps.
Animation is just a pure function of time
Each layer gets a "kind" from its id or role (scene, logo, headline, shop...), and each kind has a cue: start time, duration, starting scale, how far it rises.
const REVEAL_CUES = { scene: { start: 0, dur: 1.15, fromScale: 1.06 }, logo: { start: 1.2, dur: 0.5, fromScale: 0.9 }, kicker: { start: 1.55, dur: 0.4, rise: 14 }, headline: { start: 1.95, dur: 0.5, rise: 18 }, sub: { start: 2.45, dur: 0.45, rise: 14 }, english: { start: 2.9, dur: 0.45, rise: 12 }, shop: { start: 3.35, dur: 0.45, rise: 10 }, // ... } Given a time t, every layer's opacity, scale and offset is computed with an ease-out curve. No requestAnimationFrame state, no timers. That matters because the encoder doesn't run in real time: frame 97 is just paint(97 / 30). The in-page preview and the exported file use the same function, so what you see is what you download.
Safe zones
Instagram, Facebook and TikTok draw their own UI over the top and bottom of a story. The layout code keeps text and the logo below the top 250px and above the bottom 350px, while the background scene stays full-bleed:
export const STORY_SAFE_TOP = 250 export const STORY_SAFE_BOTTOM = 350 If a design's text block sits outside that band, it is shifted as one block (logo included) so nothing gets cramped.
Encoding with WebCodecs + Mediabunny
On Chrome and Edge, VideoEncoder can produce H.264, and Mediabunny muxes it into an MP4 in memory. This is the heart of it:
const output = new Output({ format: new Mp4OutputFormat({ fastStart: 'in-memory' }), target: new BufferTarget(), }) const source = new CanvasSource(stage.element, { codec, // 'avc', checked with getFirstEncodableVideoCodec quality: new Quality({ bitrate: 8_000_000 }), latencyMode: 'quality', keyFrameInterval: 2, }) output.addVideoTrack(source, { frameRate: 30 }) await output.start() for (let i = 0; i < frameCount; i++) { paintStoryFrame(stage, i / 30) // pose layers, render await source.add(i / 30, 1 / 30) // timestamp, duration onProgress?.((i + 1) / frameCount) } await output.finalize() fastStart: 'in-memory' puts the moov atom at the front, which upload pipelines prefer. Because frames are added with explicit timestamps, encoding isn't tied to the clock. It runs as fast as the machine allows.
Two small things that bit me:
- Retina canvases. Fabric scales the backing store by
devicePixelRatio. On a MacBook that silently turns a 1080x1920 canvas into 2160x3840. For video the stage setsenableRetinaScaling: false. - Object caching. Fabric caches objects as bitmaps. When you change opacity and scale every frame, caching just adds work, so it's turned off for animated stages.
Browser support: a fallback ladder
WebCodecs H.264 isn't everywhere, so the exporter tries options in order (simplified; the real code also catches each failure and moves to the next rung):
if (await canEncode('avc')) return encodeCanvas(stage, 'mp4') // Chrome, Edge if (recorderMime('mp4')) return recordCanvas(stage, 'mp4') // MediaRecorder MP4 (e.g. Safari) if (await canEncode('vp9', 'vp8')) return encodeCanvas(stage, 'webm') if (recorderMime('webm')) return recordCanvas(stage, 'webm') // e.g. Firefox throw new Error('This browser can’t record a video. Open the page in Chrome or Edge and try again.') The MediaRecorder path is the awkward one. It records a captureStream() from the canvas in real time, so the loop has to pace itself to 30 fps, and the download takes as long as the video. Where the track supports requestFrame(), it captures at 0 fps and pushes each frame by hand, which is more reliable than letting the browser sample. While recording, the canvas is also mounted off-screen in the document rather than left detached.
WebM is a real file, but Instagram and TikTok want MP4. So the page tells users that before they download, and again after, instead of handing them a file that fails on upload. Silent failure was the thing I most wanted to avoid.
What I'd tell anyone doing this
- Treat complex-script text as atomic. Never split strings for drawing or animating unless you split by grapheme cluster and know the result still shapes.
- Load fonts with the real text you'll draw, then render.
- Make animation a pure function of time so preview and export can't drift.
- Detect encoder support up front and tell the user what format they'll get.
If you want to see it working, the poster maker is free to use. Pick a 9:16 design, add a logo, and try the motion export in Chrome and then Safari. I'd love feedback from anyone who has fought canvas text in other scripts like Bengali, Tamil or Arabic. Did you hit the same per-character problems?
