This commit is contained in:
2026-02-23 11:16:33 +00:00
parent 2779fe22c6
commit 8fb96c7cf8
23 changed files with 575 additions and 102 deletions
+75 -27
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 } from 'node:fs/promises';
import { readdir, stat, mkdir, rm, rename, readFile, cp, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
@@ -221,8 +221,7 @@ router.get('/raw', async (ctx) => {
});
});
// Transcode video via ffmpeg for non-native browser formats (mkv, avi, wmv, etc.)
// Outputs fragmented MP4 streamed to the client
// 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);
@@ -233,37 +232,86 @@ router.get('/transcode', async (ctx) => {
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
const startTime = ctx.req.query('t') || '0';
const userDataDir = getUserDataDir(user.email);
const { dir, name } = parsePath(relPath);
const cacheRel = dir ? `video/${dir}/${name}.mp4` : `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',
'-ss',
startTime,
'-i',
absPath,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-crf',
'23',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'frag_mp4+empty_moov+default_base_moof',
'-f',
'mp4',
'pipe:1',
],
['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': 'video/mp4',
'Content-Type': 'audio/mpeg',
'Transfer-Encoding': 'chunked',
},
});
+40
View File
@@ -2,6 +2,7 @@ import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import * as storage from './storage';
import { readApiKeys, readLocalProviders } from '../server-settings/pi-mono';
import { readSttConfig } from '../server-settings/stt';
import { listPiModels } from './list-models';
import { getHomeDir } from '../../data-path';
import { resolveBaseCwd } from './websocket';
@@ -497,3 +498,42 @@ piRestRouter.post('/pi/sessions/:sessionId/move', async (ctx: Context) => {
return ctx.json({ error: 'Failed to move session' }, 500);
}
});
/**
* POST /api/pi/stt
* Proxy audio to configured Whisper server for speech-to-text transcription.
* Accepts multipart form data with audio file + whisper params.
*/
piRestRouter.post('/pi/stt', async (ctx: Context) => {
const sttConfig = await readSttConfig();
if (!sttConfig?.url) {
return ctx.json({ error: 'Whisper not configured — set it up in Settings → Speech to Text' }, 400);
}
const body = await ctx.req.parseBody();
const file = body['file'];
if (!file || !(file instanceof File)) {
return ctx.json({ error: 'file is required' }, 400);
}
const formData = new FormData();
formData.append('file', file, 'recording.wav');
formData.append('temperature', String(body['temperature'] ?? '0.0'));
formData.append('temperature_inc', String(body['temperature_inc'] ?? '0.2'));
formData.append('response_format', String(body['response_format'] ?? 'json'));
try {
const res = await fetch(`${sttConfig.url.replace(/\/+$/, '')}/inference`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
return ctx.json({ error: `Whisper returned ${res.status}` }, 502);
}
const json = await res.json();
return ctx.json(json);
} catch (err) {
logger.error('STT proxy failed', { error: String(err) });
return ctx.json({ error: 'Failed to reach Whisper server' }, 502);
}
});