“🗓️ Simp1OS Dev Logs – The Sacred Texts of Simp1OS”

  1. The “Vision” (Literally)
    We decided that looking at a static screen was too easy. We wanted action. We wanted drama. We wanted a UI that looks like a high-end cheese shop but acts like a futuristic space station.

  2. The “Mouse” (The Red Ninja)
    Since writing a real USB/PS2 mouse driver involves talking to hardware that hasn’t changed since the 1980s, we created the Simulated Rodent System.

Appearance: A 8×8 red pixel square.

Movement: Powered by the Arrow Keys (and your sheer willpower).

Speed: Upgraded to 15 pixels per tap because we don’t have time for slow cursors in Class 6.

  1. The “Stage” System (The Magic Trick)
    The OS now has a “brain.” It remembers where it is:

Stage 0 (The Welcome): Where we befriend the user with a nice white box and a “Click Next” button that isn’t actually a button, but a coordinate-based trap for the Red Ninja.

Stage 1 (The Partition Party): The screen clears, the Branding stays (because we love our logo), and we show a list of “Dummy Partitions.”

Note: These partitions are “fake” for now. They are like the plastic fruit in a furniture store—they look great, but don’t try to install an OS on them yet.

  1. The “Stroke” Prevention Protocol
    In v0.01, the kernel was trying to redraw the screen 999,999 times a second, causing it to have a “visual heart attack” (flickering).

The Fix: We implemented Input Debouncing. The kernel now stays perfectly still and only moves a muscle if you touch a key. It’s basically “Red Light, Green Light” but with C++ code.

  1. The “Font” of Eternal Correction
    We finally taught the computer how to spell “Partition” and “files”. Before this, the kernel was lazy and skipped letters it didn’t recognize. Now, every pixel of every letter is hand-crafted like a fine Italian suit.

WebSockets for Real-Time Video Processing Progress Updates

Video processing jobs take 30-90 seconds. Leaving users staring at a spinner for that duration without feedback is a quick way to drive churn. Real-time progress updates via WebSocket transform “is this thing working?” anxiety into engaged “I can see it happening” confidence.

This post covers the complete WebSocket implementation for video job progress — server setup with ws, integration with BullMQ queue events, client-side hook in React, and graceful reconnection. This is the progress system running on ClipSpeedAI.

Architecture Overview

Client (React) ←WebSocket→ Node.js WS Server ←Bull Events→ Worker Process

The worker updates job progress via BullMQ’s progress API. The API server listens for Bull queue events and relays them to the appropriate WebSocket connection (matched by job ID).

Server Setup

// ws/server.js
import { WebSocketServer } from 'ws';
import { QueueEvents } from 'bullmq';
import { verifyJobOwnership } from '../lib/auth.js';

const redisConnection = {
  host: process.env.REDIS_HOST,
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD
};

// Map of jobId → Set of WebSocket connections
const jobConnections = new Map();

