Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,10 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

build

# Add to .gitignore
public/video-cache/
public/videos/
public/audios
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ use cli rendering instead of ssr
`
npx remotion render remotion/index.ts MyVideo output.mp4
`
new
`
node -p "JSON.stringify({captions:require('./remotion/captions/captions-1769673372841.json')})" > props.json && \
npx remotion render remotion/index.ts MyVideo output.mp4 --props=props.json
`
- runs directly in node.js
- bypasses next.js completely
- bundling and rendering work without errors
Expand Down
15 changes: 10 additions & 5 deletions app/actions/generate-audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { elevenlabs } from '@ai-sdk/elevenlabs';
import { experimental_generateSpeech as generateSpeech} from "ai"
import { writeFile } from 'fs/promises';
import path from 'path';
import { mp3ToWav } from './mp3-16k_wav';

export async function generateAudio(cleanedStory: string){
// const rawText = await generateStory(genre)
Expand All @@ -14,7 +15,7 @@ export async function generateAudio(cleanedStory: string){
model: elevenlabs.speech('eleven_flash_v2'),
text: cleanedStory,
voice: "cgSgspJ2msm6clMCkdW9",
outputFormat: "pcm_16000",
outputFormat: "mp3",
providerOptions: {
elevenlabs: {
voiceSettings: {
Expand All @@ -27,10 +28,14 @@ export async function generateAudio(cleanedStory: string){
})

const buffer = Buffer.from(result.audio.base64, "base64");
const fileName = `story-${Date.now()}.${result.audio.format}`;
const filePath = path.join(process.cwd(), "public/audios", fileName);

const baseName = `story-${Date.now()}`;
const mp3Path = path.join(process.cwd(), "public/audios", `${baseName}.mp3`);
const wavPath = path.join(process.cwd(), "public/audios", `${baseName}_16k.wav`);

await writeFile(filePath, buffer);
await writeFile(mp3Path, buffer);

await mp3ToWav(mp3Path, wavPath);


return {
Expand All @@ -39,7 +44,7 @@ export async function generateAudio(cleanedStory: string){
format: result.audio.format,
mediaType: result.audio.mediaType,
uint8Array: result.audio.uint8Array,
url: `/audios/${fileName}`
url: `/audios/${baseName}_16k.wav`,
},
metadata: result.providerMetadata
}
Expand Down
17 changes: 17 additions & 0 deletions app/actions/mp3-16k_wav.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use server"

import { execFile } from "child_process";
import { promisify } from "util";

const execFileAsync = promisify(execFile);

export async function mp3ToWav(mp3Path: string, wavPath: string) {
await execFileAsync("ffmpeg", [
"-y",
"-i", mp3Path,
"-ac", "1",
"-ar", "16000",
"-c:a", "pcm_s16le",
wavPath,
]);
Comment on lines +6 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check the file context and see if there are any related tests or usage patterns
find . -type f -name "*.ts" -o -name "*.js" | head -20

Repository: subhraneel2005/vidgen

Length of output: 607


🏁 Script executed:

# Look for test files related to mp3-16k_wav
fd -e test.ts -e test.js -e spec.ts -e spec.js | grep -i mp3

Repository: subhraneel2005/vidgen

Length of output: 47


🏁 Script executed:

# Check if there are any existing usages of execFile in the codebase
rg "execFile|execFileAsync" -A 3 -B 1

Repository: subhraneel2005/vidgen

Length of output: 629


🏁 Script executed:

# Verify the file exists and check its full content
find . -type f -name "mp3-16k_wav.ts"

Repository: subhraneel2005/vidgen

Length of output: 93


🏁 Script executed:

cat -n ./app/actions/mp3-16k_wav.ts

Repository: subhraneel2005/vidgen

Length of output: 530


🌐 Web query:

Node.js execFile default maxBuffer size child_process

💡 Result:

In modern Node.js, child_process.execFile()’s options.maxBuffer default is 1024 * 1024 bytes (1 MiB), and it applies to each of stdout and stderr (if either exceeds the limit, the child is terminated and output is truncated). (nodejs.org)

Historically (e.g., Node 0.11 / 8.x), the default was 200 * 1024 bytes (200 KiB). (r2.nodejs.org)

Citations:


🌐 Web query:

ffmpeg stderr output size progress logs buffer overflow

💡 Result:

When you run ffmpeg with stderr captured (e.g., via subprocess.PIPE) and you don’t continuously read it, the OS pipe buffer can fill up and ffmpeg will block/hang while trying to write more log/progress lines—this is often mistaken for a “buffer overflow.”[2]

Ways to fix/avoid it:

  • Don’t capture logs if you don’t need them: let stderr inherit the parent terminal (no pipe), or redirect it to a file.[2]
  • Continuously drain the pipe(s):
    • If you capture stderr, read it in a dedicated thread/async loop, or merge it into stdout and read one stream.
    • For CLI shells, redirect stderrstdout (e.g., 2>&1) if you want to pipe/parse it.[3]
  • Reduce/disable ffmpeg’s progress spam on stderr:
    • Disable the default stats/progress line: -nostats (since -stats is on by default).[1]
    • Reduce logging: -loglevel warning (or error). (This also reduces what gets written to stderr.)
    • Slow the update rate: -stats_period 1 (or higher) to emit less frequently.[1]
  • Use “program-friendly” progress instead of parsing stderr:
    • -progress <url> outputs periodic key=value lines designed for parsing, and the update rate is controlled by -stats_period.[1]
    • Common pattern: write progress to a pipe you read (often pipe:1 / stdout) while keeping logs minimal.[1]

If you share your exact command and how you’re capturing stderr (language + snippet), I can suggest a robust read/drain pattern for that runtime.


🏁 Script executed:

# Check how mp3ToWav is used in the codebase to understand typical audio lengths
rg "mp3ToWav" -B 2 -A 2

Repository: subhraneel2005/vidgen

Length of output: 915


🏁 Script executed:

# Search for any context about audio processing and file sizes
rg "duration|length|seconds|minutes|long|audio" -i app/actions/mp3-16k_wav.ts

Repository: subhraneel2005/vidgen

Length of output: 47


Increase maxBuffer to prevent ffmpeg stderr from overflowing execFile's 1MB default.
ffmpeg writes progress logs to stderr by default; for longer audio files, this can exceed the buffer limit and silently abort conversions. Increase maxBuffer to 10MB (or higher depending on typical audio length), or alternatively add the -nostats flag to suppress progress output.

💡 Suggested fix (increase maxBuffer)
 export async function mp3ToWav(mp3Path: string, wavPath: string) {
-  await execFileAsync("ffmpeg", [
+  await execFileAsync("ffmpeg", [
     "-y",
     "-i", mp3Path,
     "-ac", "1",
     "-ar", "16000",
     "-c:a", "pcm_s16le",
     wavPath,
-  ]);
+  ], { maxBuffer: 10 * 1024 * 1024 });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const execFileAsync = promisify(execFile);
export async function mp3ToWav(mp3Path: string, wavPath: string) {
await execFileAsync("ffmpeg", [
"-y",
"-i", mp3Path,
"-ac", "1",
"-ar", "16000",
"-c:a", "pcm_s16le",
wavPath,
]);
const execFileAsync = promisify(execFile);
export async function mp3ToWav(mp3Path: string, wavPath: string) {
await execFileAsync("ffmpeg", [
"-y",
"-i", mp3Path,
"-ac", "1",
"-ar", "16000",
"-c:a", "pcm_s16le",
wavPath,
], { maxBuffer: 10 * 1024 * 1024 });
}
🤖 Prompt for AI Agents
In `@app/actions/mp3-16k_wav.ts` around lines 6 - 16, The ffmpeg conversion in
mp3ToWav uses execFileAsync (promisified execFile) without increasing
stdout/stderr buffer, so long ffmpeg stderr output can overflow the default 1MB
and abort conversion; update the exec call in mp3ToWav to pass an options object
with a larger maxBuffer (e.g., maxBuffer: 10 * 1024 * 1024) to execFileAsync, or
alternatively add the "-nostats" flag to the ffmpeg args array to suppress
progress output; modify the call site that invokes execFileAsync with ("ffmpeg",
[...args...], { maxBuffer: 10 * 1024 * 1024 }) (or include "-nostats" in the
args) to resolve the buffer overflow.

}
102 changes: 102 additions & 0 deletions app/api/render-video/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// app/api/render-video/route.ts
import { renderMedia, selectComposition } from "@remotion/renderer";
import path from "path";
import fs from "fs";
import type { Caption } from "@remotion/captions";
import { cleanOldCache, getCachedVideo } from "@/lib/video-cache";

export async function POST(request: Request) {
const renderData = await request.json();

// load captions
let captions: Caption[] = [];
if (renderData.captionsPath) {
try {
const captionsFullPath = path.join(process.cwd(), renderData.captionsPath);
const captionsContent = fs.readFileSync(captionsFullPath, "utf-8");
captions = JSON.parse(captionsContent);
} catch (error) {
console.error("Error loading captions:", error);
return Response.json(
{ success: false, error: "Failed to load captions" },
{ status: 500 }
);
}
}
Comment on lines +8 to +25
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Block path traversal when loading captions.
renderData.captionsPath is client-controlled and is joined directly to process.cwd() (Line 15), so absolute paths or .. can escape the intended directory and read arbitrary files. Constrain to an allow‑listed base dir and reject paths outside it.

🔒 Proposed fix
-      const captionsFullPath = path.join(process.cwd(), renderData.captionsPath);
+      const captionsBaseDir = path.join(process.cwd(), "public", "captions");
+      const captionsFullPath = path.resolve(
+        captionsBaseDir,
+        renderData.captionsPath
+      );
+      if (!captionsFullPath.startsWith(captionsBaseDir + path.sep)) {
+        return Response.json(
+          { success: false, error: "Invalid captions path" },
+          { status: 400 }
+        );
+      }
🤖 Prompt for AI Agents
In `@app/api/render-video/route.ts` around lines 8 - 25, The POST handler reads a
client-controlled renderData.captionsPath and uses path.join(process.cwd(),
renderData.captionsPath) in the POST function, allowing path traversal; restrict
loading to a known base directory (e.g., a captions directory) and validate the
resolved path is inside it before reading. Change the logic around
path.join()/fs.readFileSync: compute the absolute resolved path (path.resolve or
equivalent) against your baseDir, verify the resolved path startsWith the
baseDir (or is within an allow-list), reject requests that violate this
constraint with a 400/403, and only then read and JSON.parse the file; keep the
existing error handling for read/parse failures. Ensure references: POST,
renderData.captionsPath, captionsFullPath, process.cwd(), and path.join are
updated accordingly.


// Download and cache background video if it's from Cloudinary
let videoUrl = renderData.videoUrl;
if (videoUrl && videoUrl.includes("cloudinary.com")) {
try {
videoUrl = await getCachedVideo(videoUrl);
console.log("Using cached video:", videoUrl);
} catch (error) {
console.error("Failed to cache video:", error);
return Response.json(
{ success: false, error: "Failed to download background video" },
{ status: 500 }
);
}
}
Comment on lines +27 to +40
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate Cloudinary URLs with URL parsing to avoid SSRF bypasses.
videoUrl.includes("cloudinary.com") (Line 29) is a substring check that can be bypassed via crafted URLs (userinfo/host tricks), enabling arbitrary fetches. Parse with new URL() and enforce protocol/hostname; reject other remote URLs.

🛡️ Proposed fix
-  if (videoUrl && videoUrl.includes("cloudinary.com")) {
-    try {
-      videoUrl = await getCachedVideo(videoUrl);
-      console.log("Using cached video:", videoUrl);
-    } catch (error) {
-      console.error("Failed to cache video:", error);
-      return Response.json(
-        { success: false, error: "Failed to download background video" },
-        { status: 500 }
-      );
-    }
-  }
+  if (videoUrl) {
+    let isCloudinary = false;
+    try {
+      const parsed = new URL(videoUrl);
+      isCloudinary =
+        parsed.protocol === "https:" &&
+        (parsed.hostname === "res.cloudinary.com" ||
+          parsed.hostname.endsWith(".cloudinary.com"));
+    } catch {
+      // Not an absolute URL → treat as local path
+    }
+
+    if (isCloudinary) {
+      try {
+        videoUrl = await getCachedVideo(videoUrl);
+        console.log("Using cached video:", videoUrl);
+      } catch (error) {
+        console.error("Failed to cache video:", error);
+        return Response.json(
+          { success: false, error: "Failed to download background video" },
+          { status: 500 }
+        );
+      }
+    } else if (/^https?:\/\//i.test(videoUrl)) {
+      return Response.json(
+        { success: false, error: "Unsupported remote video URL" },
+        { status: 400 }
+      );
+    }
+  }
🤖 Prompt for AI Agents
In `@app/api/render-video/route.ts` around lines 27 - 40, The substring check
using renderData.videoUrl is unsafe; replace the naive
includes("cloudinary.com") logic by parsing the URL with new
URL(renderData.videoUrl) and validate protocol and hostname before calling
getCachedVideo: ensure url.protocol === "https:" (or allow "http:" only if
justified) and that url.hostname === "cloudinary.com" or
url.hostname.endsWith(".cloudinary.com") (reject crafted userinfo/host
variants), and if validation fails return an error response instead of
attempting the fetch; keep the existing try/catch around getCachedVideo and only
call getCachedVideo when the parsed URL passes these checks.


// Create the final input props
const inputProps = {
hook: renderData.hook,
story: renderData.story,
audioUrl: renderData.audioUrl,
videoUrl: videoUrl, // Use cached local path
highlightColor: renderData.highlightColor,
captions: captions,
};

const bundleLocation = path.join(process.cwd(), "build");
const outputDir = path.join(process.cwd(), "public", "videos");

if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}

try {
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "MyVideo",
inputProps: inputProps,
timeoutInMilliseconds: 60000,
});

const lastCaptionEndMs = captions.length
? Math.max(...captions.map(c => c.endMs))
: 2000;

const durationInFrames = Math.ceil((lastCaptionEndMs / 1000) * 30);

// override duration
composition.durationInFrames = durationInFrames;

Comment on lines +59 to +75
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n app/api/render-video/route.ts | sed -n '59,75p'

Repository: subhraneel2005/vidgen

Length of output: 609


🌐 Web query:

Remotion composition fps durationInFrames override API documentation

💡 Result:

To “override” a composition’s fps / durationInFrames in Remotion, you generally don’t override them via inputProps directly; instead you either (A) compute dynamic metadata using calculateMetadata, or (B) pass different metadata when using APIs that accept it (like <Player>).

  • Static composition metadata (authoring-time): Set fps and durationInFrames on <Composition /> in Root.tsx. (remotion.dev)
  • Dynamic override (render-time / based on props or fetched data): Use the <Composition calculateMetadata={...} /> callback to return durationInFrames and/or fps (and/or width/height). Returned fields take precedence over the props you passed to <Composition>. (remotion.dev)
  • How the renderer picks up the overridden values: selectComposition() evaluates calculateMetadata() (with your inputProps) and returns the resolved fps / durationInFrames, which you then pass to renderMedia() / renderStill() / renderFrames() as the composition object. (remotion.dev)
  • Player-specific override: The <Player> does not use <Composition>; you pass fps and durationInFrames directly as props to <Player>. (remotion.dev)
  • Notes about “override parameters”: The dynamic-metadata guide documents that certain CLI override parameters (e.g. for dimensions) can take priority over values returned by calculateMetadata(). (remotion.dev)

Citations:


🏁 Script executed:

# Check if there's a calculateMetadata callback defined in the composition setup
grep -r "calculateMetadata" app/ --include="*.ts" --include="*.tsx"

Repository: subhraneel2005/vidgen

Length of output: 47


🌐 Web query:

Remotion renderMedia composition object durationInFrames how to pass render settings

💡 Result:

In renderMedia(), the composition argument is only a VideoConfig (metadata): {id, width, height, fps, durationInFrames, …}—so durationInFrames comes from that object (usually returned by selectComposition() / getCompositions()), not from “render settings”. Render settings are separate top-level options you pass to renderMedia() (e.g. codec, crf, imageFormat, concurrency, frameRange, etc.).[1]

Also note: Remotion config (remotion.config.ts) and CLI flags do not apply to the Node render APIs—you must pass options explicitly in your renderMedia() call.[2]

Example (get durationInFrames from the composition, pass render settings separately)

import {bundle} from '@remotion/bundler';
import {renderMedia, selectComposition} from '@remotion/renderer';

const serveUrl = await bundle({entryPoint: './src/index.ts'});

const inputProps = { /* your props */ };

// composition.durationInFrames is resolved here
const composition = await selectComposition({
  serveUrl,
  id: 'MyComposition',
  inputProps, // pass same inputProps here and to renderMedia
});

await renderMedia({
  serveUrl,
  composition,                 // includes durationInFrames/fps/width/height/id
  codec: 'h264',               // render setting
  crf: 18,                     // render setting
  concurrency: 4,              // render setting
  frameRange: null,            // or [0, composition.durationInFrames - 1]
  outputLocation: 'out.mp4',   // render setting
  inputProps,
});

If you need durationInFrames to be dynamic

Make it dynamic via calculateMetadata() on the <Composition>—that’s the supported way to compute durationInFrames (and also width/height/fps) from data/props before render.[3]

Sources: Remotion renderMedia() API[1], Remotion renderer API note about config/CLI flags[2], Remotion calculateMetadata() docs[3].


Use composition.fps instead of hardcoded 30 and handle missing captions properly.

The fps is hardcoded to 30 (line 71) when it should use composition.fps from the resolved composition metadata. The arbitrary 2000ms fallback when captions are missing (line 69) doesn't align with the actual composition duration.

However, note that directly mutating composition.durationInFrames after selectComposition() is not the intended Remotion API pattern. According to Remotion's documentation, dynamic composition metadata (like duration based on caption data) should be computed via the calculateMetadata() callback on the <Composition> component definition, not by mutating the composition object post-selection.

If the composition definition supports calculateMetadata(), move the caption-duration logic there so that selectComposition() returns the correct resolved duration. If that's not feasible for your architecture, at minimum fix the hardcoded fps:

- const durationInFrames = Math.ceil((lastCaptionEndMs / 1000) * 30);
- 
- // override duration
- composition.durationInFrames = durationInFrames;
+ const durationInFrames = lastCaptionEndMs
+   ? Math.ceil((lastCaptionEndMs / 1000) * composition.fps)
+   : composition.durationInFrames;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "MyVideo",
inputProps: inputProps,
timeoutInMilliseconds: 60000,
});
const lastCaptionEndMs = captions.length
? Math.max(...captions.map(c => c.endMs))
: 2000;
const durationInFrames = Math.ceil((lastCaptionEndMs / 1000) * 30);
// override duration
composition.durationInFrames = durationInFrames;
try {
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "MyVideo",
inputProps: inputProps,
timeoutInMilliseconds: 60000,
});
const lastCaptionEndMs = captions.length
? Math.max(...captions.map(c => c.endMs))
: 2000;
const durationInFrames = lastCaptionEndMs
? Math.ceil((lastCaptionEndMs / 1000) * composition.fps)
: composition.durationInFrames;
🤖 Prompt for AI Agents
In `@app/api/render-video/route.ts` around lines 59 - 75, The code computes
durationInFrames using a hardcoded 30fps and mutates
composition.durationInFrames after selectComposition; instead, move the
caption-based duration logic into the composition definition via
calculateMetadata so selectComposition returns correct metadata, or if you
cannot change the composition, compute frames using composition.fps (from the
returned composition) and avoid directly mutating
composition.durationInFrames—use the calculateMetadata hook on the <Composition>
(or a separate metadata calculation before rendering) to derive duration from
captions (use captions.length to handle missing captions and compute
lastCaptionEndMs safely).


const outputPath = path.join(outputDir, `story-${Date.now()}.mp4`);

await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
outputLocation: outputPath,
inputProps: inputProps,
timeoutInMilliseconds: 300000,
});

