Skip to content

Commit

Permalink
Broadcast stream instead of pipe, split arguments, add player statist…
Browse files Browse the repository at this point in the history
…ics to application settings
  • Loading branch information
Lillifee committed Feb 14, 2023
1 parent 297c2b1 commit 8c9cebf
Show file tree
Hide file tree
Showing 16 changed files with 201 additions and 46 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ Open the browser and navigate to: `http://__ip_address__:8000`
## Command line options:

_-p_ or _--port 80_ - server port (default 8000)
_-c_ or _--CORs true_ - allow CORs during development (default true)

# Run RaspiCam as a service

Expand Down
61 changes: 59 additions & 2 deletions serve.site.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,63 @@
import * as esbuild from 'esbuild';
import { esbuildSiteConfig } from './build.site.mjs';

await esbuild.context(esbuildSiteConfig).then((x) => x.watch());
import http from 'node:http';

console.info('- watch site');

const raspi = { hostname: '192.168.3.139', port: '8000' };

// Start esbuild's server on a random local port
const ctx = await esbuild.context({ ...esbuildSiteConfig, minify: false });

// The return value tells us where esbuild's local server is
const { host, port } = await ctx.serve({ servedir: './build/public/', port: 8001 });

// Then start a proxy server on port 3000
http
.createServer((req, res) => {
// Forward requests to Raspberry PI
const apiReq = http.request(
{
hostname: raspi.hostname,
port: raspi.port,
path: req.url,
method: req.method,
headers: req.headers,
},
(resp) => {
resp.pipe(res, { end: true });
},
);

if (req.url?.startsWith('/api')) {
req.pipe(apiReq, { end: true });
return;
}

// Forward to esbuild serve
const proxyReq = http.request(
{
hostname: host,
port: port,
path: req.url,
method: req.method,
headers: req.headers,
},
(proxyRes) => {
// If esbuild returns "not found", send a custom 404 page
if (proxyRes.statusCode === 404) {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>A custom 404 page</h1>');
return;
}

// Otherwise, forward the response from esbuild to the client
res.writeHead(proxyRes?.statusCode || 0, proxyRes.headers);
proxyRes.pipe(res, { end: true });
},
);

// Forward the body of the request to esbuild
req.pipe(proxyReq, { end: true });
})
.listen(8000);
11 changes: 11 additions & 0 deletions src/server/argument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import yargs from 'yargs/yargs';

export const parseArguments = () =>
yargs(process.argv.slice(2))
.options({
p: { type: 'number', alias: 'port', default: 8000 },
c: { type: 'boolean', alias: 'cors', default: false },
})
.parseSync();

export type Arguments = ReturnType<typeof parseArguments>;
20 changes: 17 additions & 3 deletions src/server/control.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'path';
import internal from 'stream';
import { PassThrough } from 'stream';
import { getIsoDataTime } from '../shared/helperFunctions';
import { RaspiMode, RaspiStatus } from '../shared/settings/types';
import { createLogger } from './logger';
Expand All @@ -14,19 +14,25 @@ export interface RaspiControl {
stop: () => void;
restartStream: () => Promise<void>;
getStatus: () => RaspiStatus;
getStream: () => internal.Readable | null | undefined;
getStream: () => PassThrough;
}

/**
* RaspiControl
*/
export const createRaspiControl = (settingsHelper: SettingsHelper): RaspiControl => {
let streams: PassThrough[] = [];

const actionProcess = spawnProcess();
const streamProcess = spawnProcess({
stdioOptions: ['ignore', 'pipe', 'inherit'],
resolveOnData: true,
});

streamProcess.stream.on('data', (chunk: unknown) =>
streams.forEach((stream) => stream.write(chunk)),
);

const startStream = async () => {
actionProcess.stop();
streamProcess.stop();
Expand All @@ -39,7 +45,15 @@ export const createRaspiControl = (settingsHelper: SettingsHelper): RaspiControl
});
};

const getStream = () => streamProcess.output();
const getStream = () => {
const stream = new PassThrough();
stream.once('close', () => {
streams = streams.filter((x) => x != stream);
});

streams = [...streams, stream];
return stream;
};

