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 { createRouter } from '@@/create-router';
|
||||||
import { resolve, dirname, join, parse as parsePath } from 'node:path';
|
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 { existsSync } from 'node:fs';
|
||||||
import { getHomeDir, DATA_PATH } from '@@/data-path';
|
import { getHomeDir, DATA_PATH } from '@@/data-path';
|
||||||
import * as errors from '@@/custom-errors';
|
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
|
// 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) => {
|
router.post('/save-result', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { Loader2, FolderArchive } from 'lucide-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 { useFileViewer } from './FileViewerContext';
|
||||||
import { PdfRenderer } from './renderers/PdfRenderer';
|
import { PdfRenderer } from './renderers/PdfRenderer';
|
||||||
import { ImageRenderer } from './renderers/ImageRenderer';
|
import { ImageRenderer } from './renderers/ImageRenderer';
|
||||||
@@ -14,11 +14,11 @@ import { EditorRenderer } from './renderers/EditorRenderer';
|
|||||||
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
|
import { JsonEditorRenderer } from './renderers/JsonEditorRenderer';
|
||||||
|
|
||||||
export const FileViewerBody = () => {
|
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const videoSrc =
|
const videoSrc = fileType === 'video' ? getRawUrl(filePath, root) : '';
|
||||||
fileType === 'video' ? (needsTranscode(fileName) ? getTranscodeUrl(filePath, root) : getRawUrl(filePath, root)) : '';
|
|
||||||
|
|
||||||
const isJson = useMemo(() => {
|
const isJson = useMemo(() => {
|
||||||
if (!content) return false;
|
if (!content) return false;
|
||||||
@@ -52,11 +52,7 @@ export const FileViewerBody = () => {
|
|||||||
) : fileType === 'image' ? (
|
) : fileType === 'image' ? (
|
||||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||||
) : fileType === 'video' ? (
|
) : fileType === 'video' ? (
|
||||||
<VideoRenderer
|
<VideoRenderer src={videoSrc} fileName={fileName} />
|
||||||
src={videoSrc}
|
|
||||||
fileName={fileName}
|
|
||||||
fallbackSrc={needsTranscode(fileName) ? undefined : getTranscodeUrl(filePath, root)}
|
|
||||||
/>
|
|
||||||
) : fileType === 'audio' ? (
|
) : fileType === 'audio' ? (
|
||||||
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
||||||
) : content !== null && editing && isJson ? (
|
) : 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 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 AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'oga', 'opus', 'aac', 'm4a', 'wma'];
|
||||||
export const NATIVE_VIDEO_EXTS = ['mp4', 'm4v', 'webm', 'ogv', 'mov'];
|
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 = [
|
export const IMAGE_EXTS = [
|
||||||
'jpg',
|
'jpg',
|
||||||
'jpeg',
|
'jpeg',
|
||||||
@@ -107,9 +89,15 @@ export function getFileType(name: string): FileType {
|
|||||||
if (ext === 'pdf') return 'pdf';
|
if (ext === 'pdf') return 'pdf';
|
||||||
if (EXT_TO_LANG[ext]) return 'code';
|
if (EXT_TO_LANG[ext]) return 'code';
|
||||||
if (AUDIO_EXTS.includes(ext)) return 'audio';
|
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 (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 (ARCHIVE_EXTS.includes(ext)) return 'archive';
|
||||||
if (baseName === 'dockerfile' || baseName === 'makefile') return 'code';
|
if (baseName === 'dockerfile' || baseName === 'makefile') return 'code';
|
||||||
return 'text';
|
return 'text';
|
||||||
@@ -127,10 +115,6 @@ export function getExt(name: string): string {
|
|||||||
return name.split('.').pop()?.toLowerCase() ?? '';
|
return name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needsTranscode(name: string): boolean {
|
|
||||||
return TRANSCODE_VIDEO_EXTS.includes(getExt(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTime(s: number): string {
|
export function formatTime(s: number): string {
|
||||||
if (!isFinite(s) || isNaN(s)) return '0:00';
|
if (!isFinite(s) || isNaN(s)) return '0:00';
|
||||||
const h = Math.floor(s / 3600);
|
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}`;
|
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 {
|
export function getArchiveBaseName(name: string): string {
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
|
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import { useSeekBar, SeekBar } from './SeekBar';
|
|||||||
type VideoRendererProps = {
|
type VideoRendererProps = {
|
||||||
src: string;
|
src: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fallbackSrc?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps) => {
|
export const VideoRenderer = ({ src, fileName }: VideoRendererProps) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
@@ -27,7 +26,6 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
let blobUrl: string | null = null;
|
|
||||||
const onLoaded = () => {
|
const onLoaded = () => {
|
||||||
setDuration(v.duration);
|
setDuration(v.duration);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
@@ -36,34 +34,7 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
const onPlay = () => setPlaying(true);
|
const onPlay = () => setPlaying(true);
|
||||||
const onPause = () => setPlaying(false);
|
const onPause = () => setPlaying(false);
|
||||||
const onEnded = () => setPlaying(false);
|
const onEnded = () => setPlaying(false);
|
||||||
let fetching = false;
|
const onError = () => setError(true);
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
v.addEventListener('loadedmetadata', onLoaded);
|
v.addEventListener('loadedmetadata', onLoaded);
|
||||||
v.addEventListener('timeupdate', onTime);
|
v.addEventListener('timeupdate', onTime);
|
||||||
v.addEventListener('play', onPlay);
|
v.addEventListener('play', onPlay);
|
||||||
@@ -77,7 +48,6 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
v.removeEventListener('pause', onPause);
|
v.removeEventListener('pause', onPause);
|
||||||
v.removeEventListener('ended', onEnded);
|
v.removeEventListener('ended', onEnded);
|
||||||
v.removeEventListener('error', onError);
|
v.removeEventListener('error', onError);
|
||||||
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -147,7 +117,14 @@ export const VideoRenderer = ({ src, fileName, fallbackSrc }: VideoRendererProps
|
|||||||
if (playing) setShowControls(false);
|
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 && (
|
{loaded && !playing && (
|
||||||
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
|
<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>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<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" />}
|
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<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>
|
<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" />}
|
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user