mobile
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 } 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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user