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, "_")

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

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
-		.replace(/[\\"\r\n\x00-\x1f]/g, "_")
+		.replace(/[\p{Cc}"\\\r\n]/gu, "_")
As per coding guidelines, "Don't use control characters and escape sequences that match control characters in regular expression literals".
📝 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
.replace(/[\\"\r\n\x00-\x1f]/g, "_")
.replace(/[\p{Cc}"\\\r\n]/gu, "_")
🧰 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
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 19, The regex in the
.replace call inside the convert-video route uses explicit control-character
escapes and fails Biome lint; update the .replace call (the method invocation
using .replace(/[\\"\r\n\x00-\x1f]/g, "_")) to use a Unicode property class for
control characters (Cc) with the u flag and include the double-quote and
backslash literals so behavior is preserved; ensure the pattern uses the global
and unicode flags (g and u) and still replaces control characters, backslashes
and quotes with the same replacement string.

.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();
Comment thread
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

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

Bound stderr buffering to avoid unbounded memory growth.

stderr += data.toString() grows without limit; noisy/long-running ffmpeg failures can inflate memory unnecessarily. Keep only the tail you need for diagnostics.

Suggested patch
 		let stderr = "";
 		proc.stderr.on("data", (data) => {
-			stderr += data.toString();
+			stderr = (stderr + data.toString()).slice(-4000);
 		});
📝 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
let stderr = "";
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
let stderr = "";
proc.stderr.on("data", (data) => {
stderr = (stderr + data.toString()).slice(-4000);
});
🤖 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 123 - 126, The
current stderr accumulation in the proc.stderr.on("data") handler (variable
stderr) can grow unbounded; change the handler to append incoming chunks but
truncate to a fixed max tail (e.g., 8KB or configurable MAX_STDERR_BYTES) so
only the most recent bytes are kept for diagnostics: in the
proc.stderr.on("data", ...) callback append data.toString() (or buffer) and if
stderr.length > MAX_STDERR_BYTES slice off the oldest bytes (keep the rightmost
tail), ensuring you preserve complete UTF‑8 characters if using strings or
operate on Buffers to avoid splitting multi-byte characters; replace existing
stderr growth logic with this size-limited tailing behavior.


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);
});
});
}
95 changes: 90 additions & 5 deletions apps/web/src/core/managers/audio-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
Input,
type WrappedAudioBuffer,
} from "mediabunny";
import {
type AudioBufferSinkLike,
FallbackAudioBufferSink,
} from "@/services/audio-cache/fallback-audio-buffer-sink";

export class AudioManager {
private audioContext: AudioContext | null = null;
Expand All @@ -30,7 +34,7 @@ export class AudioManager {
private lookaheadSeconds = 2;
private scheduleIntervalMs = 500;
private clips: AudioClipSource[] = [];
private sinks = new Map<string, AudioBufferSink>();
private sinks = new Map<string, AudioBufferSinkLike>();
private inputs = new Map<string, Input>();
private activeClipIds = new Set<string>();
private clipIterators = new Map<
Expand Down Expand Up @@ -267,7 +271,42 @@ export class AudioManager {

const iterator = sink.buffers(sourceStartTime);
this.clipIterators.set(clip.id, iterator);
let consecutiveDroppedBufferCount = 0;

try {
await this.consumeClipIterator({
clip,
clipEnd,
iterator,
audioContext,
sessionId,
initialDroppedCount: 0,
});
} catch (error) {
console.warn(
`[AudioManager] clip iterator failed for ${clip.id}; aborting playback for this clip.`,
error,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.clipIterators.delete(clip.id);
this.activeClipIds.delete(clip.id);
}
}

private async consumeClipIterator({
clip,
clipEnd,
iterator,
audioContext,
sessionId,
initialDroppedCount,
}: {
clip: AudioClipSource;
clipEnd: number;
iterator: AsyncGenerator<WrappedAudioBuffer, void, unknown>;
audioContext: AudioContext;
sessionId: number;
initialDroppedCount: number;
}): Promise<void> {
let consecutiveDroppedBufferCount = initialDroppedCount;

for await (const { buffer, timestamp } of iterator) {
if (!this.editor.playback.getIsPlaying()) return;
Expand Down Expand Up @@ -605,7 +644,18 @@ export class AudioManager {
return null;
}

const sink = new AudioBufferSink(audioTrack);
const canDecode = await safeCanDecode({ audioTrack });
const sink: AudioBufferSinkLike = canDecode
? new AudioBufferSink(audioTrack)
: await FallbackAudioBufferSink.create({
file: clip.file,
audioContext,
});
if (!canDecode) {
console.warn(
"[AudioManager] WebCodecs unavailable; decoding clip audio via decodeAudioData.",
);
}
const chunks: AudioBuffer[] = [];
let totalSamples = 0;

Expand Down Expand Up @@ -673,12 +723,13 @@ export class AudioManager {
clip,
}: {
clip: AudioClipSource;
}): Promise<AudioBufferSink | null> {
}): Promise<AudioBufferSinkLike | null> {
const existingSink = this.sinks.get(clip.sourceKey);
if (existingSink) return existingSink;

let input: Input | null = null;
try {
const input = new Input({
input = new Input({
source: new BlobSource(clip.file),
formats: ALL_FORMATS,
});
Expand All @@ -688,13 +739,47 @@ export class AudioManager {
return null;
}

const canDecode = await safeCanDecode({ audioTrack });
if (!canDecode) {
input.dispose();
input = null;
const audioContext = this.ensureAudioContext();
if (!audioContext) {
return null;
}
const fallback = await FallbackAudioBufferSink.create({
file: clip.file,
audioContext,
});
this.sinks.set(clip.sourceKey, fallback);
console.warn(
"[AudioManager] WebCodecs unavailable; using decodeAudioData fallback for audio clip.",
);
return fallback;
}

const sink = new AudioBufferSink(audioTrack);
this.inputs.set(clip.sourceKey, input);
this.sinks.set(clip.sourceKey, sink);
return sink;
} catch (error) {
if (input) {
input.dispose();
}
console.warn("Failed to initialize audio sink:", error);
return null;
}
}
}

async function safeCanDecode({
audioTrack,
}: {
audioTrack: { canDecode: () => Promise<boolean> };
}): Promise<boolean> {
try {
return await audioTrack.canDecode();
} catch {
return false;
}
}
22 changes: 21 additions & 1 deletion apps/web/src/media/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { canTrackHaveAudio } from "@/timeline";
import { mediaSupportsAudio } from "@/media/media-utils";
import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/retime";
import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny";
import {
type AudioBufferSinkLike,
FallbackAudioBufferSink,
} from "@/services/audio-cache/fallback-audio-buffer-sink";
import { TICKS_PER_SECOND } from "@/wasm";
import {
computeRmsBuckets,
Expand Down Expand Up @@ -271,7 +275,23 @@ async function resolveAudioBufferForAsset({
const audioTrack = await input.getPrimaryAudioTrack();
if (!audioTrack) return null;

const sink = new AudioBufferSink(audioTrack);
let audioTrackCanDecode = false;
try {
audioTrackCanDecode = await audioTrack.canDecode();
} catch {
audioTrackCanDecode = false;
}
const sink: AudioBufferSinkLike = audioTrackCanDecode
? new AudioBufferSink(audioTrack)
: await FallbackAudioBufferSink.create({
file: asset.file,
audioContext,
});
if (!audioTrackCanDecode) {
console.warn(
"[audio] WebCodecs unavailable; decoding asset via decodeAudioData fallback.",
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
const targetSampleRate = audioContext.sampleRate;

const chunks: AudioBuffer[] = [];
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/media/ffmpeg-convert.ts
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" });
}
Loading