export function setupWebSocketServer(httpServer) {
  const wss = new WebSocketServer({
    server: httpServer,
    path: '/ws'
  });

  // Listen for Bull queue events
  const queueEvents = new QueueEvents('video:encode', { connection: redisConnection });

  queueEvents.on('progress', ({ jobId, data }) => {
    const connections = jobConnections.get(jobId);
    if (!connections) return;

    const message = JSON.stringify({
      type: 'progress',
      jobId,
      stage: data.stage,
      pct: data.pct,
      message: data.message
    });

    for (const ws of connections) {
      if (ws.readyState === ws.OPEN) {
        ws.send(message);
      }
    }
  });

  queueEvents.on('completed', ({ jobId, returnvalue }) => {
    const connections = jobConnections.get(jobId);
    if (!connections) return;

    const message = JSON.stringify({
      type: 'completed',
      jobId,
      result: JSON.parse(returnvalue || '{}')
    });

    for (const ws of connections) {
      if (ws.readyState === ws.OPEN) {
        ws.send(message);
        ws.close(1000, 'Job complete');
      }
    }

    jobConnections.delete(jobId);
  });

  queueEvents.on('failed', ({ jobId, failedReason }) => {
    const connections = jobConnections.get(jobId);
    if (!connections) return;

    const message = JSON.stringify({
      type: 'failed',
      jobId,
      error: failedReason
    });

    for (const ws of connections) {
      if (ws.readyState === ws.OPEN) ws.send(message);
    }

    jobConnections.delete(jobId);
  });

  // Handle new WebSocket connections
  wss.on('connection', async (ws, req) => {
    // Parse jobId from URL: /ws?jobId=xxx&token=yyy
    const params = new URL(req.url, 'http://localhost').searchParams;
    const jobId = params.get('jobId');
    const token = params.get('token');

    if (!jobId || !token) {
      ws.close(4001, 'Missing jobId or token');
      return;
    }

    // Verify the user owns this job
    const authorized = await verifyJobOwnership(token, jobId);
    if (!authorized) {
      ws.close(4003, 'Unauthorized');
      return;
    }

    // Register connection
    if (!jobConnections.has(jobId)) {
      jobConnections.set(jobId, new Set());
    }
    jobConnections.get(jobId).add(ws);

    // Send current job state immediately
    const currentState = await getCurrentJobState(jobId);
    if (currentState) {
      ws.send(JSON.stringify({ type: 'state', ...currentState }));
    }

    ws.on('close', () => {
      const connections = jobConnections.get(jobId);
      if (connections) {
        connections.delete(ws);
        if (connections.size === 0) jobConnections.delete(jobId);
      }
    });

    // Heartbeat
    ws.isAlive = true;
    ws.on('pong', () => { ws.isAlive = true; });
  });

  // Heartbeat interval to clean up dead connections
  const heartbeat = setInterval(() => {
    wss.clients.forEach(ws => {
      if (!ws.isAlive) return ws.terminate();
      ws.isAlive = false;
      ws.ping();
    });
  }, 30_000);

  wss.on('close', () => clearInterval(heartbeat));

  return wss;
}

Worker: Emitting Progress

// workers/processors/encode.js
export async function encodeProcessor(job) {
  const { videoPath, clipSegments } = job.data;

  await job.updateProgress({ stage: 'starting', pct: 5, message: 'Preparing video...' });

  // Face detection
  let cropX;
  try {
    await job.updateProgress({ stage: 'analyzing', pct: 15, message: 'Detecting faces...' });
    cropX = await detectAndComputeCrop(videoPath);
  } catch {
    cropX = null; // fallback
  }

  await job.updateProgress({ stage: 'encoding', pct: 30, message: 'Encoding clips...' });

  const outputPaths = [];
  for (let i = 0; i < clipSegments.length; i++) {
    await encodeClip(videoPath, clipSegments[i], cropX);
    const pct = 30 + Math.floor(((i + 1) / clipSegments.length) * 50);
    await job.updateProgress({
      stage: 'encoding',
      pct,
      message: `Encoded clip ${i + 1} of ${clipSegments.length}`
    });
    outputPaths.push(/* output path */);
  }

  await job.updateProgress({ stage: 'uploading', pct: 85, message: 'Uploading clips...' });
  const urls = await uploadClips(outputPaths);

  await job.updateProgress({ stage: 'complete', pct: 100, message: 'Done!' });
  return { clipUrls: urls };
}

React Client Hook

// hooks/useJobProgress.js
import { useEffect, useRef, useState, useCallback } from 'react';

const RECONNECT_DELAY = 2000;
const MAX_RECONNECTS = 5;

