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
144 changes: 144 additions & 0 deletions apps/web/src/app/api/convert-video/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { type NextRequest, NextResponse } from "next/server";
import { writeFile, readFile, rm, mkdtemp } from "fs/promises";
import { spawn } from "child_process";
import path from "path";
import os from "os";

const GENERIC_CONVERSION_ERROR = "Video conversion failed.";

function sanitizeContentDispositionName({
rawName,
}: {
rawName: string;
}): string {
// Strip characters that can break the Content-Disposition header (quotes,
// backslashes, CR/LF, control bytes) and replace non-ASCII so the
// "filename=" parameter stays unambiguous across clients.
const stripped = rawName
// eslint-disable-next-line no-control-regex
.replace(/[\\"\r\n\x00-\x1f]/g, "_")
.replace(/[^\x20-\x7e]/g, "_")
.slice(0, 200);
return stripped.length > 0 ? stripped : "video";
}

export async function POST(request: NextRequest) {
let workDir: string | null = null;

try {
const formData = await request.formData();
const file = formData.get("file") as File | null;

if (!file || !(file instanceof File)) {
return NextResponse.json(
{ error: "No file provided" },
{ status: 400 },
);
}

workDir = await mkdtemp(path.join(os.tmpdir(), "opencut-convert-"));
const ext = path.extname(file.name) || ".mp4";
const inputPath = path.join(workDir, `input${ext}`);
const outputPath = path.join(workDir, "output.mp4");

const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(inputPath, buffer);
Comment on lines +29 to +45
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add an explicit upload size limit before buffering.

await file.arrayBuffer() loads the entire payload into memory. A single oversized upload can exhaust memory and degrade availability. Reject large files early with 413.

🔧 Proposed fix
 const GENERIC_CONVERSION_ERROR = "Video conversion failed.";
+const MAX_UPLOAD_BYTES = 250 * 1024 * 1024; // adjust to product limit

 export async function POST(request: NextRequest) {
@@
 		if (!file || !(file instanceof File)) {
@@
 		}
+
+		if (file.size > MAX_UPLOAD_BYTES) {
+			return NextResponse.json(
+				{ error: "File too large" },
+				{ status: 413 },
+			);
+		}
 
 		workDir = await mkdtemp(path.join(os.tmpdir(), "opencut-convert-"));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/convert-video/route.ts` around lines 29 - 45, The route
currently calls await file.arrayBuffer() which can OOM for large uploads; add an
explicit upload size check before buffering: define a MAX_UPLOAD_SIZE constant
(e.g., in route.ts), check file.size (or fallback to
request.headers.get('content-length')) and if it exceeds the limit return
NextResponse.json({ error: "File too large" }, { status: 413 }); only proceed to
Buffer.from(await file.arrayBuffer()) and writeFile(inputPath, ...) when the
size is within limits. Target symbols: request.formData, the File object from
formData.get("file"), file.arrayBuffer(), and the inputPath/outputPath handling.


await runFfmpeg({
inputPath,
outputPath,
});

const convertedBuffer = await readFile(outputPath);

const safeName = sanitizeContentDispositionName({
rawName: path.basename(file.name, ext),
});

return new Response(new Uint8Array(convertedBuffer), {
status: 200,
headers: {
"Content-Type": "video/mp4",
"Content-Disposition": `attachment; filename="${safeName}.mp4"`,
},
});
} catch (error) {
console.error("Video conversion error:", error);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the project logger instead of console.error.

Switch this to your structured logger to satisfy linting and keep observability consistent.

As per coding guidelines, "Don't use console".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/convert-video/route.ts` at line 66, Replace the raw
console.error call in the convert-video route (the console.error("Video
conversion error:", error) line) with the project's structured logger (e.g.,
logger.error or processLogger.error) and pass the same message plus the error
object as metadata; import the logger used across the project (or reuse the
existing processLogger) at the top of route.ts, remove the console usage, and
keep the message "Video conversion error" while ensuring the error is supplied
as the second argument to the logger call so structured fields are preserved.

return NextResponse.json(
{ error: GENERIC_CONVERSION_ERROR },
{ status: 500 },
);
} finally {
if (workDir) {
await rm(workDir, { recursive: true, force: true }).catch(() => {});
}
}
}

function runFfmpeg({
inputPath,
outputPath,
}: {
inputPath: string;
outputPath: string;
}): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn("ffmpeg", [
"-i",
inputPath,
"-c:v",
"libx264",
"-profile:v",
"baseline",
"-level",
"3.1",
"-preset",
"veryfast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-g",
"60",
"-keyint_min",
"60",
"-sc_threshold",
"0",
"-movflags",
"+faststart",
"-tag:v",
"avc1",
"-c:a",
"aac",
"-ac",
"2",
"-ar",
"48000",
"-b:a",
"128k",
"-y",
outputPath,
]);

let stderr = "";
proc.stderr.on("data", (data) => {
stderr += data.toString();
});

proc.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`ffmpeg exited with code ${code}. stderr: ${stderr.slice(-500)}`,
),
);
}
});

