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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user