export function useJobProgress(jobId, authToken) {
  const [state, setState] = useState({ status: 'connecting', pct: 0, stage: '', message: '' });
  const wsRef = useRef(null);
  const reconnectCount = useRef(0);
  const reconnectTimer = useRef(null);

  const connect = useCallback(() => {
    if (!jobId || !authToken) return;

    const url = `${process.env.NEXT_PUBLIC_WS_URL}/ws?jobId=${jobId}&token=${authToken}`;
    const ws = new WebSocket(url);
    wsRef.current = ws;

    ws.onopen = () => {
      reconnectCount.current = 0;
      setState(prev => ({ ...prev, status: 'connected' }));
    };

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);

      switch (data.type) {
        case 'progress':
          setState({ status: 'processing', pct: data.pct, stage: data.stage, message: data.message });
          break;
        case 'completed':
          setState({ status: 'completed', pct: 100, stage: 'done', clips: data.result.clipUrls });
          break;
        case 'failed':
          setState({ status: 'failed', error: data.error, pct: 0, stage: '' });
          break;
        case 'state':
          setState({ status: data.status, pct: data.pct || 0, stage: data.stage || '' });
          break;
      }
    };

    ws.onclose = (event) => {
      if (event.code === 1000) return; // Normal close

      if (reconnectCount.current < MAX_RECONNECTS) {
        reconnectCount.current++;
        reconnectTimer.current = setTimeout(connect, RECONNECT_DELAY);
      } else {
        setState(prev => ({ ...prev, status: 'disconnected' }));
      }
    };

    ws.onerror = () => {
      ws.close();
    };
  }, [jobId, authToken]);

  useEffect(() => {
    connect();
    return () => {
      clearTimeout(reconnectTimer.current);
      wsRef.current?.close(1000, 'Component unmounted');
    };
  }, [connect]);

  return state;
}

React Component

// components/JobProgress.jsx
import { useJobProgress } from '../hooks/useJobProgress';

export function JobProgress({ jobId, token }) {
  const { status, pct, stage, message, clips, error } = useJobProgress(jobId, token);

  if (status === 'completed') {
    return (
      <div>
        <p>Processing complete! {clips?.length} clips ready.</p>
        {clips?.map((url, i) => (
          <a key={i} href={url} download>Download clip {i + 1}</a>
        ))}
      </div>
    );
  }

  if (status === 'failed') {
    return <p>Processing failed: {error}</p>;
  }

  return (
    <div>
      <div style={{ width: `${pct}%`, height: 4, background: '#4ade80', transition: 'width 0.3s' }} />
      <p>{message || stage} — {pct}%</p>
    </div>
  );
}

Connection Management at Scale

One pitfall: if thousands of users have open WebSocket connections and your server restarts, they all reconnect simultaneously. Rate-limit reconnections at the load balancer level, or use jittered reconnection delays on the client:

const delay = RECONNECT_DELAY * (1 + Math.random() * 0.5); // ±25% jitter
reconnectTimer.current = setTimeout(connect, delay);

ClipSpeedAI uses this exact pattern. The progress bar updating in real-time during processing is one of the features users mention most in feedback — it converts a passive wait into an engaged experience.

The full implementation above handles connection cleanup, dead connection detection via heartbeats, job ownership verification, and graceful reconnection. It’s the production-ready version of what most tutorials cover only superficially. The complete video processing pipeline it connects to — including AI clip selection and face tracking — is available as a hosted service at ClipSpeedAI.

ContextCore: AI Agents conversations to an MCP-queryable memory layer

Hello :). This OSS product is for you (or future-you) who reached the point of wanting to tap into the ton of knowledge you have in your AI chat histories. “Hey, Agent, we have a problem with SomeClass.function, remind me what we changed in the past few months”.

Product’s tl;dr:

ContextCore is a local-first memory layer that ingests AI coding chats across multiple IDE assistants and machines, makes them searchable (keyword + optional semantic), and exposes them to assistants over MCP so future sessions don’t start from zero.

IMPORTANT: I emphasize local-first, as in nothing is sent to any LLM other than when you explicitly use the MCP server in the context of using an LLM. However, once you engage semantic vector search OR chat content summarization, we DO use LLMs (although you can use local ones).

ContextCore is not just “chat history storage.” It is a developer-grade memory layer that turns AI-assisted development from ephemeral to iterative—where prior debugging sessions, architectural decisions, refactors, and tool-call outcomes become reusable context rather than lost effort.

More in the README.md in the repo.

This is the first time I show this in a public forum :). My hope is that I get a little bit of feedback, hopefully even traction, so that I can get some help to expand ContextCore’s compatibility (to add parsers for IntelliJ or other IDEs for example – which is quite easy now that the project has solid architecure docs and templates). The project has a roadmap in the README.