// Clean old cache after successful render
cleanOldCache(7);

return Response.json({
success: true,
videoPath: `/videos/${path.basename(outputPath)}`,
});
} catch (error) {
console.error("Rendering error:", error);
return Response.json(
{ success: false, error: "Failed to render video" },
{ status: 500 }
);
}
}
15 changes: 15 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,31 @@
import { RenderButton } from "@/components/render-button";
import ChooseHighlightColor from "@/components/screens/choose-highlight-color";
import ChooseBGVideo from "@/components/screens/choose-stock-bg";
import GenerateAudio from "@/components/screens/generate-audio-screen";
import GenerateCaptions from "@/components/screens/generate-captions-screen";
import ScriptGenerator from "@/components/screens/script-generator";


export default function Page() {
return (
<div className="flex-1 mt-6 ml-6 space-y-6">

{/* correct */}
<ScriptGenerator />

{/* correct */}
<GenerateAudio />

{/* incorrect, captions are getting generated manually r9*/}
<GenerateCaptions />

{/* correct */}
<ChooseBGVideo />

{/* correct */}
<ChooseHighlightColor />

Comment on lines +12 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove debug/internal comments before merging.

The inline comments ({/* correct */}, {/* incorrect, captions are getting generated manually r9*/}) appear to be developer notes rather than documentation. These should be removed or converted to meaningful documentation before merging.

The comment on line 19 (incorrect, captions are getting generated manually r9) suggests there may be an unresolved issue with the captions implementation.

🧹 Proposed cleanup
-
-      {/* correct */}
       <ScriptGenerator />
-
-      {/* correct */}
       <GenerateAudio />
-
-      {/* incorrect, captions are getting generated manually r9*/}
       <GenerateCaptions />
-
-      {/* correct */}
       <ChooseBGVideo />
-
-      {/* correct */}
       <ChooseHighlightColor />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{/* correct */}
<ScriptGenerator />
{/* correct */}
<GenerateAudio />
{/* incorrect, captions are getting generated manually r9*/}
<GenerateCaptions />
{/* correct */}
<ChooseBGVideo />
{/* correct */}
<ChooseHighlightColor />
<ScriptGenerator />
<GenerateAudio />
<GenerateCaptions />
<ChooseBGVideo />
<ChooseHighlightColor />
🤖 Prompt for AI Agents
In `@app/page.tsx` around lines 12 - 27, Remove the inline developer/debug
comments around the JSX components (ScriptGenerator, GenerateAudio,
GenerateCaptions, ChooseBGVideo, ChooseHighlightColor); delete the `{/* correct
*/}` and `{/* incorrect, captions are getting generated manually r9*/}` markers
or replace them with concise, meaningful JSDoc or TODO comments if persistent
context is required, and if the note about GenerateCaptions indicates an
unresolved bug, open/fix the underlying issue in the GenerateCaptions component
(or add a TODO with a link/issue id) so no ambiguous developer notes remain in
the merged code.

<RenderButton />
</div>
);
}
35 changes: 35 additions & 0 deletions components/render-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import { useVideoStoryStore } from '@/store/useVideoStoryStore';
import { useState } from 'react';
import { Button } from './ui/button';

