remove ffmpeg video transcode, back to native-only playback

Drop the /transcode (and dead /transcode-audio) routes and the frontend
mkv/avi transcode wiring. Only browser-native formats (mp4, webm, mov,
m4v, ogv) are classified as video now; other containers fall through to
the generic file view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 23:37:14 +00:00
co-authored by Claude Opus 4.8
parent 510254cbd4
commit 82d0e9afd6
4 changed files with 33 additions and 192 deletions
+2 -110
View File
@@ -1,6 +1,6 @@
import { createRouter } from '@@/create-router';
import { resolve, dirname, join, parse as parsePath } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { getHomeDir, DATA_PATH } from '@@/data-path';
import * as errors from '@@/custom-errors';
@@ -261,116 +261,8 @@ router.get('/raw', async (ctx) => {
});
});
// Transcode video via ffmpeg with caching — outputs a seekable MP4 file
router.get('/transcode', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(relPath);
const cacheRel = dir ? `cache/video/${dir}/${name}.mp4` : `cache/video/${name}.mp4`;
const cacheAbs = resolve(userDataDir, cacheRel);
if (!existsSync(cacheAbs)) {
await mkdir(dirname(cacheAbs), { recursive: true });
const tmpPath = cacheAbs + '.tmp';
const proc = Bun.spawn(
[
'ffmpeg',
'-i',
absPath,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-crf',
'23',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'+faststart',
'-y',
tmpPath,
],
{ stdout: 'ignore', stderr: 'pipe' },
);
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
await unlink(tmpPath).catch(() => {});
throw errors.BAD_REQUEST(stderr.trim() || 'Video transcoding failed');
}
await rename(tmpPath, cacheAbs);
}
const file = Bun.file(cacheAbs);
const total = file.size;
const rangeHeader = ctx.req.header('range');
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
const start = match[1] ? parseInt(match[1], 10) : 0;
const end = match[2] ? parseInt(match[2], 10) : total - 1;
const chunkSize = end - start + 1;
return new Response(file.slice(start, end + 1), {
status: 206,
headers: {
'Content-Type': 'video/mp4',
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(chunkSize),
'Accept-Ranges': 'bytes',
},
});
}
}
return new Response(file, {
headers: {
'Content-Type': 'video/mp4',
'Content-Length': String(total),
'Accept-Ranges': 'bytes',
},
});
});
// Transcode audio via ffmpeg for universal playback (outputs MP3)
router.get('/transcode-audio', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-c:a', 'libmp3lame', '-q:a', '2', '-f', 'mp3', 'pipe:1'], {
stdout: 'pipe',
stderr: 'ignore',
});
return new Response(proc.stdout as ReadableStream, {
headers: {
'Content-Type': 'audio/mpeg',
'Transfer-Encoding': 'chunked',
},
});
});
// Save a cached result (ocr/tts/transcriptions/audio) next to the original file
const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/', 'cache/video/'];
const CACHE_PREFIXES = ['cache/ocr/', 'cache/tts/', 'cache/transcriptions/', 'cache/audio/'];
router.post('/save-result', async (ctx) => {
const user = ctx.get('user');
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { Loader2, FolderArchive } from 'lucide-react';
import { getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
import { getLang, getRawUrl, getArchiveBaseName } from './file-types';
import { useFileViewer } from './FileViewerContext';
import { PdfRenderer } from './renderers/PdfRenderer';
import { ImageRenderer } from './renderers/ImageRenderer';
@@ -14,11 +14,11 @@ import { EditorRenderer } from './renderers/EditorRenderer';
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
export const FileViewerBody = () => {
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent } = useFileViewer();
const { filePath, fileName, root, fileType, content, loading, error, autoPlay, editing, setContent } =
useFileViewer();
const scrollRef = useRef<HTMLDivElement>(null);
const videoSrc =
fileType === 'video' ? (needsTranscode(fileName) ? getTranscodeUrl(filePath, root) : getRawUrl(filePath, root)) : '';
const videoSrc = fileType === 'video' ? getRawUrl(filePath, root) : '';
const isJson = useMemo(() => {
if (!content) return false;
@@ -52,11 +52,7 @@ export const FileViewerBody = () => {
) : fileType === 'image' ? (
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
) : fileType === 'video' ? (
<VideoRenderer
src={videoSrc}
fileName={fileName}
fallbackSrc={needsTranscode(fileName) ? undefined : getTranscodeUrl(filePath, root)}
/>
<VideoRenderer src={videoSrc} fileName={fileName} />
) : fileType === 'audio' ? (
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
) : content !== null && editing && isJson ? (
@@ -7,24 +7,6 @@ export type FileType = 'markdown' | 'audio' | 'video' | 'image' | 'pdf' | 'code'
export const ARCHIVE_EXTS = ['zip', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst', '7z', 'rar'];
export const AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'oga', 'opus', 'aac', 'm4a', 'wma'];
export const NATIVE_VIDEO_EXTS = ['mp4', 'm4v', 'webm', 'ogv', 'mov'];
export const TRANSCODE_VIDEO_EXTS = [
'mkv',
'avi',
'wmv',
'flv',
'ts',
'mts',
'm2ts',
'3gp',
'3g2',
'vob',
'divx',
'asf',
'f4v',
'rm',
'rmvb',
];
export const ALL_VIDEO_EXTS = [...NATIVE_VIDEO_EXTS, ...TRANSCODE_VIDEO_EXTS];
export const IMAGE_EXTS = [
'jpg',
'jpeg',
@@ -107,9 +89,15 @@ export function getFileType(name: string): FileType {
if (ext === 'pdf') return 'pdf';
if (EXT_TO_LANG[ext]) return 'code';
if (AUDIO_EXTS.includes(ext)) return 'audio';
if (ALL_VIDEO_EXTS.includes(ext)) return 'video';
if (NATIVE_VIDEO_EXTS.includes(ext)) return 'video';
if (IMAGE_EXTS.includes(ext)) return 'image';
if (baseName.endsWith('.tar.gz') || baseName.endsWith('.tar.bz2') || baseName.endsWith('.tar.xz') || baseName.endsWith('.tar.zst')) return 'archive';
if (
baseName.endsWith('.tar.gz') ||
baseName.endsWith('.tar.bz2') ||
baseName.endsWith('.tar.xz') ||
baseName.endsWith('.tar.zst')
)
return 'archive';
if (ARCHIVE_EXTS.includes(ext)) return 'archive';
if (baseName === 'dockerfile' || baseName === 'makefile') return 'code';
return 'text';
@@ -127,10 +115,6 @@ export function getExt(name: string): string {
return name.split('.').pop()?.toLowerCase() ?? '';
}
export function needsTranscode(name: string): boolean {
return TRANSCODE_VIDEO_EXTS.includes(getExt(name));
}
export function formatTime(s: number): string {
if (!isFinite(s) || isNaN(s)) return '0:00';
const h = Math.floor(s / 3600);
@@ -147,20 +131,6 @@ export function getRawUrl(filePath: string, root?: string): string {
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getTranscodeUrl(filePath: string, root?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/transcode?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getTranscodeAudioUrl(filePath: string, root?: string): string {
const headers = getHeaders();
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
return `${API_URL}/file-browser/transcode-audio?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`;
}
export function getArchiveBaseName(name: string): string {
const lower = name.toLowerCase();
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
@@ -6,10 +6,9 @@ import { useSeekBar, SeekBar } from './SeekBar';
type VideoRendererProps = {
src: string;
fileName: string;
fallbackSrc?: string;
};
export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps) => {
export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
@@ -27,7 +26,6 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
useEffect(() => {
const v = videoRef.current;
if (!v) return;
let blobUrl: string | null = null;
const onLoaded = () => {
setDuration(v.duration);
setLoaded(true);
@@ -36,34 +34,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
const onPlay = () => setPlaying(true);
const onPause = () => setPlaying(false);
const onEnded = () => setPlaying(false);
let fetching = false;
let fetchDone = false;
const onError = () => {
if (fetching) return;
if (fetchDone) {
setError(true);
return;
}
fetching = true;
const fetchUrl = fallbackSrc || src;
fetch(fetchUrl)
.then((res) => {
if (!res.ok) throw new Error();
return res.blob();
})
.then((blob) => {
fetching = false;
fetchDone = true;
blobUrl = URL.createObjectURL(blob);
v.src = blobUrl;
v.load();
})
.catch(() => {
fetching = false;
fetchDone = true;
setError(true);
});
};
const onError = () => setError(true);
v.addEventListener('loadedmetadata', onLoaded);
v.addEventListener('timeupdate', onTime);
v.addEventListener('play', onPlay);
@@ -77,7 +48,6 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
v.removeEventListener('pause', onPause);
v.removeEventListener('ended', onEnded);
v.removeEventListener('error', onError);
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, []);
@@ -147,7 +117,14 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
if (playing) setShowControls(false);
}}
>
<video ref={videoRef} src={src} preload="metadata" playsInline className="max-w-full max-h-full" onClick={togglePlay} />
<video
ref={videoRef}
src={src}
preload="metadata"
playsInline
className="max-w-full max-h-full"
onClick={togglePlay}
/>
{loaded && !playing && (
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
@@ -183,7 +160,10 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
</button>
<div className="flex items-center gap-1.5">
<button onClick={toggleMute} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
<button
onClick={toggleMute}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
<input
@@ -205,7 +185,10 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
<span className="text-[9px] font-mono text-white/30 uppercase tracking-wider">{getExt(fileName)}</span>
<button onClick={toggleFullscreen} className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer">
<button
onClick={toggleFullscreen}
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>