The endgame for ContextCore is to become an engineer’s reliable side-kick when it comes to digging into chat history and turning that into pure context gold at the MINIMUM amount of tokens spent. The current search system is decent, but much more can be done.

And my endgame is twofold: 1) give something back after being a lurker for years and 2) get some help to polish the search system and other areas of the product, so that we create an awesome, vendor-independent, cross-agent memory layer.

Thank you for reading this! 🙂

How I built a Blog app using Edge Functions

The app I am talking about,

Tech Stack,

  • Hono for easy routing and APIs
  • Cloudflare Workers for Edge functions and deployment
  • Prisma ORM to work with PostgreSQL
  • Prisma Accelerate for Connection Pool
  • NeonDB for Postgres Database on Cloud
  • React + Vite for Frontend Development
  • Tanstack Query to make API requests
  • Tanstack Router for file-based routing
  • AWS S3 for deployment of Frontend Build
  • AWS Cloudfront CDN for distribution of Frontend
  • npm to make custom package for Zod type to use both in FE and BE
  • Zod for validations and types

This project will have 3 directories at the end,
blogs/
├── backend/
├── common/
└── frontend/

In backend/ dir, we will write our Hono application.

Hono

Why we chose Hono to write routing and API logic?

Because cloudflare workers can’t run express as it relies heavily on Node.js specific APIs. Cloudflare has developed its own runtime other than Node to make the edge runtime faster.

Cloudflare official doc suggests 2 frameworks to develop APIs for workers.

As we want to use Typescript, we go with Hono.
Hono is the library that is built while keeping edge environments in mind. It is fast and Node independent. This library can run on any runtime environment.

Cloudflare Workers

Why deploy on Cloudflare Workers?

Cloudflare is a leading provider of CDNs.

Edge functions work on CDNs nearest you so you get low Latency.
Unlike AWS lambda (Serverless Functions) and Vercel serverless functions have cold starts before processing your request, these workers starts in ~0ms.

These are cheaper at scale because you are paying per req and not running a server 24/7 like a EC2 instance for example. (That’s why startups love it!)

Cloudflare protects your origin from DDoS attacks.

And most important part you can deploy a full end-to-end backend on it, but the only requirement is that every request your APIs process must have low compute time, less than 10ms. It means you can’t run ML model, of course.

Although Waiting on network requests like fetch() calls or database queries does not count toward CPU time.

But for CRUD app,this is enough!

In addition, cloudflare workers provide cool dashboard at the end where you can monitor your backend,

Also where your backend is hosting currently,

As you can see my initial deployment was in France! But if you all hit it enough then an Orange dot in Mumbai, India will pop up!

NeonDB

It is always a good idea to get a database on the cloud as you don’t have to worry about lots of infrastructure decisions like having to replicate database so that if one database goes down, your data is safe on the second one.

In production companies use AWS RDS instead.

Prisma ORM and Accelerate

Prisma ORM is the industry standard to work with Postgres database.

On top of that prisma provides support if you want to deploy your app on cloudflare workers.

Here they explained how to configure Prisma Client,


Deploy to Cloudflare Workers & Pages | Prisma Documentation

Learn the things you need to know in order to deploy an app that uses Prisma Client for talking to a database to a Cloudflare Worker or to Cloudflare Pages.

favicon
prisma.io

Now, there is one problem!
What If your backend is running let’s say on 50 regions simultaneously and each having 500 users querying for our database?

Your postgres DB can’t maintain the concurrent connection of 25000 users! it has a limit like 100-500 connections.

To solve that we use connection pools, Prisma provides one through their offering of Prisma accelerate.

What does a connection pool do?
It acts as a waiter who quickly takes those 25000 orders and delivers them to the chef (our database) in small bunches. We can set the size of those bunches, like 50 or 100. So even though 25000 queries are pending, postgres resolves them 50 at a time and does not get overwhelmed.

npm

Make an account on npmjs.com and enable 2-factor authentication.

We want to make a custom package and publish it to npm so that we use it in both frontend and backend.

