scrape / NextJS_Personal_Caption_Studio_PRD.md
kjbytes's picture
push code
ef4c36f
|
Raw
History Blame Contribute Delete
24.1 kB

PRD: Personal Video Caption Studio

Next.js App (Private Use)

Version: 1.0 | Date: July 5, 2026


1. WHAT THIS IS

A personal web app that burns animated, word-by-word captions onto videos. You upload a video + text (or SRT), style the captions, preview, and download the result.

Inspiration: The viral TikTok/Reels caption style β€” bold text, black stroke, bounce animation.


2. HOW IT WORKS (USER FLOW)

Upload Video ──► Paste Text / Upload SRT ──► Style Captions ──► Preview ──► Export MP4
     β”‚                  β”‚                        β”‚                β”‚            β”‚
     β–Ό                  β–Ό                        β–Ό                β–Ό            β–Ό
  Drag & drop      Type or paste           Pick font,          Play with     Download
  or file picker   words manually          color, size,        captions      to disk
                   or import .srt          position,           burned in
                                           animation speed

3. FEATURES

3.1 Must-Have (MVP)

# Feature How It Works
1 Upload video Drag & drop or click. Accept MP4, MOV, WebM. Max 500MB, 5 min.
2 Add captions Textarea: type/paste words. Or upload .srt file.
3 Auto-sync timing If no SRT: split text by words, estimate ~200ms per word + pauses.
4 Style captions Font, size, fill color, stroke color, stroke width, shadow.
5 Position Top/center/bottom + fine-tune Y offset.
6 Preview Play video with real-time caption overlay in browser.
7 Export Server-side FFmpeg renders video + captions β†’ MP4 download.

3.2 Nice-to-Have (v2)

# Feature Notes
8 Upload audio Separate voiceover track
9 Auto-transcribe Whisper API (OpenAI) β€” costs money, optional
10 Timeline editor Drag word timings visually
11 Presets Save style combos ("Viral Yellow", "Minimal White", etc.)
12 Batch export Queue multiple videos

4. TECH STACK

Frontend (Browser)
β”œβ”€β”€ Next.js 15 (App Router)
β”œβ”€β”€ React 19 + TypeScript
β”œβ”€β”€ Tailwind CSS 4
β”œβ”€β”€ shadcn/ui (components)
β”œβ”€β”€ Framer Motion (caption animations in preview)
└── React Player / native <video> (video playback)

Backend (Next.js API Routes + Server Actions)
β”œβ”€β”€ FFmpeg (ffmepg-static or system install)
β”œβ”€β”€ Canvas API / node-canvas (render caption frames)
β”œβ”€β”€ Sharp (image processing, optional)
└── tmp (temp file cleanup)

Storage
β”œβ”€β”€ Local filesystem (uploads + exports in /tmp or ./storage)
└── No database needed β€” state is ephemeral

5. DATA MODELS

// types/index.ts

interface Project {
  id: string;                    // uuid
  videoPath: string;             // /tmp/uploads/abc123.mp4
  videoMeta: {
    width: number;
    height: number;
    duration: number;            // seconds
    fps: number;
  };
  captions: Caption[];
  style: CaptionStyle;
  layout: CaptionLayout;
  status: 'draft' | 'rendering' | 'done' | 'error';
  exportPath?: string;           // /tmp/exports/abc123_captioned.mp4
  createdAt: Date;
}

interface Caption {
  id: string;
  text: string;                  // single word or short phrase
  startMs: number;               // when to appear
  endMs: number;                 // when to disappear/dim
}

interface CaptionStyle {
  fontFamily: string;            // "Impact", "Oswald", "Anton", etc.
  fontSize: number;              // px, relative to video height
  fillColor: string;             // #FFD700
  strokeColor: string;           // #000000
  strokeWidth: number;           // px
  highlightColor?: string;       // #FFFFFF top highlight
  shadow: {
    color: string;
    blur: number;
    offsetX: number;
    offsetY: number;
  };
  uppercase: boolean;
  letterSpacing: number;
}

interface CaptionLayout {
  position: 'top' | 'center' | 'bottom';
  yOffset: number;               // px from position anchor
  maxWidthPercent: number;       // 0.9 = 90% of video width
  maxLines: number;              // how many lines visible at once
}

6. PAGE BREAKDOWN