const restartStream = async () => {
if (streamProcess.running()) {
Expand Down
15 changes: 7 additions & 8 deletions src/server/main.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import http from 'http';
import { parseArguments } from './argument';
import { createButtonControl } from './button';
import { createRaspiControl } from './control';
import { createLogger } from './logger';
import { server } from './server';
import { createSettingsHelper } from './settings';
import { createFileWatcher } from './watcher';
import yargs from 'yargs/yargs';

const logger = createLogger('server');

const args = yargs(process.argv.slice(2))
.options({
p: { type: 'number', alias: 'port', default: 8000 },
})
.parseSync();

const start = async () => {
logger.info('starting services...');

/**
* Parse the startup arguments
*/
const args = parseArguments();

/**
* https server
* Create an http server to bind the express server.
Expand Down Expand Up @@ -50,7 +49,7 @@ const start = async () => {
* Webserver
* Start the webserver and serve the website.
*/
const app = server(control, settingsHelper, watcher, button);
const app = server(args, control, settingsHelper, watcher, button);
httpServer.on('request', app);

/**
Expand Down
11 changes: 6 additions & 5 deletions src/server/process.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ChildProcess, StdioOptions, spawn } from 'child_process';
import { Readable } from 'stream';
import { PassThrough } from 'stream';
import { createLogger } from './logger';

const logger = createLogger('process');
Expand All @@ -25,7 +25,7 @@ export interface SpawnProcess {
start: (command: string, args: Record<string, unknown>) => Promise<void>;
stop: () => void;
running: () => boolean;
output: () => Readable | null | undefined;
stream: PassThrough;
}

/**
Expand All @@ -36,18 +36,17 @@ export const spawnProcess = (options?: {
resolveOnData?: boolean;
}): SpawnProcess => {
let process: ChildProcess | undefined;
const stream = new PassThrough();

const stop = () => {
if (process) {
process.stdout?.pause();
process.unref();
process.kill();
process = undefined;
}
};

const running = () => !!process;
const output = () => process?.stdout;

const start = (command: string, args: Record<string, unknown>): Promise<void> =>
new Promise<void>((resolve, reject) => {
Expand All @@ -60,7 +59,9 @@ export const spawnProcess = (options?: {
}
process.on('error', (e) => reject(e));
process.on('exit', () => resolve());

process.stdout?.pipe(stream, { end: false });
});

return { start, stop, running, output };
return { start, stop, running, stream };
};
36 changes: 27 additions & 9 deletions src/server/server.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,47 @@
import express from 'express';
import path from 'path';
import { pipeline } from 'stream';
import { isDefined } from '../shared/helperFunctions';
import { RaspiGallery, RaspiStatus, GenericSettingDesc, Setting } from '../shared/settings/types';
import { Arguments } from './argument';
import { ButtonControl } from './button';
import { curDirName } from './common';
import { RaspiControl } from './control';
import { createLogger } from './logger';
import { SettingsBase, SettingsHelper } from './settings';
import { splitJpeg } from './splitJpeg';
import { FileWatcher } from './watcher';

const logger = createLogger('server');

type SettingRequest = express.Request<undefined, undefined, Setting<GenericSettingDesc>>;

/**
* Initialize the express server
*/
export const server = (
args: Arguments,
control: RaspiControl,
settingsHelper: SettingsHelper,
fileWatcher: FileWatcher,
buttonControl: ButtonControl,
): express.Express => {
const app = express();

if (args.c) {
// Run server insecure and allow CORs
app.use((_, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader(
'Access-Control-Allow-Headers',
'Access-Control-Allow-Headers, Origin,Accept, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers',
);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,OPTIONS,HEAD,DELETE');
res.setHeader('Access-Control-Allow-Credentials', 'true');
next();
});
}

// Serve the static content from public
app.use(express.static(path.join(curDirName, 'public')));
app.use('/photos', express.static(fileWatcher.getPath()));
Expand Down Expand Up @@ -100,17 +120,15 @@ export const server = (
app.get('/api/stream/live', (_, res) => {
const liveStream = control.getStream();

if (liveStream) {
res.writeHead(200, { 'Content-Type': 'video/mp4' });
res.setHeader('Content-Type', 'video/mp4');
res.writeHead(200);

res.on('close', () => {
res.destroy();
});
pipeline(liveStream, res, (err) => {
if (!err) return;
if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;

liveStream.pipe(res);
} else {
res.status(503).send('Camera restarting or in use');
}
logger.error('live stream pipeline error', err);
});
});

app.get('/api/stream/mjpeg', (_, res) => {
Expand Down
5 changes: 4 additions & 1 deletion src/shared/settings/application.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { enumSetting } from './helper';
import { booleanSetting, enumSetting } from './helper';
import { GridLineType, Setting } from './types';

/**
Expand All @@ -17,6 +17,9 @@ export const applicationSettingDesc = {

/** Player for H264 stream */
player: enumSetting('Player', ['Broadway', 'JMuxer'], 'JMuxer'),

/** Display the player statistics */
playerStats: booleanSetting('Stream statistics', false),
};

export type ApplicationSettingDesc = typeof applicationSettingDesc;
Expand Down
2 changes: 1 addition & 1 deletion src/shared/settings/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const streamSettingDesc = {
* Maximum bitrate is 25Mbits/s (-b 25000000), but much over 17Mbits/s
* won't show noticeable improvement at 1080p30.
*/
bitrate: numberSetting('Bitrate', 0, 25000000, 10000000, 1000000, abbreviateNumber('bits/s', 0)),
bitrate: numberSetting('Bitrate', 0, 25000000, 6000000, 1000000, abbreviateNumber('bits/s', 0)),

/**
* Intra-frame period (H.264 only)
Expand Down
8 changes: 5 additions & 3 deletions src/site/components/main/Camera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,18 +141,20 @@ export const Camera: React.FC<Props> = ({ setTheme }) => {
const activateSetting = (setting: ActiveSetting) =>
setActiveSetting((currentSetting) => (currentSetting === setting ? undefined : setting));

const playerLoading = loading || !!status.data.running;

return (
<MainContainer ref={mainRef}>
<PlayerWrapper>
<PlayerContainer>
<ErrorBoundary errorHeader="You can try to change the stream codec or the selected player in the settings and retry.">
{stream.codec.value === 'MJPEG' ? (
<MJPEGPlayer loading={loading || !!status.data.running} />
<MJPEGPlayer loading={playerLoading} />
) : stream.codec.value === 'H264' ? (
application.player.value === 'Broadway' ? (
<BroadwayPlayer loading={loading} />
<BroadwayPlayer loading={playerLoading} showStats={application.playerStats.value} />
) : (
<JMuxerPlayer loading={loading} />
<JMuxerPlayer loading={playerLoading} showStats={application.playerStats.value} />
)
) : null}
</ErrorBoundary>
Expand Down
2 changes: 2 additions & 0 deletions src/site/components/main/settings/ApplicationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ApplicationSetting,
ApplicationSettingDesc,
} from '../../../../shared/settings/application.js';
import { BooleanSetting } from './common/BooleanSetting.js';
import { EnumDropdownSetting } from './common/EnumDropdownSetting.js';
import { updateTypedField } from './common/helperFunctions.js';
import { SettingsExpander, SettingsExpanderHeader } from './common/SettingsExpander.js';
Expand All @@ -28,6 +29,7 @@ export const ApplicationSettings: React.FC<ApplicationSettingsProps> = ({
<SettingsExpander header={<SettingsExpanderHeader>General</SettingsExpanderHeader>}>
<EnumDropdownSetting {...application.theme} update={updateField('theme')} />
<EnumDropdownSetting {...application.gridLines} update={updateField('gridLines')} />
<BooleanSetting {...application.playerStats} update={updateField('playerStats')} />
</SettingsExpander>
</SettingsWrapper>
);
Expand Down
6 changes: 5 additions & 1 deletion src/site/components/stream/BroadwayPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import styled from 'styled-components';
import { BlurOverlay } from './Common.js';
import { createPlayerOptions, player, Player, PlayerOptions } from './player.js';
import { PlayerStatistics } from './PlayerStatistics.js';
import { createInitialPlayerStats, PlayerStats } from './stats.js';
import { streamBroadway } from './streamBroadway.js';

Expand Down Expand Up @@ -68,12 +69,13 @@ const usePlayer = (url: string, container: React.RefObject<HTMLElement>) => {

export interface PlayerProps {
loading: boolean;
showStats?: boolean;
}

/**
* Player to display the live stream
*/
export const BroadwayPlayer: React.FC<PlayerProps> = ({ loading }) => {
export const BroadwayPlayer: React.FC<PlayerProps> = ({ loading, showStats }) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [stats] = usePlayer('/api/stream/live', containerRef);

Expand All @@ -84,6 +86,8 @@ export const BroadwayPlayer: React.FC<PlayerProps> = ({ loading }) => {
<BlurOverlay
$blur={loading || !stats.streamRunning || !stats.playerRunning || stats.droppingFrames}
/>

{showStats && <PlayerStatistics loading={loading} stats={stats} />}
</Container>
);
};
Loading

0 comments on commit 8c9cebf

Please sign in to comment.