We gonna publish that /common directory that consist our Zod validation code.

Our backend needs Zod schema objects to safeparse the data coming from frontend.

Our frontend needs Zod types derived from z.infer

So here’s how /common/src/ would look like,

common/
└── src/
    ├── validators/
    │   ├── blog.ts
    │   └── user.ts
    └── index.ts

index.ts exporting everything from blog.ts and user.ts

then we gonna change name in package.json to @<yournpmusername>/blog-app

then we will run $ npm login to authenticate ourselves
and $ npm publish --access public to publish our custom package to npm.

Now we can install it like any other library in both frontend and backend with just $ npm install @<yournpmusername>/blog-app@latest command.

React + Vite

So as every React dev knows that vite became the new default to develop frontend applications.

Vite has very low build time for react apps and officially react suggests that you should make react app using Vite.

Tanstack Query

So this is a modern standard of fetching data from backend APIs.

Tanstack Query provide best Developer Experience and it does so many important things automatically.

We are using it because we are too lazy to bother with state management libraries for data fetch and making useState hooks for isLoading and isError variables.

It also does automatic caching of the data fetched and remembers it through that queryKey property. Now if another component needs to hit that same backend API route and we provided this exact queryKey to it, then useQuery first find in cache if the query is already made by some other component or not, if it does then no request is made to the API.

Tanstack Router

Tanstack Router gives you ability to implement neat file-based routing to your frontend application.

AWS S3 and Cloudfront CDNs

After finishing frontend using mentioned technologies, we will just run,

$ npm run build

Now we will need an AWS account using credit card or your UPI number with autopay enabled to make S3 bucket and Cloudfront CDN Distribution.

Now making a Cloudfront CDN and connecting it to S3 bucket is very easy and quick than before.

When you successfully make an Cloudfront distribution (It will take sometime in deployment of newly created distribution), AWS will give you distribution URL where your site will host after deployment.

To deploy,

check if your “scripts” field looks like this,

"scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "deploy": "npm run build && aws s3 sync dist/ s3://blog-app-fe-edge --delete && aws cloudfront create-invalidation --distribution-id <Your_Cloudfront_Distribution_ID> --paths "/*""
  },

Look I included that “deploy” field from my end.
So whenever I run $ npm run deploy, my frontend automatically builds and stores the final HTML/CSS/JS files in the ./dist directory, removes old files that are not in sync with new files in ./dist, and the last command invalidates the currently cached version of the site across all Cloudfront instances.

After any deployment where you need changes to go live immediately you run that aws cloudfront create-invalidation ... command. Without it you users still gets that old version of the site which is stored as cache in their nearest Cloudfront CDN instanse.

Deploying frontends using aws S3 and Cloudfront is the industry standard.

Now you can go to that Cloudfront URL and visit you fresh app.

This is my URL,

Yeah it looks ugly, but what I need to do now is buy one domain name for 1500-2000 rupees and connect it with this Cloudfront Distribution URL, which is very simple process.

After buying decent domain, My end-to-end product is deployed professionally without me worrying about sophisticated infrastructural decisions.

Now I am sharing you my GitHub repo with source code,

GitHub logo

sujal-luhar
/
blog-app

Serverless blog app made using Cloudflare workers and Hono library

Architecture

User
  ↓ HTTPS
CloudFront (global CDN, edge caching)
  ↓ private OAC
S3 (stores dist/ files, not public)
  
User → API calls → Cloudflare Workers (Hono backend)
                        ↓
                  Prisma Accelerate
                        ↓
                    NeonDB (PostgreSQL)

After looking at the code you might say there are so many good code practices I could follow like,

  • Global error handling middleware
  • Whitelisting specific parameter
  • Unhandled promise rejection
  • Graceful Shutdowns
  • Logging
  • HTTP payloads
  • Rate Limiting
    etc.
    But you know what most of these are not applicable because the stack we used.

but still 2 things, Logging and Single global middleware to throw error message in the same format would be nice to have in my code.

You can sign up on shared Cloudfront URL without worrying about security as I am always hashing your passwords,