Page 1: / β€” Upload

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  🎬 Caption Studio          [Settings]  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚
β”‚         β”‚                 β”‚             β”‚
β”‚         β”‚   DROP VIDEO    β”‚             β”‚
β”‚         β”‚    HERE         β”‚             β”‚
β”‚         β”‚   or click      β”‚             β”‚
β”‚         β”‚                 β”‚             β”‚
β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚
β”‚                                         β”‚
β”‚    MP4, MOV, WebM β€’ Max 500MB β€’ 5 min   β”‚
β”‚                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Actions:

  • Drag & drop video β†’ upload to /tmp/uploads/{uuid}.mp4
  • Extract metadata via FFmpeg: ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate,duration
  • Redirect to /editor?id={uuid}

Page 2: /editor β€” Captions + Style

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ← Back              Caption Studio              [Export]   β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                             β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚                         β”‚  β”‚  CAPTION TEXT            β”‚  β”‚
β”‚  β”‚    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚  β”‚
β”‚  β”‚    β”‚             β”‚      β”‚  β”‚  β”‚ Type or paste   β”‚    β”‚  β”‚
β”‚  β”‚    β”‚   VIDEO     β”‚      β”‚  β”‚  β”‚ your captions   β”‚    β”‚  β”‚
β”‚  β”‚    β”‚   PREVIEW   β”‚      β”‚  β”‚  β”‚ here...         β”‚    β”‚  β”‚
β”‚  β”‚    β”‚             β”‚      β”‚  β”‚  β”‚                 β”‚    β”‚  β”‚
β”‚  β”‚    β”‚  [captions  β”‚      β”‚  β”‚  β”‚ Or upload .srt  β”‚    β”‚  β”‚
β”‚  β”‚    β”‚   overlay]  β”‚      β”‚  β”‚  β”‚                 β”‚    β”‚  β”‚
β”‚  β”‚    β”‚             β”‚      β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚  β”‚
β”‚  β”‚    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β”‚  β”‚                         β”‚  β”‚
β”‚  β”‚    ▢️ ◀️ β–Άβ–Ά ⏸️          β”‚  β”‚  [Auto-sync] [Clear]    β”‚  β”‚
β”‚  β”‚                         β”‚  β”‚                         β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  STYLE                  β”‚  β”‚
β”‚                               β”‚  ─────────────────────  β”‚  β”‚
β”‚                               β”‚  Font: [Impact β–Ό]       β”‚  β”‚
β”‚                               β”‚  Size: [━━━●━━━━] 48px  β”‚  β”‚
β”‚                               β”‚  Fill: [🟨] #FFD700     β”‚  β”‚
β”‚                               β”‚  Stroke: [⬛] #000 4px  β”‚  β”‚
β”‚                               β”‚  Highlight: [⬜] #FFF   β”‚  β”‚
β”‚                               β”‚  Shadow: [βœ“] blur:8     β”‚  β”‚
β”‚                               β”‚  Uppercase: [βœ“]         β”‚  β”‚
β”‚                               β”‚                         β”‚  β”‚
β”‚                               β”‚  POSITION               β”‚  β”‚
β”‚                               β”‚  [Top] [Center] [Bottom]β”‚  β”‚
β”‚                               β”‚  Y-offset: [━━●━━━━] 0  β”‚  β”‚
β”‚                               β”‚                         β”‚  β”‚
β”‚                               β”‚  ANIMATION              β”‚  β”‚
β”‚                               β”‚  Speed: [━━━●━━━] 1.0x  β”‚  β”‚
β”‚                               β”‚  Style: [Bounce β–Ό]      β”‚  β”‚
β”‚                               β”‚                         β”‚  β”‚
β”‚                               β”‚  [πŸ’Ύ Save Preset]       β”‚  β”‚
β”‚                               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Left Panel β€” Preview:

  • <video> element playing uploaded video
  • Overlay div with Framer Motion animated captions
  • Captions rendered as absolutely-positioned <span> elements
  • Animation mimics final FFmpeg output (for WYSIWYG)

Right Panel β€” Editor:

  • Text input: <textarea> for raw text
  • Auto-sync button: Split text by whitespace β†’ assign timings:
    wordDuration = 180ms + (chars * 40ms)
    gapBetweenWords = 60ms
    
  • SRT upload: Parse HH:MM:SS,mmm --> HH:MM:SS,mmm format
  • Style controls: Color pickers, sliders, toggles
  • Presets: JSON blobs saved to localStorage

Caption Preview Animation (Framer Motion):

// components/CaptionWord.tsx
<motion.span
  initial={{ scale: 0.5, opacity: 0, y: 20 }}
  animate={{ 
    scale: isActive ? [1, 1.05, 1] : isPast ? 0.95 : 1,
    opacity: isPast ? 0.5 : 1,
    y: 0
  }}
  transition={{ 
    type: "spring", 
    stiffness: 400, 
    damping: 15,
    duration: 0.3 
  }}