proc.on("error", (err) => {
reject(err);
});
});
Comment on lines +85 to +143
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

fd -t f "route.ts" --path "*convert-video*"

Repository: OpenCut-app/OpenCut

Length of output: 297


🏁 Script executed:

cat -n apps/web/src/app/api/convert-video/route.ts | head -160

Repository: OpenCut-app/OpenCut

Length of output: 4170


Enforce a timeout/abort path for ffmpeg execution.

If ffmpeg hangs, this request will stall indefinitely with no cleanup. Add a hard timeout that kills the process and rejects.

🔧 Proposed fix
 function runFfmpeg({
 	inputPath,
 	outputPath,
 }: {
 	inputPath: string;
 	outputPath: string;
 }): Promise<void> {
 	return new Promise((resolve, reject) => {
+		const FFMPEG_TIMEOUT_MS = 120_000;
 		const proc = spawn("ffmpeg", [
@@
 			outputPath,
 		]);
+		const timeout = setTimeout(() => {
+			proc.kill("SIGKILL");
+			reject(new Error(`ffmpeg timed out after ${FFMPEG_TIMEOUT_MS}ms`));
+		}, FFMPEG_TIMEOUT_MS);
 
 		let stderr = "";
 		proc.stderr.on("data", (data) => {
 			stderr += data.toString();
 		});
 
 		proc.on("close", (code) => {
+			clearTimeout(timeout);
 			if (code === 0) {
 				resolve();
 			} else {
@@
 		});
 
 		proc.on("error", (err) => {
+			clearTimeout(timeout);
 			reject(err);
 		});
 	});
 }
📝 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
return new Promise((resolve, reject) => {
const proc = spawn("ffmpeg", [
"-i",
inputPath,
"-c:v",
"libx264",
"-profile:v",
"baseline",
"-level",
"3.1",
"-preset",
"veryfast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-g",
"60",
"-keyint_min",
"60",
"-sc_threshold",
"0",
"-movflags",
"+faststart",
"-tag:v",
"avc1",
"-c:a",
"aac",
"-ac",
"2",
"-ar",
"48000",
"-b:a",
"128k",
"-y",
outputPath,
]);
let stderr = "";
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(
`ffmpeg exited with code ${code}. stderr: ${stderr.slice(-500)}`,
),
);
}
});
proc.on("error", (err) => {
reject(err);
});
});
return new Promise((resolve, reject) => {
const FFMPEG_TIMEOUT_MS = 120_000;
const proc = spawn("ffmpeg", [
"-i",
inputPath,
"-c:v",
"libx264",
"-profile:v",
"baseline",
"-level",
"3.1",
"-preset",
"veryfast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-g",
"60",
"-keyint_min",
"60",
"-sc_threshold",
"0",
"-movflags",
"+faststart",
"-tag:v",
"avc1",
"-c:a",
"aac",
"-ac",
"2",
"-ar",
"48000",
"-b:a",
"128k",
"-y",
outputPath,
]);
const timeout = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error(`ffmpeg timed out after ${FFMPEG_TIMEOUT_MS}ms`));
}, FFMPEG_TIMEOUT_MS);
let stderr = "";
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
resolve();
} else {
reject(
new Error(
`ffmpeg exited with code ${code}. stderr: ${stderr.slice(-500)}`,
),
);
}
});
proc.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/app/api/convert-video/route.ts` around lines 85 - 143, The
ffmpeg spawn Promise (the block that creates proc via spawn("ffmpeg", ...))
lacks a timeout/abort path so a hung ffmpeg will stall forever; implement a hard
timeout by starting a timer (e.g. setTimeout) after spawning proc that, when
fired, kills proc (proc.kill('SIGKILL') or similar) and rejects the Promise with
a descriptive Error, and ensure you clear that timer in both proc.on("close")
and proc.on("error") handlers to avoid leaks; keep using the existing stderr
capture and include the timeout case in the rejection message so callers can
distinguish timeouts from normal ffmpeg failures.

}
99 changes: 95 additions & 4 deletions apps/web/src/components/editor/export-button.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useState } from "react";
import { useMemo, useState } from "react";
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
Expand Down Expand Up @@ -34,6 +34,9 @@ import {
} from "@/components/section";
import { useEditor } from "@/editor/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
import { isWebCodecsExportSupported } from "@/services/renderer/media-recorder-support";
import { convertVideoToH264 } from "@/media/ffmpeg-convert";
import { toast } from "sonner";

function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
Expand Down Expand Up @@ -111,6 +114,20 @@ function ExportPopover({
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
);

// When the browser lacks WebCodecs encoders, the SceneExporter falls back to
// a MediaRecorder-based path that always produces WebM (VP8/VP9 + Opus).
// QuickTime and Safari can't play WebM natively, so we offer a one-click
// server-side transcode to H.264 MP4 when the user originally asked for MP4.
const webCodecsExportSupported = useMemo(
() => isWebCodecsExportSupported(),
[],
);
const willFallbackToWebM = !webCodecsExportSupported;
const shouldOfferServerTranscode = willFallbackToWebM && format === "mp4";
const [transcodeToMp4OnServer, setTranscodeToMp4OnServer] =
useState<boolean>(true);
const [isTranscoding, setIsTranscoding] = useState(false);

const handleExport = async () => {
if (!activeProject) return;

Expand All @@ -129,10 +146,39 @@ function ExportPopover({
}

if (result.success && result.buffer) {
let outBuffer = result.buffer;
let outFormat: ExportFormat = format;

if (shouldOfferServerTranscode) {
if (transcodeToMp4OnServer) {
setIsTranscoding(true);
try {
const webmFile = new File([outBuffer], "export.webm", {
type: "video/webm",
});
const mp4File = await convertVideoToH264({ file: webmFile });
outBuffer = await mp4File.arrayBuffer();
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown error";
toast.error("Server transcode failed", {
description: `${message}. Saving WebM instead.`,
});
outFormat = "webm";
} finally {
setIsTranscoding(false);
}
} else {
// User opted out of server transcode: keep WebM extension/mime so
// the file matches its actual container.
outFormat = "webm";
}
}
Comment on lines +149 to +176
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block re-entrant exports while server transcode is running.

handleExport can be triggered again during isTranscoding, which can start overlapping export/transcode jobs and cause duplicate uploads/downloads. Add an in-flight guard and lock the controls while transcoding.

Suggested fix
 const handleExport = async () => {
-  if (!activeProject) return;
+  if (!activeProject || isExporting || isTranscoding) return;

   const result = await editor.project.export({
@@
-  {!isExporting && (
+  {!isExporting && !isTranscoding && (
@@
-    <Button onClick={handleExport} className="w-full gap-2">
+    <Button onClick={handleExport} className="w-full gap-2" disabled={isTranscoding}>
       <Download className="size-4" />
       Export
     </Button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/editor/export-button.tsx` around lines 149 - 176, The