export function RenderButton() {
const [isRendering, setIsRendering] = useState(false);
const getRenderData = useVideoStoryStore(state => state.getRenderData);

const handleRender = async () => {
setIsRendering(true);

// get plain json from zustand
const renderData = getRenderData();

// send to /api/render-video
const response = await fetch('/api/render-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(renderData) // <-- this is plain json data no zustand
});

const result = await response.json();
console.log('Video rendered:', result.videoPath);

setIsRendering(false);
};
Comment on lines +11 to +28
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing error handling and user feedback.

The fetch call has no error handling. Network failures, server errors (non-2xx responses), or JSON parse failures will cause unhandled exceptions, leaving isRendering stuck as true and the button permanently disabled.

🐛 Proposed fix with proper error handling
+import { toast } from 'sonner';
+
 // ...
 
     const handleRender = async () => {
         setIsRendering(true);
-
-        // get plain json from zustand
-        const renderData = getRenderData();
-
-        // send to /api/render-video
-        const response = await fetch('/api/render-video', {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify(renderData) // <-- this is plain json data no zustand
-        });
-
-        const result = await response.json();
-        console.log('Video rendered:', result.videoPath);
-
-        setIsRendering(false);
+        try {
+            const renderData = getRenderData();
+
+            const response = await fetch('/api/render-video', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify(renderData),
+            });
+
+            if (!response.ok) {
+                throw new Error(`Render failed: ${response.status}`);
+            }
+
+            const result = await response.json();
+            console.log('Video rendered:', result.videoPath);
+            toast.success('Video rendered successfully!');
+        } catch (error) {
+            console.error('Render error:', error);
+            toast.error('Failed to render video');
+        } finally {
+            setIsRendering(false);
+        }
     };
