|
Download NextJS_Personal_Caption_Studio_PRD.md from d3evil4/scrape: direct link, hf CLI and curl.
- Browser
- Download file 24.1 kB
-
https://huggingface.co/spaces/d3evil4/scrape/resolve/main/NextJS_Personal_Caption_Studio_PRD.md
- Command line
-
hf download hf://spaces/d3evil4/scrape/NextJS_Personal_Caption_Studio_PRD.md
-
curl -L -o NextJS_Personal_Caption_Studio_PRD.md https://huggingface.co/spaces/d3evil4/scrape/resolve/main/NextJS_Personal_Caption_Studio_PRD.md
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 | |
| ```typescript | |
| // 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):** | |
| ```tsx | |
| // 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 | |
| ```typescript | |
| // 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 | |
| ```json | |
| { | |
| "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 | |
| ```bash | |
| # .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 | |
| ```typescript | |
| // 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 | |
| ```typescript | |
| // 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) | |
| ```typescript | |
| // 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.* | |