DEV Community

Cover image for I built a YouTube Shorts converter that runs entirely in your browser (no upload, no server)
dhritich20baruah
dhritich20baruah

Posted on

I built a YouTube Shorts converter that runs entirely in your browser (no upload, no server)

The problem

I wanted to created some youtube shorts out of some of my existing videos but most online YouTube Shorts converters that I found have the same playbook: upload your video, wait for server processing, download with a watermark, pay to remove the watermark. For a simple crop and resize operation — something that used to require server-side FFmpeg — this felt unnecessarily invasive.

I wanted to build something better. A tool that does the same job with zero server involvement. Your video stays on your device from start to finish and you do not have to create any account.

What I built

Convert to Shorts is a browser-based YouTube Shorts converter built with Vite, React, TypeScript, and ffmpeg.wasm. It converts horizontal video to 9:16 format, lets you trim clips from longer videos, add text overlays, and choose between center crop and blur letterbox styles — all without a single byte of video data touching a server.

Try it at converttoshorts.com.

The tech stack

  • Vite + React + TypeScript — fast dev experience, static output
  • Tailwind CSS v4 — new @theme token system for light/dark mode
  • ffmpeg.wasm — WebAssembly port of FFmpeg, runs in the browser
  • Vercel — static hosting with custom headers

The core challenge: ffmpeg.wasm in Vite

ffmpeg.wasm requires SharedArrayBuffer for multi-threading, which in turn requires two specific HTTP headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Enter fullscreen mode Exit fullscreen mode

Setting these in Vite's dev server is straightforward:

// vite.config.ts
export default defineConfig({
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
  optimizeDeps: {
    exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"],
  },
});
Enter fullscreen mode Exit fullscreen mode

The optimizeDeps.exclude is critical — without it Vite tries to pre-bundle the ffmpeg packages and fails because they contain WebAssembly binaries that Vite's optimizer can't handle.

For Vercel deployment, the same headers go in vercel.json:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
        { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Loading ffmpeg core

The ffmpeg.wasm package has two parts: the JavaScript glue code and the actual WebAssembly binary. I copy both to the public/ folder and load them at runtime using toBlobURL:

cp node_modules/@ffmpeg/core/dist/esm/ffmpeg-core.js public/ffmpeg-core.js
cp node_modules/@ffmpeg/core/dist/esm/ffmpeg-core.wasm public/ffmpeg-core.wasm
Enter fullscreen mode Exit fullscreen mode
await ffmpeg.load({
  coreURL: await toBlobURL("/ffmpeg-core.js", "text/javascript"),
  wasmURL: await toBlobURL("/ffmpeg-core.wasm", "application/wasm"),
});
Enter fullscreen mode Exit fullscreen mode

Serving from public/ means no CDN dependency in production and the files get cached by the browser after the first load — which is why the tool works offline after that first visit.

The video processing pipeline

Once ffmpeg is loaded, the pipeline is surprisingly clean. Write the input file to ffmpeg's virtual filesystem, run the command, read the output:

await ffmpeg.writeFile("input.mp4", await fetchFile(file));
await ffmpeg.exec(args);
const data = await ffmpeg.readFile("output.mp4");
Enter fullscreen mode Exit fullscreen mode

The ffmpeg command for center crop looks like this:

ffmpeg -ss 0.000 -to 15.000 -i input.mp4
  -vf "crop=608:1080:656:0,scale=1080:1920,setsar=1"
  -c:v libx264 -preset fast -crf 18
  -c:a aac -b:a 128k
  -movflags +faststart
  -y output.mp4
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

  • -ss and -to handle trimming (placed before -i for fast seeking)
  • crop=608:1080:656:0 crops a 608×1080 window starting at x=656 from the source
  • scale=1080:1920 scales to the output resolution
  • -crf 18 is visually lossless quality (23 for medium quality)
  • -movflags +faststart moves the moov atom to the front for immediate playback

For blur letterbox the command uses filter_complex to compose a blurred background with a sharp foreground:

Text overlays

Adding text uses ffmpeg's drawtext filter. The tricky part is that ffmpeg.wasm runs in a sandboxed WebAssembly environment with no access to system fonts, so you have to bundle a font file and write it to the virtual filesystem before running the command:

await ffmpeg.writeFile("font.ttf", await fetchFile("/Roboto-Bold.ttf"));
Enter fullscreen mode Exit fullscreen mode

Then reference it in the filter:

drawtext=fontfile='font.ttf':text='Your text':fontcolor=white:fontsize=72:x=(w-text_w)/2:y=h-text_h-80:box=1:boxcolor=black@0.4:boxborderw=16
Enter fullscreen mode Exit fullscreen mode

The crop UI

The draggable crop window in the video preview is built entirely with CSS-positioned divs — no canvas, no SVG. Two dark overlay divs flank the crop window, and the crop window itself is a draggable div with an accent-colored border and corner handles. Drag state is tracked with useRef and mousemove/touchmove events on window so the drag continues even if the cursor moves outside the element quickly.

The cropX value (0–1 fraction of the display width) gets translated to source pixel coordinates before being passed to the ffmpeg crop filter.

The trim UI

The trim bar uses a similar approach — two absolutely positioned handle divs over a track div, with percentage-based widths derived from trimStart/trimEnd/duration. The selected region is highlighted in the accent color; outside regions are dimmed. No library needed.

Lessons learned

  1. Dynamic imports inside callbacks work in Vite. I wasted time trying to make static imports of @ffmpeg/ffmpeg work at the module level. The solution was to import them inside the async start callback, which Vite handles correctly without trying to bundle the packages at build time.

  2. ffmpeg.wasm needs system fonts explicitly. This one cost me a few hours. The drawtext filter silently fails or throws cryptic errors if you don't provide a fontfile parameter pointing to a font in the virtual filesystem. System fonts don't exist in the WebAssembly sandbox.

  3. COOP/COEP headers are non-negotiable. Without them SharedArrayBuffer is unavailable and ffmpeg.wasm falls back to single-threaded mode which is significantly slower. Setting them correctly in both the dev server and the production host is essential.

  4. optimizeDeps.exclude saves a lot of pain. Any package that ships WebAssembly binaries or uses new URL() patterns internally needs to be excluded from Vite's dependency optimizer.

What's next

  • More text styling options (font family, stroke, shadow)
  • Multiple text overlays
  • Auto-highlight clipping using transcript analysis

The full project is live at converttoshorts.com. It's completely free — no account, no watermark, no upload.

If you have questions about the ffmpeg.wasm integration or the Vite setup, drop them in the comments — happy to go deeper on any part of it.

Top comments (0)