| import fs from "fs/promises"; |
| import path from "path"; |
| import { createCanvas, loadImage } from "canvas"; |
| import { renderFrameCaptions } from "@/lib/caption-renderer"; |
| import { encodeVideo, extractFrames } from "@/lib/ffmpeg"; |
| import { loadFonts } from "@/lib/fonts"; |
| import { clearProgress, setProgress } from "@/lib/progress"; |
| import { exportPath, renderDir, saveProject, videoPath } from "@/lib/storage"; |
| import type { Project } from "@/types"; |
|
|
| |
| export async function renderProject(project: Project) { |
| const { id, captions, style, layout, animation, videoMeta, audioPath: ttsAudio } = project; |
| const inputVideo = videoPath(id); |
| const framesDirectory = renderDir(id); |
| const outputVideo = exportPath(id); |
|
|
| await fs.mkdir(framesDirectory, { recursive: true }); |
| loadFonts(); |
|
|
| const targetWidth = Math.min(videoMeta.width, 1080); |
| const fps = Math.min(Math.round(videoMeta.fps) || 30, 30); |
| const framePattern = path.join(framesDirectory, "frame_%06d.png"); |
|
|
| setProgress(id, { |
| percent: 5, |
| stage: "extracting", |
| message: "Extracting video frames...", |
| }); |
|
|
| await extractFrames(inputVideo, framePattern, fps, targetWidth); |
|
|
| const frameFiles = (await fs.readdir(framesDirectory)) |
| .filter((f) => f.endsWith(".png")) |
| .sort(); |
|
|
| const totalFrames = frameFiles.length; |
| const frameDurationMs = 1000 / fps; |
| const batchSize = 50; |
|
|
| setProgress(id, { |
| percent: 15, |
| stage: "rendering", |
| message: `Rendering captions on ${totalFrames} frames...`, |
| }); |
|
|
| for (let i = 0; i < totalFrames; i++) { |
| const currentMs = i * frameDurationMs; |
| const framePath = path.join(framesDirectory, frameFiles[i]); |
| const img = await loadImage(framePath); |
| const canvas = createCanvas(img.width, img.height); |
| const ctx = canvas.getContext("2d"); |
|
|
| ctx.drawImage(img, 0, 0); |
| renderFrameCaptions( |
| ctx, |
| captions, |
| currentMs, |
| style, |
| layout, |
| animation, |
| img.width, |
| img.height, |
| ); |
|
|
| await fs.writeFile(framePath, canvas.toBuffer("image/png")); |
|
|
| if (i % batchSize === 0 || i === totalFrames - 1) { |
| const percent = 15 + (i / totalFrames) * 70; |
| setProgress(id, { |
| percent, |
| stage: "rendering", |
| message: `Rendering frame ${i + 1} of ${totalFrames}...`, |
| etaSeconds: Math.round(((totalFrames - i) / fps) * 0.5), |
| }); |
| } |
| } |
|
|
| setProgress(id, { |
| percent: 90, |
| stage: "encoding", |
| message: "Merging video, captions, and voiceover...", |
| }); |
|
|
| await encodeVideo(inputVideo, framePattern, outputVideo, fps, ttsAudio); |
| await fs.rm(framesDirectory, { recursive: true, force: true }); |
|
|
| project.status = "done"; |
| project.exportPath = outputVideo; |
| await saveProject(project); |
|
|
| setProgress(id, { |
| percent: 100, |
| stage: "done", |
| message: "Render complete!", |
| }); |
|
|
| setTimeout(() => clearProgress(id), 60_000); |
| } |
|
|