export flow can be re-entered while a server transcode is active; add an
in-flight guard to prevent overlapping exports by checking a dedicated boolean
(e.g., isTranscoding or new isExporting) at the start of handleExport and
returning early if set, and set that flag (use setIsTranscoding or
setIsExporting) before starting the transcode/upload and clear it in finally;
also disable export UI controls while the flag is true so the user cannot
trigger another export mid-transcode—update references to
shouldOfferServerTranscode and transcodeToMp4OnServer logic to respect this
guard.


downloadBuffer({
buffer: result.buffer,
filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
mimeType: getExportMimeType({ format }),
buffer: outBuffer,
filename: `${activeProject.metadata.name}${getExportFileExtension({ format: outFormat })}`,
mimeType: getExportMimeType({ format: outFormat }),
});

editor.project.clearExportState();
Expand Down Expand Up @@ -250,6 +296,39 @@ function ExportPopover({
</div>
</SectionContent>
</Section>

{shouldOfferServerTranscode && (
<Section collapsible defaultOpen>
<SectionHeader>
<SectionTitle>Browser compatibility</SectionTitle>
</SectionHeader>
<SectionContent>
<p className="mb-2 text-xs text-amber-600 dark:text-amber-400">
This browser can&apos;t encode H.264 directly, so the
export will produce a WebM file. WebM plays in Chrome,
Firefox and VLC, but not natively in QuickTime or
Safari.
</p>
<div className="flex items-start space-x-2">
<Checkbox
id="transcode-mp4"
className="mt-0.5"
checked={transcodeToMp4OnServer}
onCheckedChange={(checked) =>
setTranscodeToMp4OnServer(!!checked)
}
/>
<Label
htmlFor="transcode-mp4"
className="text-xs leading-snug"
>
Transcode to H.264 MP4 on the server after export
(uploads the WebM and re-encodes via ffmpeg)
</Label>
</div>
</SectionContent>
</Section>
)}
</div>

<div className="p-3 pt-0">
Expand Down Expand Up @@ -282,6 +361,18 @@ function ExportPopover({
</Button>
</div>
)}

{isTranscoding && !isExporting && (
<div className="space-y-2 p-3">
<p className="text-muted-foreground text-sm">
Transcoding to MP4 on server…
</p>
<p className="text-muted-foreground text-xs">
Uploading the WebM file and re-encoding via ffmpeg. This
runs once per export and is independent of preview playback.
</p>
</div>
)}
</div>
</>
)}
Expand Down
Loading