I believe in current age, every programmer must be familiar with how to use Edge networks to quickly dive into the market with a working product because speed matters.

That’s why I shared this system with you so that any stuck individuals can become unstuck.

Nisan 2026 Renault Fiyat Listesi Açıklandı

Renault’nun Nisan 2026 güncel fiyat listesi yayımlandı. Mart ayına kıyasla özellikle Megane Sedan tarafında minik fiyat değişimleri görülürken, bazı modellerde sınırlı artışlar ve stabil kalan fiyatlar öne çıkıyor.

Auto-Caption Generation: Whisper + FFmpeg in a Node.js Worker

Captions are no longer optional for short-form video. Studies consistently show 85%+ of social media videos are watched without sound. If your pipeline produces clips without captions, you’re shipping an inferior product. This post covers the full implementation: audio extraction, Whisper transcription, timing alignment, and burning captions directly into the video with FFmpeg.

This is part of the caption stack used by ClipSpeedAI.

The Approach: Hardcoded vs. Soft Captions

Two options exist:

  • Soft captions (SRT/VTT): Separate subtitle track, viewer can toggle. Doesn’t work reliably on Instagram or TikTok uploads.
  • Hardcoded captions (burned in): Rendered into the video pixels permanently. Works everywhere.

For YouTube Shorts, TikTok, and Instagram Reels uploads, hardcoded captions are the only reliable option. That’s what we’re building.

Step 1: Extract Audio From the Clip

// extract_audio.js
import { execa } from 'execa';
import path from 'path';

export async function extractAudio(videoPath) {
  const audioPath = videoPath.replace(/.(mp4|mov|webm)$/, '_audio.mp3');

  await execa('ffmpeg', [
    '-i', videoPath,
    '-vn',                    // no video
    '-acodec', 'mp3',
    '-ar', '16000',           // Whisper prefers 16kHz
    '-ac', '1',               // mono
    '-b:a', '64k',
    '-y',
    audioPath
  ]);

  return audioPath;
}

Downsampling to 16kHz mono before sending to Whisper reduces file size (faster upload) and matches Whisper’s internal sample rate, so there’s no quality loss.

Step 2: Transcribe With Whisper API

// transcribe.js
import OpenAI from 'openai';
import fs from 'fs';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function transcribeAudio(audioPath) {
  const response = await openai.audio.transcriptions.create({
    file: fs.createReadStream(audioPath),
    model: 'whisper-1',
    response_format: 'verbose_json',
    timestamp_granularities: ['word']  // word-level timestamps
  });

  return response.words; // [{ word, start, end }]
}

Word-level granularity is key here. Sentence-level timestamps give you segments that are too long for caption display — you’d be showing 15 words at a time, which is unreadable at normal viewing speed. Word-level lets you group into natural 3-6 word display chunks.

Step 3: Build Caption Chunks

// caption_chunker.js
export function buildCaptionChunks(words, maxWords = 5, maxDuration = 2.5) {
  const chunks = [];
  let current = [];
  let chunkStart = null;

  for (const word of words) {
    if (current.length === 0) {
      chunkStart = word.start;
    }

    current.push(word.word);
    const chunkDuration = word.end - chunkStart;

    if (current.length >= maxWords || chunkDuration >= maxDuration) {
      chunks.push({
        text: current.join(' ').toUpperCase(), // caps look better in short-form
        start: chunkStart,
        end: word.end
      });
      current = [];
      chunkStart = null;
    }
  }

  // Flush remaining words
  if (current.length > 0) {
    chunks.push({
      text: current.join(' ').toUpperCase(),
      start: chunkStart,
      end: words[words.length - 1].end
    });
  }

  return chunks;
}

The maxWords = 5 and maxDuration = 2.5 values were tuned empirically across hundreds of clips. 5 words at ~24pt font fills about 80% of the width on a 1080px-wide vertical frame.

Step 4: Write an ASS Subtitle File

FFmpeg’s drawtext filter works for simple captions, but ASS (Advanced SubStation Alpha) format gives you far better control over fonts, sizing, positioning, and per-word highlighting effects.

