diff --git a/src/__tests__/video/pipeline-cleanup.test.ts b/src/__tests__/video/pipeline-cleanup.test.ts new file mode 100644 index 00000000..79c9d3ca --- /dev/null +++ b/src/__tests__/video/pipeline-cleanup.test.ts @@ -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); + }); +}); diff --git a/src/app/api/doubts/route.ts b/src/app/api/doubts/route.ts index 710481a5..efde3cb4 100644 --- a/src/app/api/doubts/route.ts +++ b/src/app/api/doubts/route.ts @@ -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, @@ -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 { diff --git a/src/app/api/video/generate/route.ts b/src/app/api/video/generate/route.ts index 42ea6309..dd23394a 100644 --- a/src/app/api/video/generate/route.ts +++ b/src/app/api/video/generate/route.ts @@ -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, @@ -85,7 +77,6 @@ export async function POST(req: Request) { email, content: content ?? null, imageUrl: imageUrl ?? null, - baseUrl, lockKey, }, }); diff --git a/src/app/api/video/status/route.ts b/src/app/api/video/status/route.ts index 0e3fb056..f7c8663a 100644 --- a/src/app/api/video/status/route.ts +++ b/src/app/api/video/status/route.ts @@ -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"; @@ -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); diff --git a/src/inngest/functions.ts b/src/inngest/functions.ts index 9d069a62..26eb8ac8 100644 --- a/src/inngest/functions.ts +++ b/src/inngest/functions.ts @@ -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"; @@ -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; }); @@ -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; }; @@ -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) diff --git a/src/lib/video/pipeline.ts b/src/lib/video/pipeline.ts index 47d84ab2..cf11cfab 100644 --- a/src/lib/video/pipeline.ts +++ b/src/lib/video/pipeline.ts @@ -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"; @@ -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 { @@ -36,6 +35,13 @@ export interface VideoProgress { export type ProgressReporter = (update: VideoProgress) => Promise | void; +export async function cleanupVideoArtifacts(tempDir: string, outputLocation: string): Promise { + 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", }); @@ -98,7 +104,7 @@ export async function runVideoPipeline( onProgress: ProgressReporter = () => {}, ): Promise { 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) { @@ -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-")); const scenes: EnrichedScene[] = await Promise.all( rawScenes.map(async (scene: SceneData, i: number): Promise => { @@ -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 }; } \ No newline at end of file diff --git a/src/lib/video/storage.ts b/src/lib/video/storage.ts index 0275edc8..02b889fd 100644 --- a/src/lib/video/storage.ts +++ b/src/lib/video/storage.ts @@ -4,10 +4,11 @@ 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; + 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; @@ -15,19 +16,16 @@ function getStorageClient() { 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 { +): Promise { 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 @@ -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 { + 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; } \ No newline at end of file