-
Notifications
You must be signed in to change notification settings - Fork 6.3k
feat(media): support browsers without WebCodecs via FFmpeg + HTML5 fallbacks #794
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||
| 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); | ||||||||||||||||||
|
|
||||||||||||||||||
| 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); | ||||||||||||||||||
| 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(); | ||||||||||||||||||
| }); | ||||||||||||||||||
|
Comment on lines
+123
to
+126
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bound
Suggested patch let stderr = "";
proc.stderr.on("data", (data) => {
- stderr += data.toString();
+ stderr = (stderr + data.toString()).slice(-4000);
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
|
|
||||||||||||||||||
| 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); | ||||||||||||||||||
| }); | ||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| export async function convertVideoToH264({ | ||
| file, | ||
| }: { | ||
| file: File; | ||
| }): Promise<File> { | ||
| const formData = new FormData(); | ||
| formData.append("file", file); | ||
|
|
||
| const response = await fetch("/api/convert-video", { | ||
| method: "POST", | ||
| body: formData, | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| const errorData = await response.json().catch(() => ({})); | ||
| throw new Error(errorData.error || `Conversion failed: ${response.status}`); | ||
| } | ||
|
|
||
| const blob = await response.blob(); | ||
| const baseName = file.name.replace(/\.[^.]+$/, ""); | ||
| return new File([blob], `${baseName}.mp4`, { type: "video/mp4" }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regex currently violates the control-character rule (Biome lint error).
Use a Unicode property class for control chars to satisfy lint while preserving behavior.
Suggested patch
📝 Committable suggestion
🧰 Tools
🪛 Biome (2.4.15)
[error] 19-19: Unexpected control character in a regular expression.
(lint/suspicious/noControlCharactersInRegex)
[error] 19-19: Unexpected control character in a regular expression.
(lint/suspicious/noControlCharactersInRegex)
🤖 Prompt for AI Agents