// ass_writer.js
import fs from 'fs';

export function writeAssFile(chunks, outputPath, videoWidth = 1080, videoHeight = 1920) {
  const header = `[Script Info]
ScriptType: v4.00+
PlayResX: ${videoWidth}
PlayResY: ${videoHeight}
ScaledBorderAndShadow: yes

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,${Math.floor(videoWidth * 0.055)},&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,10,10,${Math.floor(videoHeight * 0.1)},1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
`;

  const events = chunks.map(chunk => {
    const start = formatAssTime(chunk.start);
    const end = formatAssTime(chunk.end);
    return `Dialogue: 0,${start},${end},Default,,0,0,0,,{\an5}${chunk.text}`;
  }).join('n');

  fs.writeFileSync(outputPath, header + events);
}

function formatAssTime(seconds) {
  const h = Math.floor(seconds / 3600);
  const m = Math.floor((seconds % 3600) / 60);
  const s = Math.floor(seconds % 60);
  const cs = Math.floor((seconds % 1) * 100);
  return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
}

The {\an5} tag centers the subtitle both horizontally and vertically — relative to the MarginV from the bottom. For vertical video, positioning captions in the lower third works best.

Step 5: Burn Captions With FFmpeg

// caption_burner.js
import { execa } from 'execa';

export async function burnCaptions(videoPath, assPath, outputPath) {
  await execa('ffmpeg', [
    '-i', videoPath,
    '-vf', `ass=${assPath}`,
    '-c:v', 'libx264',
    '-preset', 'fast',
    '-crf', '22',
    '-c:a', 'copy',       // don't re-encode audio
    '-y',
    outputPath
  ]);
}

-c:a copy is important here — you’ve already encoded the audio once during the clip extraction step. Re-encoding it again introduces generational loss. Copy the audio stream directly.

Full Worker Integration

// caption_worker.js
import { extractAudio } from './extract_audio.js';
import { transcribeAudio } from './transcribe.js';
import { buildCaptionChunks } from './caption_chunker.js';
import { writeAssFile } from './ass_writer.js';
import { burnCaptions } from './caption_burner.js';
import fs from 'fs';
import path from 'path';

export async function addCaptionsToClip(clipPath) {
  const tmp = path.dirname(clipPath);
  const base = path.basename(clipPath, '.mp4');
  const audioPath = path.join(tmp, `${base}_audio.mp3`);
  const assPath = path.join(tmp, `${base}.ass`);
  const outputPath = path.join(tmp, `${base}_captioned.mp4`);

  try {
    const audioFile = await extractAudio(clipPath);
    const words = await transcribeAudio(audioFile);
    const chunks = buildCaptionChunks(words);
    writeAssFile(chunks, assPath);
    await burnCaptions(clipPath, assPath, outputPath);
    return outputPath;
  } finally {
    // Cleanup temp files
    [audioPath, assPath].forEach(f => {
      try { fs.unlinkSync(f); } catch {}
    });
  }
}

Performance Notes

  • Audio extraction takes ~2-5 seconds for a 60-second clip
  • Whisper API latency is typically 4-10 seconds for a 60-second audio file
  • Caption burning adds ~15-30 seconds to the FFmpeg encode (font rendering overhead)
  • Total caption pipeline: ~30-45 seconds per clip

For ClipSpeedAI, caption generation runs in the encode worker alongside the crop and scale step — it’s the same FFmpeg pass, so the caption burn doesn’t add a separate encode cycle.

Common Pitfalls

  1. Font not found on server: ASS subtitles fall back silently if the font isn’t installed. Use fontconfig or specify a font path explicitly: ass=${assPath}:fontsdir=/usr/share/fonts
  2. Timing drift: If you trim the clip before captioning, Whisper timestamps are relative to clip start — don’t offset them, they’re already correct.
  3. Long words overflowing: Add a maxWidth check and break words that exceed it. “Entrepreneurship” in all-caps at 55px will overflow a 1080px frame.

The full caption pipeline described here is in daily production use at ClipSpeedAI, processing clips for YouTube content repurposed to Shorts, Reels, and TikTok.