Skip to content
Merged
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
21 changes: 21 additions & 0 deletions src/__tests__/video/pipeline-cleanup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import fs from "fs";
import os from "os";
import path from "path";
import { cleanupVideoArtifacts } from "../../lib/video/pipeline";

describe("cleanupVideoArtifacts", () => {
it("removes both the rendered output and the temp directory", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "doubtdesk-test-"));
const tempDir = path.join(tempRoot, "audio-run");
const outputLocation = path.join(tempRoot, "video.mp4");

fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(path.join(tempDir, "audio.mp3"), "dummy");
fs.writeFileSync(outputLocation, "dummy video");

await cleanupVideoArtifacts(tempDir, outputLocation);

expect(fs.existsSync(outputLocation)).toBe(false);
expect(fs.existsSync(tempDir)).toBe(false);
});
});
4 changes: 2 additions & 2 deletions src/app/api/doubts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export async function GET(req: Request) {
}

if (doubts.length > 0) {
const tagRows = await db
const tagRows: { doubtId: number; id: number; name: string; normalizedName: string }[] = await db
.select({
doubtId: doubtTagsTable.doubtId,
id: tagsTable.id,
Expand Down Expand Up @@ -432,7 +432,7 @@ export async function POST(req: Request) {
const tagsToInsert: (typeof tagsTable.$inferInsert)[] = [];

for (const name of normalizedTags) {
const match = existingTagsMap.get(name);
const match = existingTagsMap.get(name) as typeof tagsTable.$inferInsert | undefined;
if (match) {
savedTags.push(match as typeof tagsTable.$inferSelect);
} else {
Expand Down
9 changes: 0 additions & 9 deletions src/app/api/video/generate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,6 @@ export async function POST(req: Request) {
try {
const { content, imageUrl } = data;

// Use a configured, allowlisted origin — never request headers. Host and
// x-forwarded-proto are attacker-controlled here and would otherwise let a
// request choose the origin the background pipeline fetches assets from.
const baseUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
if (!baseUrl) {
throw new Error("APP_URL is not configured");
}

const jobId = randomUUID();
await db.insert(videoJobsTable).values({
id: jobId,
Expand All @@ -85,7 +77,6 @@ export async function POST(req: Request) {
email,
content: content ?? null,
imageUrl: imageUrl ?? null,
baseUrl,
lockKey,
},
});
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/video/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { currentUser } from "@clerk/nextjs/server";
import { db } from "@/configs/db";
import { videoJobsTable } from "@/configs/schema";
import { eq } from "drizzle-orm";
import { getVideoSignedUrl } from "@/lib/video/storage";

// Always run dynamically; an SSE stream must never be cached.
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -107,6 +108,16 @@ export async function GET(req: Request) {
videoType: row.videoType,
error: row.error,
};

if (snapshot.status === "completed" && snapshot.videoUrl) {
try {
snapshot.videoUrl = await getVideoSignedUrl(snapshot.videoUrl);
} catch (err) {
console.error("Failed to sign video URL:", err);
// keep stored object key; client will see a broken link rather than no status
}
}

const serialized = JSON.stringify(snapshot);
if (serialized !== lastSerialized) {
send(snapshot);
Expand Down
41 changes: 22 additions & 19 deletions src/inngest/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { inngest } from "./client";
import type { NonRetriableError } from "inngest";
import fs from "fs";
import path from "path";
import os from "os";
import { db } from "../configs/db";
import { doubtsTable, usersTable, pendingNotificationsTable, repliesTable, videoJobsTable } from "../configs/schema";
import { eq, inArray, and, lt } from "drizzle-orm";
Expand Down Expand Up @@ -38,28 +39,31 @@ export const cleanupTempAssets = inngest.createFunction(
{ id: "cleanup-temp-assets", triggers: [{ cron: "0 * * * *" }] },
async ({ step }: { step: InngestStep }) => {
const deletedFiles = await step.run("delete-old-files", async () => {
const tempDir = path.resolve("./public/temp-assets");
const videosDir = path.resolve("./public/videos");
const now = Date.now();
const retentionMs = 24 * 60 * 60 * 1000; // 24 hours
const now = Date.now();
let count = 0;

const cleanDir = (dirPath: string) => {
if (fs.existsSync(dirPath)) {
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath);
if (now - stats.mtimeMs > retentionMs) {
fs.unlinkSync(filePath);
count++;
}
const tmpRoot = os.tmpdir();
if (fs.existsSync(tmpRoot)) {
const entries = fs.readdirSync(tmpRoot);
for (const entry of entries) {
const entryPath = path.join(tmpRoot, entry);
const stats = fs.statSync(entryPath);
const isStale = now - stats.mtimeMs > retentionMs;

if (entry.startsWith("doubtdesk-audio-") && stats.isDirectory() && isStale) {
fs.rmSync(entryPath, { recursive: true, force: true });
count++;
continue;
}

if (/^video-.*\.mp4$/i.test(entry) && stats.isFile() && isStale) {
fs.unlinkSync(entryPath);
count++;
}
}
};
}

cleanDir(tempDir);
cleanDir(videosDir);
return count;
});

Expand Down Expand Up @@ -336,11 +340,10 @@ export { detectConfusionSpikes } from "../app/api/inngest/ConfusionSpikeDetector
export const generateVideo = inngest.createFunction(
{ id: "generate-video", retries: 0, triggers: [{ event: "video/generate.requested" }] },
async ({ event, step }: { event: InngestEvent; step: InngestStep }) => {
const { jobId, content, imageUrl, baseUrl, lockKey } = event.data as {
const { jobId, content, imageUrl, lockKey } = event.data as {
jobId: string;
content: string | null;
imageUrl: string | null;
baseUrl: string;
lockKey?: string;
};

Expand All @@ -351,7 +354,7 @@ export const generateVideo = inngest.createFunction(
try {
const result = await step.run("run-video-pipeline", async () => {
return await runVideoPipeline(
{ content, imageUrl, baseUrl },
{ content, imageUrl },
async ({ progress, step: label }) => {
await db
.update(videoJobsTable)
Expand Down
90 changes: 34 additions & 56 deletions src/lib/video/pipeline.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { renderMedia, selectComposition } from "@remotion/renderer";
import path from "path";
import fs from "fs";
import os from "os";
import Groq from "groq-sdk";
import axios from "axios";
import Tesseract from "tesseract.js";
Expand All @@ -20,8 +21,6 @@ export interface EnrichedScene extends SceneData {
export interface VideoPipelineInput {
content?: string | null;
imageUrl?: string | null;
/** Absolute base URL (e.g. https://host) used to reference generated audio assets. */
baseUrl: string;
}

export interface VideoPipelineResult {
Expand All @@ -36,6 +35,13 @@ export interface VideoProgress {

export type ProgressReporter = (update: VideoProgress) => Promise<void> | void;

export async function cleanupVideoArtifacts(tempDir: string, outputLocation: string): Promise<void> {
await Promise.all([
fs.promises.unlink(outputLocation).catch(() => {}),
fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {}),
]);
}

const groq = new Groq({
apiKey: process.env.GROQ_API_KEY || "dummy_key",
});
Expand Down Expand Up @@ -98,7 +104,7 @@ export async function runVideoPipeline(
onProgress: ProgressReporter = () => {},
): Promise<VideoPipelineResult> {
let content = input.content ?? undefined;
const { imageUrl, baseUrl } = input;
const { imageUrl } = input;

// 1. OCR if an image is provided and no text content was supplied.
if (imageUrl && !content) {
Expand Down Expand Up @@ -178,8 +184,7 @@ Return ONLY a JSON object with a "scenes" array.`;

// 4. Generate audio (free Google TTS).
await onProgress({ progress: 65, step: "Generating audio…" });
const tempDir = path.resolve("./public/temp-assets");
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "doubtdesk-audio-"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: Each run creates a unique temp directory with mkdtempSync, but only individual audio files are deleted and the directory itself is never removed, causing unbounded empty-directory buildup in /tmp. Remove the temp directory explicitly after file cleanup (and in failure paths). [missing cleanup]

Severity Level: Major ⚠️
⚠️ Every job leaves orphaned audio temp directories in /tmp.
⚠️ Growing directory clutter complicates debugging filesystem state.
⚠️ Slightly increased inode usage on long-lived workers.
Steps of Reproduction ✅
1. Trigger a video generation job via POST /api/video/generate
(src/app/api/video/generate/route.ts:22-84), which creates a video_jobs row and emits the
"video/generate.requested" event consumed by Inngest.

2. The generateVideo function in src/inngest/functions.ts:33-58 invokes runVideoPipeline,
and during step 4 (TTS), runVideoPipeline creates a unique tempDir using
fs.mkdtempSync(path.join(os.tmpdir(), "doubtdesk-audio-")) at
src/lib/video/pipeline.ts:180.

3. For each generated scene, runVideoPipeline writes audio-*.mp3 files inside tempDir
(lines 183-201) and, after rendering, deletes each audio file in the "Clean up temporary
audio files" block at src/lib/video/pipeline.ts:230-241; this cleanup only unlinks files
and never removes the tempDir directory itself.

4. After the pipeline completes, inspecting os.tmpdir() on the worker shows that the
doubtdesk-audio-* directory created at line 180 still exists but is now empty; repeating
successful jobs accumulates more orphaned temp directories under /tmp because
runVideoPipeline never deletes its tempDir.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/video/pipeline.ts
**Line:** 180:180
**Comment:**
	*Missing Cleanup: Each run creates a unique temp directory with `mkdtempSync`, but only individual audio files are deleted and the directory itself is never removed, causing unbounded empty-directory buildup in `/tmp`. Remove the temp directory explicitly after file cleanup (and in failure paths).

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎


const scenes: EnrichedScene[] = await Promise.all(
rawScenes.map(async (scene: SceneData, i: number): Promise<EnrichedScene> => {
Expand All @@ -201,66 +206,39 @@ Return ONLY a JSON object with a "scenes" array.`;
const combinedBuffer = Buffer.concat(audioBuffers);
await fs.promises.writeFile(audioPath, combinedBuffer);

return { ...scene, audioUrl: `${baseUrl}/temp-assets/${path.basename(audioPath)}` };
return { ...scene, audioUrl: `file://${audioPath}` };
}),
);

// 5. Render the video with Remotion.
await onProgress({ progress: 90, step: "Rendering video…" });
const entryPoint = path.resolve(process.cwd(), "src/lib/video/remotion/index.tsx");
const outputLocation = path.resolve(`./public/videos/video-${Date.now()}.mp4`);
if (!fs.existsSync(path.resolve("./public/videos"))) {
fs.mkdirSync(path.resolve("./public/videos"), { recursive: true });
}
const outputLocation = path.join(os.tmpdir(), `video-${Date.now()}.mp4`);

const { bundle } = await import("@remotion/bundler");
const bundleLocation = await bundle({ entryPoint });
try {
const { bundle } = await import("@remotion/bundler");
const bundleLocation = await bundle({ entryPoint });

const compositionId = "DoubtVideo";
const inputProps = { type: videoType, scenes };
const compositionId = "DoubtVideo";
const inputProps = { type: videoType, scenes };

const composition = await selectComposition({
serveUrl: bundleLocation,
id: compositionId,
inputProps,
});
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
outputLocation,
inputProps,
});
const composition = await selectComposition({
serveUrl: bundleLocation,
id: compositionId,
inputProps,
});
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
outputLocation,
inputProps,
});

// Clean up temporary audio files after a successful render.
await Promise.all(
scenes.map(async (scene: EnrichedScene) => {
try {
const fileName = path.basename(scene.audioUrl);
const localPath = path.join(tempDir, fileName);
if (fs.existsSync(localPath)) await fs.promises.unlink(localPath);
} catch (err) {
console.error("Failed to delete temp audio file:", err);
}
}),
).catch((err) => console.error("Error during temp audio cleanup:", err));

// Persist the render to durable object storage (issue #321). The local
// public/videos path is ephemeral in serverless/Inngest execution and isn't
// guaranteed to be served by the instance handling playback, so upload to
// Supabase Storage and return that URL. Falls back to the local path only when
// storage is not configured (e.g. local dev).
const objectName = `renders/${path.basename(outputLocation)}`;
const uploadedUrl = await uploadVideo(outputLocation, objectName);
if (uploadedUrl) {
await fs.promises.unlink(outputLocation).catch(() => {});
return { videoUrl: uploadedUrl, videoType };
const objectName = `renders/${path.basename(outputLocation)}`;
const videoUrl = await uploadVideo(outputLocation, objectName);
return { videoUrl, videoType };
} finally {
await cleanupVideoArtifacts(tempDir, outputLocation);
}

console.warn(
"[video] durable storage not configured; returning ephemeral local path. Set " +
"NEXT_PUBLIC_SUPABASE_URL and a Supabase key (SUPABASE_SERVICE_ROLE_KEY " +
"recommended) to persist renders in production.",
);
return { videoUrl: `/videos/${path.basename(outputLocation)}`, videoType };
}
44 changes: 31 additions & 13 deletions src/lib/video/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,28 @@ import fs from "fs";
// Bucket that holds rendered videos. Override with SUPABASE_VIDEO_BUCKET.
const VIDEO_BUCKET = process.env.SUPABASE_VIDEO_BUCKET || "videos";

// How long generated video signed URLs remain valid (1 hour).
const SIGNED_URL_EXPIRY_SECONDS = 60 * 60;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The function now returns a 1-hour signed URL and that URL is persisted in video_jobs, so later status reads will serve an expired link and completed videos become unusable after expiry. Store a stable object key in the job record and generate a fresh signed URL when clients request status/result instead of persisting a short-lived signed URL. [api mismatch]

Severity Level: Critical 🚨
❌ Completed videos become inaccessible after signed URL expiry.
❌ Status endpoint serves expired links, confusing client behavior.
⚠️ Users lose ability to revisit generated explanations later.
Steps of Reproduction ✅
1. Start a video generation by calling POST /api/video/generate
(src/app/api/video/generate/route.ts:22-84), which inserts a video_jobs row and sends a
"video/generate.requested" event with jobId to Inngest.

2. The generateVideo function in src/inngest/functions.ts:33-75 calls runVideoPipeline;
when runVideoPipeline finishes, generateVideo updates videoJobsTable to set videoUrl and
videoType from result.videoUrl and result.videoType at lines 60-71, persisting the
returned videoUrl string in the database.

3. Inside runVideoPipeline (src/lib/video/pipeline.ts:243-250),
uploadVideo(outputLocation, objectName) is called; uploadVideo in
src/lib/video/storage.ts:19-49 uploads the MP4 and then creates a Supabase signed URL via
createSignedUrl(objectName, SIGNED_URL_EXPIRY_SECONDS) at line 42, where
SIGNED_URL_EXPIRY_SECONDS is defined as 60 * 60 (1 hour) at line 8, and returns this
short-lived signedUrl.

4. A client streams GET /api/video/status?jobId=… from
src/app/api/video/status/route.ts:31-151, receiving JobSnapshot events that include
videoUrl and videoType (lines 101-109); the client uses snapshot.videoUrl for playback. If
the user revisits the job after more than one hour, the SSE stream still reports status
"completed" and the same stored videoUrl from videoJobsTable, but Supabase rejects
requests to that expired signed URL, so the completed video can no longer be viewed even
though the job is marked successful.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/lib/video/storage.ts
**Line:** 8:8
**Comment:**
	*Api Mismatch: The function now returns a 1-hour signed URL and that URL is persisted in `video_jobs`, so later status reads will serve an expired link and completed videos become unusable after expiry. Store a stable object key in the job record and generate a fresh signed URL when clients request status/result instead of persisting a short-lived signed URL.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Comment thread
coderabbitai[bot] marked this conversation as resolved.

function getStorageClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
// Prefer a service-role key for server-side writes; fall back to the anon key
// (which requires a public bucket / permissive insert policy).
const key =
process.env.SUPABASE_SERVICE_ROLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !key) return null;
return createClient(url, key, { auth: { persistSession: false } });
}

/**
* Upload a rendered video file to durable object storage (Supabase Storage) and
* return its public URL (issue #321).
*
* Returns `null` when storage isn't configured, so callers can fall back to a
* local/ephemeral path in development. Throws if an upload is attempted and fails.
*/
export async function uploadVideo(
localPath: string,
objectName: string,
): Promise<string | null> {
): Promise<string> {
const supabase = getStorageClient();
if (!supabase) return null;
if (!supabase) {
throw new Error(
"Video generation requires Supabase Storage. Please configure NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.",
);
}

const fileBuffer = await fs.promises.readFile(localPath);
const { error } = await supabase.storage
Expand All @@ -40,6 +38,26 @@ export async function uploadVideo(
throw new Error(`Video upload to storage failed: ${error.message}`);
}

const { data } = supabase.storage.from(VIDEO_BUCKET).getPublicUrl(objectName);
return data.publicUrl;
return objectName;
}

export async function getVideoSignedUrl(objectName: string): Promise<string> {
const supabase = getStorageClient();
if (!supabase) {
throw new Error(
"Video streaming requires Supabase Storage. Please configure NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY.",
);
}

const { data, error: signError } = await supabase.storage
.from(VIDEO_BUCKET)
.createSignedUrl(objectName, SIGNED_URL_EXPIRY_SECONDS);

if (signError || !data?.signedUrl) {
throw new Error(
`Failed to create signed URL for video${signError ? `: ${signError.message}` : ""}`,
);
}

return data.signedUrl;
}
Loading