>
  {word.text.toUpperCase()}
</motion.span>

Page 3: /export β€” Render & Download

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  ← Back           Export Video          β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                         β”‚
β”‚         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”‚
β”‚         β”‚                 β”‚             β”‚
β”‚         β”‚   PREVIEW       β”‚             β”‚
β”‚         β”‚   (low-res)     β”‚             β”‚
β”‚         β”‚                 β”‚             β”‚
β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β”‚
β”‚                                         β”‚
β”‚  Settings:                              β”‚
β”‚  Resolution: [Original β–Ό] 1080x1920     β”‚
β”‚  Quality: [High β–Ό]                      β”‚
β”‚  Format: [MP4 (H.264) β–Ό]                β”‚
β”‚                                         β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚  β”‚  🎬 Rendering...                β”‚    β”‚
β”‚  β”‚  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘  67%        β”‚    β”‚
β”‚  β”‚  ~12 seconds remaining          β”‚    β”‚
β”‚  β”‚                                 β”‚    β”‚
β”‚  β”‚  [Cancel]                       β”‚    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
β”‚                                         β”‚
β”‚         [πŸ“₯ Download MP4]               β”‚
β”‚                                         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

7. SERVER-SIDE RENDERING PIPELINE

The Big Picture

Browser                              Server (Next.js API)
   β”‚                                        β”‚
   │── POST /api/upload ───────────────────►│
   β”‚   (multipart video)                    β”‚
   │◄─ 200 { id, meta } ────────────────────│
   β”‚                                        β”‚
   │── POST /api/render ───────────────────►│
   β”‚   { id, captions, style, layout }      β”‚
   β”‚                                        β”‚
   β”‚   Server does:                         β”‚
   β”‚   1. Extract frames (FFmpeg)           β”‚
   β”‚   2. Render captions on each frame     β”‚
   β”‚      (Canvas 2D / node-canvas)         β”‚
   β”‚   3. Encode frames + audio β†’ MP4       β”‚
   β”‚      (FFmpeg)                          β”‚
   β”‚                                        β”‚
   │◄─ 200 { downloadUrl } ◄───────────────│
   β”‚                                        β”‚
   │── GET /api/download/{id} ─────────────►│
   │◄─ Stream MP4 ◄─────────────────────────│

Step-by-Step Render Process

// app/api/render/route.ts

import { NextRequest, NextResponse } from 'next/server';
import { exec } from 'child_process';
import { promisify } from 'util';
import { createCanvas, loadImage, registerFont } from 'canvas';
import fs from 'fs/promises';
import path from 'path';

const execAsync = promisify(exec);

export async function POST(req: NextRequest) {
  const { id, captions, style, layout } = await req.json();

  const uploadDir = `/tmp/uploads/${id}`;
  const renderDir = `/tmp/renders/${id}`;
  const outputPath = `/tmp/exports/${id}_captioned.mp4`;

  await fs.mkdir(renderDir, { recursive: true });

  // ── STEP 1: Extract frames ─────────────────────────────
  // Extract at video FPS, e.g. 30fps
  await execAsync(`
    ffmpeg -i ${uploadDir}/video.mp4 
    -vf "fps=30,scale=1080:-1:flags=lanczos" 
    -q:v 2 
    ${renderDir}/frame_%06d.png
  `);

  const frames = await fs.readdir(renderDir);
  const fps = 30;
  const frameDurationMs = 1000 / fps;

  // ── STEP 2: Render captions on each frame ──────────────
  for (let i = 0; i < frames.length; i++) {
    const currentMs = i * frameDurationMs;
    const framePath = path.join(renderDir, frames[i]);

    // Load frame
    const img = await loadImage(framePath);
    const canvas = createCanvas(img.width, img.height);
    const ctx = canvas.getContext('2d');

    // Draw original frame
    ctx.drawImage(img, 0, 0);

    // Determine which captions are visible
    const visibleCaptions = captions.filter(c => 
      c.startMs <= currentMs && c.endMs + 200 >= currentMs
    );

    // Render each visible caption word
    for (const cap of visibleCaptions) {
      const isActive = currentMs >= cap.startMs && currentMs < cap.endMs;
      const isPast = currentMs >= cap.endMs;

      renderCaptionWord(ctx, cap, isActive, isPast, style, layout, img.width, img.height);
    }

    // Save rendered frame
    const buffer = canvas.toBuffer('image/png');
    await fs.writeFile(framePath, buffer);
  }

  // ── STEP 3: Encode final video ─────────────────────────
  await execAsync(`
    ffmpeg -i ${uploadDir}/video.mp4 -i ${renderDir}/frame_%06d.png 
    -filter_complex "[0:a]acopy[audio];[1:v]format=yuv420p[video]" 
    -map "[video]" -map "[audio]" 
    -c:v libx264 -preset fast -crf 23 
    -c:a aac -b:a 128k 
    -movflags +faststart 
    -y ${outputPath}
  `);

  // Cleanup temp frames
  await fs.rm(renderDir, { recursive: true });

  return NextResponse.json({ 
    downloadUrl: `/api/download/${id}`,
    filename: `captioned_${id}.mp4`
  });
}