🤖 Prompt for AI Agents
In `@components/render-button.tsx` around lines 11 - 28, The handleRender function
lacks error handling and may leave isRendering true on failures; wrap the
fetch/response.json flow in a try/catch/finally around the existing
getRenderData()/fetch call, check response.ok and throw or handle non-2xx
responses before parsing JSON, catch JSON parse or network errors to log them
and surface user feedback (e.g., call a toast/alert or set an error state), and
ensure setIsRendering(false) is always executed in finally so the button is
re-enabled; reference handleRender, getRenderData, setIsRendering and the
'/api/render-video' request when making these changes.


return (
<Button onClick={handleRender} disabled={isRendering}>
{isRendering ? 'Rendering...' : 'Generate Final Video'}
</Button>
);
}
53 changes: 53 additions & 0 deletions components/screens/generate-captions-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import { useVideoStoryStore } from "@/store/useVideoStoryStore";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "../ui/button";
import { generateCaptions } from "@/remotion/scripts/generate-captions";

export default function GenerateCaptions() {
const { audioUrl, setCaptionsPath, captionsPath } = useVideoStoryStore();
const [loading, setLoading] = useState(false);

const handleGenerateCaptions = async () => {
if (!audioUrl) {
toast.error("Generate audio first");
return;
}

setLoading(true);
try {
const { captionsPath } = await generateCaptions(`public/${audioUrl}`);
setCaptionsPath(captionsPath);
} catch {
toast.error("Failed to generate captions");
} finally {
setLoading(false);
}
Comment on lines +19 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential path construction issue and error swallowing.

  1. Prepending public/ to audioUrl assumes audioUrl is a relative path without the public/ prefix. If the store already contains a full path or URL, this will fail.

  2. The catch block swallows the error without logging, making debugging difficult.

🔧 Suggested improvement
         setLoading(true);
         try {
-            const { captionsPath } = await generateCaptions(`public/${audioUrl}`);
+            // Ensure audioUrl doesn't already have public/ prefix
+            const normalizedPath = audioUrl.startsWith('public/') 
+                ? audioUrl 
+                : `public/${audioUrl}`;
+            const { captionsPath } = await generateCaptions(normalizedPath);
             setCaptionsPath(captionsPath);
-        } catch {
+        } catch (error) {
+            console.error("Caption generation failed:", error);
             toast.error("Failed to generate captions");
         } finally {
             setLoading(false);
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setLoading(true);
try {
const { captionsPath } = await generateCaptions(`public/${audioUrl}`);
setCaptionsPath(captionsPath);
} catch {
toast.error("Failed to generate captions");
} finally {
setLoading(false);
}
setLoading(true);
try {
// Ensure audioUrl doesn't already have public/ prefix
const normalizedPath = audioUrl.startsWith('public/')
? audioUrl
: `public/${audioUrl}`;
const { captionsPath } = await generateCaptions(normalizedPath);
setCaptionsPath(captionsPath);
} catch (error) {
console.error("Caption generation failed:", error);
toast.error("Failed to generate captions");
} finally {
setLoading(false);
}
🤖 Prompt for AI Agents
In `@components/screens/generate-captions-screen.tsx` around lines 19 - 27, The
code unconditionally prefixes `audioUrl` with "public/" and swallows exceptions;
update the call in the `try` block so it first normalizes/detects the form of
`audioUrl` (e.g. if it already starts with "http", "/", or "public/") and only
prepend "public/" when appropriate, then call `generateCaptions` with that
normalized path; in the `catch` block capture the thrown error (e.g. `err`) and
log it (console.error or process logger) before calling `toast.error` so
failures are observable, keeping the existing state updates via `setLoading` and
`setCaptionsPath` intact.

};

return (
<div className="w-full max-w-xl border border-border rounded-lg p-4 bg-secondary">
<span className="font-bold text-2xl">Generate captions</span>

{audioUrl ? (
<Button
onClick={handleGenerateCaptions}
disabled={loading}
className="mt-3"
>
{loading ? "Generating..." : "Generate Captions"}
</Button>
) : (
<p className="text-yellow-500 mt-3">Generate audio first</p>
)}

{captionsPath && (
<p className="mt-2 text-sm text-green-500">
Captions saved at: {captionsPath}
</p>
)}
</div>
);
}
33 changes: 26 additions & 7 deletions lib/getCaptions.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
"use server"

import { toCaptions, transcribe } from "@remotion/install-whisper-cpp";
import path from "path";
import fs from "fs";

export async function getCaptions(audioPath: string) {
const inputPath = path.isAbsolute(audioPath)
? audioPath
: path.join(process.cwd(), audioPath);

export async function getCaptions() {
const whisperCppOutput = await transcribe({
inputPath: path.join(process.cwd(), "public/audios/audio_16k.wav"),
inputPath,
whisperPath: path.join(process.cwd(), "whisper.cpp"),
whisperCppVersion: "1.5.5",
model: "medium.en",
tokenLevelTimestamps: true,
});
Comment on lines +7 to 18
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing error handling for transcription.

The transcribe() call can fail for various reasons (missing whisper.cpp, invalid audio format, corrupted file). Unhandled exceptions in Server Actions can expose internal errors to clients.

🛡️ Proposed fix with error handling
 export async function getCaptions(audioPath: string) {
   const inputPath = path.isAbsolute(audioPath)
     ? audioPath
     : path.join(process.cwd(), audioPath);
 
+  if (!fs.existsSync(inputPath)) {
+    throw new Error("Audio file not found");
+  }
+
+  let whisperCppOutput;
+  try {
-  const whisperCppOutput = await transcribe({
+    whisperCppOutput = await transcribe({
       inputPath,
       whisperPath: path.join(process.cwd(), "whisper.cpp"),
       whisperCppVersion: "1.5.5",
       model: "medium.en",
       tokenLevelTimestamps: true,
     });
+  } catch (error) {
+    console.error("Whisper transcription failed:", error);
+    throw new Error("Failed to transcribe audio");
+  }
🤖 Prompt for AI Agents
In `@lib/getCaptions.ts` around lines 7 - 18, The transcribe() call inside
getCaptions can throw and is currently unhandled; wrap the call to transcribe({
... }) in a try/catch, catch any error thrown by transcribe (and also guard
against a falsy whisperCppOutput), log the detailed error server-side (e.g.,
using your logger) and return or throw a sanitized/generic error message to the
caller so internal details aren’t exposed; update getCaptions to reference the
transcribe call and whisperCppOutput checks and ensure the function returns a
safe error/result path when transcription fails.


const { captions } = toCaptions({
whisperCppOutput,
});
const { captions } = toCaptions({ whisperCppOutput });

const fileName = `captions-${Date.now()}.json`;

const captionsDir = path.join(process.cwd(), "remotion/captions");
if (!fs.existsSync(captionsDir)) {
fs.mkdirSync(captionsDir, { recursive: true });
}

const captionsPath = path.join(captionsDir, fileName);

fs.writeFileSync(captionsPath, JSON.stringify(captions, null, 2));

return captions;
}

return {
captionsPath: `remotion/captions/${fileName}`,
};
}
Loading