// Helper: render single word with stroke, fill, shadow
function renderCaptionWord(
  ctx: CanvasRenderingContext2D,
  caption: Caption,
  isActive: boolean,
  isPast: boolean,
  style: CaptionStyle,
  layout: CaptionLayout,
  videoW: number,
  videoH: number
) {
  const text = style.uppercase ? caption.text.toUpperCase() : caption.text;
  const fontSize = (videoH * style.fontSize) / 1080; // scale relative to 1080p

  ctx.font = `900 ${fontSize}px "${style.fontFamily}"`;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // Calculate position
  const x = videoW / 2;
  let y = videoH * 0.85; // default bottom
  if (layout.position === 'top') y = videoH * 0.15;
  if (layout.position === 'center') y = videoH * 0.5;
  y += layout.yOffset;

  // Animation state
  let scale = 1;
  let opacity = 1;

  if (isActive) {
    // Bounce in effect β€” calculate based on time into word
    const progress = Math.min(1, (Date.now() - caption.startMs) / 300);
    scale = 0.5 + (0.5 * easeOutElastic(progress));
    if (progress > 0.8) scale = 1 + 0.05 * Math.sin(Date.now() / 150);
  } else if (isPast) {
    scale = 0.95;
    opacity = 0.5;
  }

  ctx.save();
  ctx.translate(x, y);
  ctx.scale(scale, scale);
  ctx.globalAlpha = opacity;

  // Shadow
  if (style.shadow) {
    ctx.shadowColor = style.shadow.color;
    ctx.shadowBlur = style.shadow.blur;
    ctx.shadowOffsetX = style.shadow.offsetX;
    ctx.shadowOffsetY = style.shadow.offsetY;
  }

  // Stroke (outline)
  ctx.strokeStyle = style.strokeColor;
  ctx.lineWidth = style.strokeWidth * (videoH / 1080);
  ctx.lineJoin = 'round';
  ctx.strokeText(text, 0, 0);

  // Fill
  ctx.fillStyle = style.fillColor;
  ctx.fillText(text, 0, 0);

  // Highlight (top edge)
  if (style.highlightColor) {
    ctx.fillStyle = style.highlightColor;
    ctx.globalAlpha = opacity * 0.3;
    ctx.fillText(text, 0, -2);
  }

  ctx.restore();
}

// Elastic ease-out for bounce
function easeOutElastic(x: number): number {
  const c4 = (2 * Math.PI) / 3;
  return x === 0 ? 0 : x === 1 ? 1 : Math.pow(2, -10 * x) * Math.sin((x * 10 - 0.75) * c4) + 1;
}

8. PROJECT STRUCTURE

my-caption-app/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ page.tsx                    # Upload page
β”‚   β”œβ”€β”€ editor/
β”‚   β”‚   └── page.tsx                # Editor + preview
β”‚   β”œβ”€β”€ export/
β”‚   β”‚   └── page.tsx                # Render progress + download
β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”œβ”€β”€ upload/
β”‚   β”‚   β”‚   └── route.ts            # POST: save uploaded video
β”‚   β”‚   β”œβ”€β”€ render/
β”‚   β”‚   β”‚   └── route.ts            # POST: FFmpeg render pipeline
β”‚   β”‚   β”œβ”€β”€ progress/
β”‚   β”‚   β”‚   └── route.ts            # GET: SSE or poll render progress
β”‚   β”‚   └── download/
β”‚   β”‚       └── [id]/
β”‚   β”‚           └── route.ts        # GET: stream MP4 file
β”‚   β”œβ”€β”€ layout.tsx
β”‚   └── globals.css
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ ui/                         # shadcn components
β”‚   β”œβ”€β”€ video-upload.tsx            # Drag & drop zone
β”‚   β”œβ”€β”€ video-preview.tsx           # Video + caption overlay
β”‚   β”œβ”€β”€ caption-word.tsx            # Animated word (Framer Motion)
β”‚   β”œβ”€β”€ caption-editor.tsx          # Text input + SRT upload
β”‚   β”œβ”€β”€ style-panel.tsx             # Font, color, size controls
β”‚   β”œβ”€β”€ timeline.tsx                # Visual word timeline (v2)
β”‚   └── export-progress.tsx         # Progress bar
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ utils.ts                    # cn(), helpers
β”‚   β”œβ”€β”€ ffmpeg.ts                   # FFmpeg command builders
β”‚   β”œβ”€β”€ caption-renderer.ts         # Canvas rendering logic
β”‚   β”œβ”€β”€ srt-parser.ts               # Parse .srt files
β”‚   └── timing.ts                   # Auto-sync algorithms
β”œβ”€β”€ types/
β”‚   └── index.ts                    # TypeScript interfaces
β”œβ”€β”€ public/
β”‚   └── fonts/                      # Impact, Oswald, Anton (WOFF2)
β”œβ”€β”€ next.config.js
β”œβ”€β”€ tailwind.config.ts
└── package.json

9. KEY DEPENDENCIES

{
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.5.0",
    "tailwindcss": "^4.0.0",
    "@tailwindcss/postcss": "^4.0.0",
    "framer-motion": "^11.0.0",
    "canvas": "^2.11.0",
    "ffmpeg-static": "^5.2.0",
    "ffprobe-static": "^3.1.0",
    "uuid": "^9.0.0",
    "tmp": "^0.2.0",
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.1.0",
    "tailwind-merge": "^2.3.0"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "@types/react": "^19.0.0",
    "@types/uuid": "^9.0.0",
    "@types/tmp": "^0.2.0"
  }
}

10. ENVIRONMENT SETUP

# .env.local
# No API keys needed for MVP!
# Just make sure FFmpeg is installed:

# macOS
brew install ffmpeg

# Ubuntu/Debian
sudo apt-get install ffmpeg

# Or use ffmpeg-static (bundled)

11. CRITICAL IMPLEMENTATION NOTES

11.1 Font Loading for Canvas

// lib/fonts.ts
import { registerFont } from 'canvas';
import path from 'path';

export function loadFonts() {
  registerFont(path.join(process.cwd(), 'public/fonts/Impact.ttf'), {
    family: 'Impact'
  });
  registerFont(path.join(process.cwd(), 'public/fonts/Oswald-Bold.ttf'), {
    family: 'Oswald'
  });
  // ... etc
}

11.2 Temp File Cleanup

// Run periodically or on shutdown
import tmp from 'tmp';

// Auto-cleanup on app exit
tmp.setGracefulCleanup();

// Or explicit cleanup after render
await fs.rm(`/tmp/uploads/${id}`, { recursive: true, force: true });

11.3 Progress Tracking (SSE)

// app/api/render/route.ts (streaming progress)

export async function POST(req: NextRequest) {
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < totalFrames; i++) {
        // render frame i...
        const progress = { percent: (i / totalFrames) * 100 };
        controller.enqueue(encoder.encode(`data: ${JSON.stringify(progress)}\n\n`));
      }
      controller.close();
    }
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'text/event-stream' }
  });
}

11.4 Performance Optimization

  • Extract frames at target resolution (not 4K if exporting 1080p)
  • Use ffmpeg -threads 4 for multi-core encoding
  • Render frames in batches (100 at a time) to avoid memory bloat
  • For long videos: stream process instead of loading all frames to RAM

12. QUICK START CHECKLIST

β–‘ npm create next-app@latest my-caption-app --typescript --tailwind --app
β–‘ cd my-caption-app
β–‘ npx shadcn@latest init
β–‘ npm install framer-motion canvas ffmpeg-static ffprobe-static uuid tmp
β–‘ npm install -D @types/uuid @types/tmp
β–‘ mkdir -p public/fonts && copy your fonts
β–‘ brew install ffmpeg (or apt-get)
β–‘ Implement /app/page.tsx (upload)
β–‘ Implement /app/editor/page.tsx (editor + preview)
β–‘ Implement /app/api/upload/route.ts
β–‘ Implement /app/api/render/route.ts
β–‘ Test with a 30s sample video
β–‘ Polish styles, add presets
β–‘ Done!

13. ESTIMATED BUILD TIME

Phase Time Deliverable
Setup + upload 2 hrs Video uploads, metadata extraction
Editor UI 4 hrs Split pane, controls, state management
Preview (browser) 3 hrs Framer Motion captions synced to video
Server render 6 hrs FFmpeg pipeline, Canvas rendering
Export + download 2 hrs Progress tracking, file serving
Polish 3 hrs Presets, error handling, cleanup
Total ~20 hrs Working MVP

Built for personal use. No auth, no payments, no analytics. Just you and your captions.