generated from ztimson/template
1081 lines
33 KiB
JavaScript
1081 lines
33 KiB
JavaScript
|
|
// Camera service (rpicam-apps wrapper)
|
|
//
|
|
// libcamera lets only ONE process own the camera, so everything here goes through a single
|
|
// state machine:
|
|
//
|
|
// ┌──────────── clients connect ────────────┐
|
|
// ▼ │
|
|
// idle ◀──────▶ streaming ──photo()/video()/timelapse()──▶ photo | video | timelapse
|
|
// ▲ last client leaves │
|
|
// └────────── job finished (stream auto-resumes if anyone is watching) ◀──┘
|
|
//
|
|
// * One shared rpicam-vid MJPEG process fans frames out to every HTTP client.
|
|
// * Photo / video / timelapse take exclusive ownership: the stream is stopped (clients stay
|
|
// connected and simply freeze), the job runs, then the stream restarts by itself.
|
|
// * A second job while one is running is rejected with 409 "camera_busy".
|
|
//
|
|
// Events: 'state', 'settings', 'focus', 'capture', 'timelapse'
|
|
// Camera service (rpicam-apps wrapper)
|
|
import { EventEmitter } from 'node:events';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { ServiceError } from '../lib/errors.mjs';
|
|
|
|
const BOUNDARY = 'frame';
|
|
const SOI = Buffer.from([0xff, 0xd8]);
|
|
const EOI = Buffer.from([0xff, 0xd9]);
|
|
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
|
|
const DEFAULT_SETTINGS = {
|
|
shutterUs: 0,
|
|
gain: 0,
|
|
ev: 0,
|
|
exposureMode: 'normal',
|
|
brightness: 0,
|
|
contrast: 1,
|
|
saturation: 1,
|
|
sharpness: 1,
|
|
awb: 'auto',
|
|
awbGains: null,
|
|
denoise: 'auto',
|
|
autofocusMode: 'manual',
|
|
autofocusRange: 'normal',
|
|
autofocusSpeed: 'normal',
|
|
lensPosition: 0,
|
|
hflip: false,
|
|
vflip: false,
|
|
rotation: 0,
|
|
quality: 93,
|
|
roi: null,
|
|
};
|
|
|
|
const num = (min, max) => (key, v) => {
|
|
const n = Number(v);
|
|
if (v === null || v === '' || !Number.isFinite(n) || n < min || n > max) {
|
|
throw new ServiceError('invalid_setting', `"${key}" must be a number between ${min} and ${max}`, 400);
|
|
}
|
|
return n;
|
|
};
|
|
|
|
const oneOf = (...opts) => (key, v) => {
|
|
if (!opts.includes(v)) {
|
|
throw new ServiceError('invalid_setting', `"${key}" must be one of: ${opts.join(', ')}`, 400);
|
|
}
|
|
return v;
|
|
};
|
|
|
|
const bool = (key, v) => {
|
|
if (typeof v !== 'boolean') {
|
|
throw new ServiceError('invalid_setting', `"${key}" must be true or false`, 400);
|
|
}
|
|
return v;
|
|
};
|
|
|
|
const SETTING_PARSERS = {
|
|
shutterUs: (k, v) => Math.round(num(0, 240_000_000)(k, v)),
|
|
gain: num(0, 64),
|
|
ev: num(-10, 10),
|
|
exposureMode: oneOf('normal', 'sport', 'long'),
|
|
brightness: num(-1, 1),
|
|
contrast: num(0, 32),
|
|
saturation: num(0, 32),
|
|
sharpness: num(0, 16),
|
|
awb: oneOf('auto', 'incandescent', 'tungsten', 'fluorescent', 'indoor', 'daylight', 'cloudy'),
|
|
awbGains: (k, v) => {
|
|
if (v === null) return null;
|
|
if (!Array.isArray(v) || v.length !== 2) {
|
|
throw new ServiceError('invalid_setting', `"${k}" must be [red, blue] or null`, 400);
|
|
}
|
|
return v.map((x) => num(0.1, 8)(k, x));
|
|
},
|
|
denoise: oneOf('auto', 'off', 'cdn_off', 'cdn_fast', 'cdn_hq'),
|
|
autofocusMode: oneOf('manual', 'auto', 'continuous'),
|
|
autofocusRange: oneOf('normal', 'macro', 'full'),
|
|
autofocusSpeed: oneOf('normal', 'fast'),
|
|
lensPosition: (k, v) => (v === null ? null : num(0, 32)(k, v)),
|
|
hflip: bool,
|
|
vflip: bool,
|
|
rotation: oneOf(0, 180),
|
|
quality: (k, v) => Math.round(num(1, 100)(k, v)),
|
|
roi: (k, v) => {
|
|
if (v === null) return null;
|
|
if (!Array.isArray(v) || v.length !== 4) {
|
|
throw new ServiceError('invalid_setting', `"${k}" must be [x, y, w, h] (0..1) or null`, 400);
|
|
}
|
|
const r = v.map((x) => num(0, 1)(k, x));
|
|
if (r[2] <= 0 || r[3] <= 0 || r[0] + r[2] > 1.0001 || r[1] + r[3] > 1.0001) {
|
|
throw new ServiceError('invalid_setting', `"${k}" region must lie inside the sensor`, 400);
|
|
}
|
|
return r;
|
|
},
|
|
};
|
|
|
|
/** Validate partial settings. ISO is a convenience alias for gain, not an independent sensor control. */
|
|
export function normalizeSettings(patch) {
|
|
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
throw new ServiceError('invalid_setting', 'Settings must be a JSON object', 400);
|
|
}
|
|
|
|
const out = {};
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (key === 'iso') {
|
|
out.gain = value === 0 ? 0 : num(1, 6400)('iso', value) / 100;
|
|
continue;
|
|
}
|
|
|
|
const parse = SETTING_PARSERS[key];
|
|
if (!parse) {
|
|
throw new ServiceError('unknown_setting', `Unknown setting "${key}"`, 400, {
|
|
valid: [...Object.keys(SETTING_PARSERS), 'iso'],
|
|
});
|
|
}
|
|
|
|
out[key] = parse(key, value);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
class MjpegParser {
|
|
#buf = Buffer.alloc(0);
|
|
|
|
constructor(onFrame) {
|
|
this.onFrame = onFrame;
|
|
}
|
|
|
|
push(chunk) {
|
|
this.#buf = this.#buf.length ? Buffer.concat([this.#buf, chunk]) : chunk;
|
|
|
|
for (;;) {
|
|
const soi = this.#buf.indexOf(SOI);
|
|
if (soi === -1) {
|
|
this.#buf = this.#buf.length && this.#buf[this.#buf.length - 1] === 0xff
|
|
? this.#buf.subarray(-1)
|
|
: Buffer.alloc(0);
|
|
return;
|
|
}
|
|
|
|
const eoi = this.#buf.indexOf(EOI, soi + 2);
|
|
if (eoi === -1) {
|
|
this.#buf = this.#buf.subarray(soi);
|
|
if (this.#buf.length > MAX_FRAME_BYTES) this.#buf = Buffer.alloc(0);
|
|
return;
|
|
}
|
|
|
|
this.onFrame(this.#buf.subarray(soi, eoi + 2));
|
|
this.#buf = this.#buf.subarray(eoi + 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
const stamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
|
|
|
|
const sleepable = () => {
|
|
let wake;
|
|
const promise = new Promise((resolve) => (wake = resolve));
|
|
return { promise, wake };
|
|
};
|
|
|
|
export class CameraService extends EventEmitter {
|
|
#cfg;
|
|
#log;
|
|
#dirs;
|
|
#settingsFile;
|
|
#metaProvider;
|
|
#settings = { ...DEFAULT_SETTINGS };
|
|
#mode = 'idle';
|
|
#locked = false;
|
|
#stream = null;
|
|
#clients = new Set();
|
|
#lastFrame = null;
|
|
#idleTimer = null;
|
|
#restartTimer = null;
|
|
#streamFailures = 0;
|
|
#job = null;
|
|
#activeProc = null;
|
|
#lastCapture = null;
|
|
#ffmpegAvailable = null;
|
|
#focus;
|
|
|
|
constructor(cfg, { log, dataDir, metaProvider }) {
|
|
super();
|
|
this.#cfg = cfg;
|
|
this.#log = log;
|
|
this.#metaProvider = metaProvider;
|
|
|
|
const base = path.join(dataDir, 'captures');
|
|
this.#dirs = {
|
|
base,
|
|
photos: path.join(base, 'photos'),
|
|
videos: path.join(base, 'videos'),
|
|
timelapse: path.join(base, 'timelapse'),
|
|
};
|
|
|
|
this.#settingsFile = path.join(dataDir, 'camera-settings.json');
|
|
this.#focus = {
|
|
position: 0,
|
|
target: 0,
|
|
min: 0,
|
|
max: cfg.focusMaxSteps,
|
|
hardware: false,
|
|
moving: false,
|
|
};
|
|
}
|
|
|
|
get mediaRoot() {
|
|
return this.#dirs.base;
|
|
}
|
|
|
|
async init() {
|
|
for (const dir of Object.values(this.#dirs)) await fsp.mkdir(dir, { recursive: true });
|
|
|
|
try {
|
|
const saved = JSON.parse(await fsp.readFile(this.#settingsFile, 'utf8'));
|
|
this.#settings = { ...DEFAULT_SETTINGS, ...normalizeSettings(saved) };
|
|
this.#log.info('loaded saved camera settings');
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') this.#log.warn(`ignoring unreadable camera settings: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async shutdown() {
|
|
clearTimeout(this.#idleTimer);
|
|
clearTimeout(this.#restartTimer);
|
|
if (this.#job?.type === 'timelapse') await this.stopTimelapse().catch(() => {});
|
|
if (this.#job?.type === 'video') await this.stopVideo().catch(() => {});
|
|
await this.#stopStream();
|
|
}
|
|
|
|
// ───────────────────────────── status / settings ─────────────────────────────
|
|
|
|
getSettings() {
|
|
return {
|
|
...this.#settings,
|
|
iso: this.#settings.gain ? Math.round(this.#settings.gain * 100) : 0,
|
|
};
|
|
}
|
|
|
|
getStatus() {
|
|
return {
|
|
mode: this.#mode,
|
|
busy: this.#locked,
|
|
streamClients: this.#clients.size,
|
|
stream: this.#stream
|
|
? { running: true, fps: this.#stream.fps, width: this.#cfg.width, height: this.#cfg.height }
|
|
: { running: false },
|
|
settings: this.getSettings(),
|
|
focus: this.getFocus(),
|
|
job: this.#jobSummary(),
|
|
lastCapture: this.#lastCapture,
|
|
};
|
|
}
|
|
|
|
/** Patch any subset of settings, persist them, and restart the live stream if needed. */
|
|
async applySettings(patch) {
|
|
const clean = normalizeSettings(patch);
|
|
this.#settings = { ...this.#settings, ...clean };
|
|
|
|
try {
|
|
await fsp.writeFile(this.#settingsFile, JSON.stringify(this.#settings, null, 2));
|
|
} catch (err) {
|
|
this.#log.warn(`could not persist camera settings: ${err.message}`);
|
|
}
|
|
|
|
this.#log.info('camera settings updated', clean);
|
|
this.emit('settings', this.getSettings());
|
|
this.#clearError();
|
|
|
|
if (this.#stream && !this.#locked) {
|
|
clearTimeout(this.#restartTimer);
|
|
this.#restartTimer = setTimeout(async () => {
|
|
if (this.#locked) return;
|
|
await this.#stopStream();
|
|
this.#ensureStream();
|
|
}, 300);
|
|
}
|
|
|
|
return this.getSettings();
|
|
}
|
|
|
|
/** Restore and persist all defaults. */
|
|
async resetSettings() {
|
|
return this.applySettings({ ...DEFAULT_SETTINGS });
|
|
}
|
|
|
|
// ───────────────────────────── focuser ─────────────────────────────
|
|
|
|
getFocus() {
|
|
return { ...this.#focus };
|
|
}
|
|
|
|
/** Stub for the telescope focuser hardware interface. */
|
|
async setFocus(position) {
|
|
const n = Number(position);
|
|
if (!Number.isFinite(n)) {
|
|
throw new ServiceError('invalid_focus', '"position" must be a number', 400);
|
|
}
|
|
|
|
const f = this.#focus;
|
|
const target = Math.min(f.max, Math.max(f.min, Math.round(n)));
|
|
const delta = target - f.position;
|
|
|
|
this.#log.info(
|
|
`[focuser:stub] move ${f.position} -> ${target} (${delta >= 0 ? '+' : ''}${delta} steps)` +
|
|
(target !== Math.round(n) ? ` (clamped from ${Math.round(n)})` : '') +
|
|
' — no focuser hardware attached',
|
|
);
|
|
|
|
f.target = target;
|
|
f.position = target;
|
|
this.emit('focus', this.getFocus());
|
|
return this.getFocus();
|
|
}
|
|
|
|
async nudgeFocus(delta) {
|
|
const n = Number(delta);
|
|
if (!Number.isFinite(n)) {
|
|
throw new ServiceError('invalid_focus', '"delta" must be a number', 400);
|
|
}
|
|
return this.setFocus(this.#focus.position + n);
|
|
}
|
|
|
|
// ───────────────────────────── live stream ─────────────────────────────
|
|
|
|
addStreamClient(req, res) {
|
|
res.writeHead(200, {
|
|
'Content-Type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
|
|
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
|
Pragma: 'no-cache',
|
|
Connection: 'close',
|
|
});
|
|
|
|
res.socket?.setNoDelay(true);
|
|
this.#clients.add(res);
|
|
clearTimeout(this.#idleTimer);
|
|
this.#clearError();
|
|
this.#log.debug(`stream client connected (${this.#clients.size})`);
|
|
this.emit('state', this.getStatus());
|
|
|
|
if (this.#lastFrame) this.#sendFrame(res, this.#lastFrame);
|
|
this.#ensureStream();
|
|
|
|
let gone = false;
|
|
const onGone = () => {
|
|
if (gone) return;
|
|
gone = true;
|
|
this.#clients.delete(res);
|
|
this.#log.debug(`stream client left (${this.#clients.size})`);
|
|
this.#scheduleIdleStop();
|
|
this.emit('state', this.getStatus());
|
|
};
|
|
|
|
req.on('close', onGone);
|
|
res.on('error', onGone);
|
|
}
|
|
|
|
getSnapshot() {
|
|
return this.#lastFrame;
|
|
}
|
|
|
|
#clearError() {
|
|
this.#streamFailures = 0;
|
|
if (this.#mode === 'error' && !this.#stream && !this.#locked) this.#setMode('idle');
|
|
}
|
|
|
|
#ensureStream() {
|
|
if (this.#locked || this.#stream || this.#clients.size === 0 || this.#mode === 'error') return;
|
|
this.#startStream();
|
|
}
|
|
|
|
#scheduleIdleStop() {
|
|
if (this.#clients.size > 0) return;
|
|
clearTimeout(this.#idleTimer);
|
|
this.#idleTimer = setTimeout(async () => {
|
|
if (this.#clients.size === 0 && !this.#locked) {
|
|
await this.#stopStream();
|
|
this.#setMode('idle');
|
|
}
|
|
}, this.#cfg.streamIdleStopMs);
|
|
}
|
|
|
|
#streamArgs() {
|
|
const c = this.#cfg;
|
|
const s = this.#settings;
|
|
const shutter = s.shutterUs > 0 ? Math.min(s.shutterUs, c.streamMaxShutterUs) : 0;
|
|
const fps = shutter > 0 ? Math.min(c.fps, 1_000_000 / shutter) : c.fps;
|
|
|
|
return {
|
|
fps,
|
|
args: [
|
|
'-n', '-t', '0',
|
|
'--width', String(c.width),
|
|
'--height', String(c.height),
|
|
'--framerate', fps.toFixed(3),
|
|
'--codec', 'mjpeg',
|
|
'-q', String(s.quality),
|
|
...this.#controlArgs(s, { shutterUs: shutter }),
|
|
'-o', '-',
|
|
],
|
|
};
|
|
}
|
|
|
|
#startStream() {
|
|
const { fps, args } = this.#streamArgs();
|
|
this.#log.info(`starting live view ${this.#cfg.width}x${this.#cfg.height} @ ${fps.toFixed(1)} fps`);
|
|
|
|
const proc = spawn(this.#cfg.vidBin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
const stream = { proc, fps, expected: false, startedAt: Date.now(), frames: 0, exited: null };
|
|
|
|
stream.exited = new Promise((resolve) => {
|
|
proc.once('close', resolve);
|
|
proc.once('error', resolve);
|
|
});
|
|
|
|
this.#stream = stream;
|
|
this.#setMode('streaming');
|
|
|
|
const parser = new MjpegParser((frame) => {
|
|
stream.frames += 1;
|
|
this.#lastFrame = Buffer.from(frame);
|
|
this.#broadcast(this.#lastFrame);
|
|
});
|
|
|
|
proc.stdout.on('data', (chunk) => parser.push(chunk));
|
|
proc.stderr.on('data', (data) => {
|
|
const message = String(data).trim();
|
|
if (/error|fail|unable|no cameras/i.test(message)) {
|
|
this.#log.error(`rpicam-vid: ${message.slice(0, 300)}`);
|
|
}
|
|
});
|
|
|
|
proc.on('error', (err) => {
|
|
this.#log.error(`cannot run ${this.#cfg.vidBin}: ${err.message}`);
|
|
if (this.#stream === stream) this.#stream = null;
|
|
this.#setMode('error');
|
|
});
|
|
|
|
proc.on('close', (code, signal) => {
|
|
if (this.#stream !== stream) return;
|
|
this.#stream = null;
|
|
this.#lastFrame = null;
|
|
this.#streamFailures += 1;
|
|
this.#log.warn(`live view exited unexpectedly (code=${code}, signal=${signal}, frames=${stream.frames})`);
|
|
|
|
if (this.#streamFailures >= 5) {
|
|
this.#log.error('live view failed 5 times in a row; giving up until a new viewer connects or settings change');
|
|
this.#setMode('error');
|
|
return;
|
|
}
|
|
|
|
this.#setMode('idle');
|
|
if (this.#clients.size && !this.#locked) {
|
|
clearTimeout(this.#restartTimer);
|
|
this.#restartTimer = setTimeout(() => this.#ensureStream(), 1000 * this.#streamFailures);
|
|
}
|
|
});
|
|
}
|
|
|
|
async #stopStream() {
|
|
const stream = this.#stream;
|
|
if (!stream) return;
|
|
|
|
this.#stream = null;
|
|
stream.expected = true;
|
|
stream.proc.kill('SIGINT');
|
|
|
|
const killer = setTimeout(() => stream.proc.kill('SIGKILL'), 3000);
|
|
await stream.exited;
|
|
clearTimeout(killer);
|
|
this.#lastFrame = null;
|
|
this.#log.debug('live view stopped');
|
|
}
|
|
|
|
#broadcast(frame) {
|
|
for (const res of this.#clients) this.#sendFrame(res, frame);
|
|
}
|
|
|
|
#sendFrame(res, frame) {
|
|
if (res.writableNeedDrain || res.destroyed) return;
|
|
res.write(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
|
|
res.write(frame);
|
|
res.write('\r\n');
|
|
}
|
|
|
|
// ───────────────────────────── exclusive jobs ─────────────────────────────
|
|
|
|
async #acquire(mode) {
|
|
if (this.#locked) {
|
|
throw new ServiceError('camera_busy', `Camera is busy (${this.#mode})`, 409, { mode: this.#mode });
|
|
}
|
|
|
|
this.#clearError();
|
|
this.#locked = true;
|
|
clearTimeout(this.#restartTimer);
|
|
clearTimeout(this.#idleTimer);
|
|
|
|
try {
|
|
await this.#stopStream();
|
|
} catch (err) {
|
|
this.#locked = false;
|
|
throw err;
|
|
}
|
|
|
|
this.#setMode(mode);
|
|
}
|
|
|
|
#release() {
|
|
this.#locked = false;
|
|
this.#job = null;
|
|
this.#setMode('idle');
|
|
this.#ensureStream();
|
|
}
|
|
|
|
#setMode(mode) {
|
|
if (this.#mode === mode) return;
|
|
const previous = this.#mode;
|
|
this.#mode = mode;
|
|
this.#log.debug(`camera ${previous} -> ${mode}`);
|
|
this.emit('state', this.getStatus());
|
|
}
|
|
|
|
#meta() {
|
|
try {
|
|
return this.#metaProvider?.() ?? null;
|
|
} catch (err) {
|
|
this.#log.warn(`metadata provider failed: ${err.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
#run(bin, args, timeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
const proc = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
this.#activeProc = proc;
|
|
let stderr = '';
|
|
let timedOut = false;
|
|
|
|
proc.stderr.on('data', (data) => {
|
|
stderr = (stderr + data).slice(-4000);
|
|
});
|
|
|
|
const timer = setTimeout(() => {
|
|
timedOut = true;
|
|
proc.kill('SIGKILL');
|
|
}, timeoutMs);
|
|
|
|
proc.on('error', (err) => {
|
|
clearTimeout(timer);
|
|
this.#activeProc = null;
|
|
reject(new ServiceError('camera_spawn_failed', `Cannot run ${bin}: ${err.message}`, 500));
|
|
});
|
|
|
|
proc.on('close', (code, signal) => {
|
|
clearTimeout(timer);
|
|
this.#activeProc = null;
|
|
resolve({ code, signal, stderr, timedOut });
|
|
});
|
|
});
|
|
}
|
|
|
|
// ───────────────────────────── photos ─────────────────────────────
|
|
|
|
#stillArgs(file, settings, { raw = false } = {}) {
|
|
const c = this.#cfg;
|
|
const manual = settings.shutterUs > 0 && settings.gain > 0;
|
|
|
|
return [
|
|
'-n',
|
|
'-o', file,
|
|
'-q', String(settings.quality),
|
|
...(manual ? ['--immediate'] : ['-t', '1500']),
|
|
...(raw ? ['--raw'] : []),
|
|
...(c.photoWidth > 0 && c.photoHeight > 0
|
|
? ['--width', String(c.photoWidth), '--height', String(c.photoHeight)]
|
|
: []),
|
|
...this.#controlArgs(settings),
|
|
];
|
|
}
|
|
|
|
#stillTimeoutMs(settings) {
|
|
return Math.max(30_000, (settings.shutterUs / 1000) * 4 + 30_000);
|
|
}
|
|
|
|
async #captureStill(file, settings, opts) {
|
|
const started = Date.now();
|
|
const result = await this.#run(
|
|
this.#cfg.stillBin,
|
|
this.#stillArgs(file, settings, opts),
|
|
this.#stillTimeoutMs(settings),
|
|
);
|
|
|
|
if (result.code !== 0 || !fs.existsSync(file)) {
|
|
const reason = result.timedOut
|
|
? 'timed out'
|
|
: result.signal
|
|
? `killed (${result.signal})`
|
|
: `exit code ${result.code}`;
|
|
|
|
throw new ServiceError('capture_failed', `rpicam-still failed: ${reason}`, 500, {
|
|
stderr: result.stderr.trim().split('\n').slice(-5).join('\n'),
|
|
});
|
|
}
|
|
|
|
const stat = await fsp.stat(file);
|
|
return { file, bytes: stat.size, durationMs: Date.now() - started };
|
|
}
|
|
|
|
async takePhoto(overrides = {}) {
|
|
const { raw = false, ...settingOverrides } = overrides;
|
|
const settings = { ...this.#settings, ...normalizeSettings(settingOverrides) };
|
|
|
|
await this.#acquire('photo');
|
|
|
|
try {
|
|
const base = `photo_${stamp()}`;
|
|
const file = path.join(this.#dirs.photos, `${base}.jpg`);
|
|
|
|
this.#log.info(
|
|
`photo: ${settings.shutterUs ? `${(settings.shutterUs / 1e6).toFixed(2)}s` : 'auto'} exposure, ` +
|
|
`gain ${settings.gain || 'auto'}${raw ? ', +RAW' : ''}`,
|
|
);
|
|
|
|
const meta = this.#meta();
|
|
const result = await this.#captureStill(file, settings, { raw });
|
|
const info = {
|
|
type: 'photo',
|
|
name: path.basename(file),
|
|
url: `/media/photos/${path.basename(file)}`,
|
|
raw: raw ? `/media/photos/${base}.dng` : null,
|
|
bytes: result.bytes,
|
|
durationMs: result.durationMs,
|
|
takenAt: new Date().toISOString(),
|
|
settings: { ...settings, iso: settings.gain ? Math.round(settings.gain * 100) : 0 },
|
|
meta,
|
|
};
|
|
|
|
await fsp.writeFile(path.join(this.#dirs.photos, `${base}.json`), JSON.stringify(info, null, 2)).catch(() => {});
|
|
this.#lastCapture = { type: 'photo', url: info.url, at: info.takenAt };
|
|
this.#log.info(`photo saved: ${info.name} (${(info.bytes / 1e6).toFixed(1)} MB in ${(info.durationMs / 1000).toFixed(1)}s)`);
|
|
this.emit('capture', info);
|
|
return info;
|
|
} finally {
|
|
this.#release();
|
|
}
|
|
}
|
|
|
|
// ───────────────────────────── video ─────────────────────────────
|
|
|
|
async startVideo(opts = {}) {
|
|
const { durationSec = 0, width, height, fps, ...settingOverrides } = opts;
|
|
const settings = { ...this.#settings, ...normalizeSettings(settingOverrides) };
|
|
const c = this.#cfg;
|
|
|
|
if (!Number.isFinite(Number(durationSec)) || durationSec < 0) {
|
|
throw new ServiceError('invalid_option', '"durationSec" must be >= 0', 400);
|
|
}
|
|
|
|
await this.#acquire('video');
|
|
|
|
const w = width || c.videoWidth;
|
|
const h = height || c.videoHeight;
|
|
const f = fps || c.videoFps;
|
|
const base = `video_${stamp()}`;
|
|
const rawFile = path.join(this.#dirs.videos, `${base}.h264`);
|
|
|
|
let proc;
|
|
|
|
try {
|
|
const args = [
|
|
'-n',
|
|
'-t', String(Math.round(durationSec * 1000)),
|
|
'--width', String(w),
|
|
'--height', String(h),
|
|
'--framerate', String(f),
|
|
'--codec', 'h264',
|
|
'--inline',
|
|
...this.#controlArgs(settings),
|
|
'-o', rawFile,
|
|
];
|
|
|
|
proc = spawn(c.vidBin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
} catch (err) {
|
|
this.#release();
|
|
throw new ServiceError('camera_spawn_failed', `Cannot run ${c.vidBin}: ${err.message}`, 500);
|
|
}
|
|
|
|
const job = {
|
|
type: 'video',
|
|
id: base,
|
|
file: rawFile,
|
|
startedAt: Date.now(),
|
|
durationSec,
|
|
proc,
|
|
stopping: false,
|
|
done: null,
|
|
};
|
|
|
|
this.#job = job;
|
|
let stderr = '';
|
|
|
|
proc.stderr.on('data', (data) => {
|
|
stderr = (stderr + data).slice(-2000);
|
|
});
|
|
|
|
job.done = new Promise((resolve) => {
|
|
const finish = async (code, signal, spawnErr) => {
|
|
let info;
|
|
|
|
try {
|
|
if (spawnErr || !fs.existsSync(rawFile)) {
|
|
info = {
|
|
type: 'video',
|
|
error: spawnErr?.message ?? `rpicam-vid produced no file (code=${code})`,
|
|
stderr: stderr.trim().split('\n').slice(-3).join('\n'),
|
|
};
|
|
this.#log.error(`video failed: ${info.error}`);
|
|
} else {
|
|
info = await this.#finishVideo(job, rawFile, base, f);
|
|
}
|
|
} finally {
|
|
this.#release();
|
|
}
|
|
|
|
resolve(info);
|
|
};
|
|
|
|
proc.once('error', (err) => finish(null, null, err));
|
|
proc.once('close', (code, signal) => finish(code, signal));
|
|
});
|
|
|
|
this.#log.info(`video recording started (${w}x${h} @ ${f} fps${durationSec ? `, ${durationSec}s` : ''})`);
|
|
return this.#jobSummary();
|
|
}
|
|
|
|
async #finishVideo(job, rawFile, base, fps) {
|
|
let finalFile = rawFile;
|
|
|
|
if (this.#cfg.remuxMp4 && this.#hasFfmpeg()) {
|
|
const mp4 = path.join(this.#dirs.videos, `${base}.mp4`);
|
|
const code = await new Promise((resolve) => {
|
|
const proc = spawn(
|
|
this.#cfg.ffmpegBin,
|
|
['-y', '-loglevel', 'error', '-framerate', String(fps), '-i', rawFile, '-c', 'copy', mp4],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
proc.on('error', () => resolve(1));
|
|
proc.on('close', resolve);
|
|
});
|
|
|
|
if (code === 0 && fs.existsSync(mp4)) {
|
|
await fsp.unlink(rawFile).catch(() => {});
|
|
finalFile = mp4;
|
|
} else {
|
|
this.#log.warn('mp4 remux failed; keeping raw .h264');
|
|
}
|
|
}
|
|
|
|
const stat = await fsp.stat(finalFile);
|
|
const info = {
|
|
type: 'video',
|
|
name: path.basename(finalFile),
|
|
url: `/media/videos/${path.basename(finalFile)}`,
|
|
bytes: stat.size,
|
|
durationSec: Math.round((Date.now() - job.startedAt) / 100) / 10,
|
|
takenAt: new Date(job.startedAt).toISOString(),
|
|
meta: this.#meta(),
|
|
};
|
|
|
|
this.#lastCapture = { type: 'video', url: info.url, at: new Date().toISOString() };
|
|
this.#log.info(`video saved: ${info.name} (${(info.bytes / 1e6).toFixed(1)} MB, ${info.durationSec}s)`);
|
|
this.emit('capture', info);
|
|
return info;
|
|
}
|
|
|
|
#hasFfmpeg() {
|
|
if (this.#ffmpegAvailable === null) {
|
|
this.#ffmpegAvailable = !spawnSync(this.#cfg.ffmpegBin, ['-version'], { stdio: 'ignore' }).error;
|
|
if (!this.#ffmpegAvailable) this.#log.warn('ffmpeg not found; videos stay as raw .h264 (apt install ffmpeg to get .mp4)');
|
|
}
|
|
return this.#ffmpegAvailable;
|
|
}
|
|
|
|
async stopVideo() {
|
|
const job = this.#job;
|
|
if (!job || job.type !== 'video') {
|
|
throw new ServiceError('not_recording', 'No video recording in progress', 409);
|
|
}
|
|
|
|
if (!job.stopping) {
|
|
job.stopping = true;
|
|
job.proc.kill('SIGINT');
|
|
const killer = setTimeout(() => job.proc.kill('SIGKILL'), 5000);
|
|
job.done.finally(() => clearTimeout(killer));
|
|
}
|
|
|
|
return job.done;
|
|
}
|
|
|
|
// ───────────────────────────── timelapse ─────────────────────────────
|
|
|
|
async startTimelapse(opts = {}) {
|
|
const { intervalSec, count = 0, durationSec = 0, name, raw = false, ...settingOverrides } = opts;
|
|
|
|
if (!(Number(intervalSec) >= 1)) {
|
|
throw new ServiceError('invalid_option', '"intervalSec" must be a number >= 1', 400);
|
|
}
|
|
if (!(Number(count) >= 0) || !(Number(durationSec) >= 0)) {
|
|
throw new ServiceError('invalid_option', '"count" and "durationSec" must be >= 0', 400);
|
|
}
|
|
if (!count && !durationSec) {
|
|
throw new ServiceError('invalid_option', 'Provide "count" and/or "durationSec" (use stopTimelapse to end early)', 400);
|
|
}
|
|
|
|
const settings = { ...this.#settings, ...normalizeSettings(settingOverrides) };
|
|
|
|
if (settings.shutterUs / 1e6 >= intervalSec) {
|
|
throw new ServiceError(
|
|
'invalid_option',
|
|
`intervalSec (${intervalSec}) must be longer than the exposure (${(settings.shutterUs / 1e6).toFixed(2)}s)`,
|
|
400,
|
|
);
|
|
}
|
|
|
|
await this.#acquire('timelapse');
|
|
|
|
const id = `${String(name || 'timelapse').replace(/[^\w-]+/g, '_')}_${stamp()}`;
|
|
const dir = path.join(this.#dirs.timelapse, id);
|
|
|
|
try {
|
|
await fsp.mkdir(dir, { recursive: true });
|
|
} catch (err) {
|
|
this.#release();
|
|
throw new ServiceError('storage_error', `Cannot create ${dir}: ${err.message}`, 500);
|
|
}
|
|
|
|
const sleeper = sleepable();
|
|
const job = {
|
|
type: 'timelapse',
|
|
id,
|
|
dir,
|
|
intervalSec: Number(intervalSec),
|
|
total: Number(count) || null,
|
|
endAt: durationSec ? Date.now() + durationSec * 1000 : null,
|
|
startedAt: Date.now(),
|
|
frames: 0,
|
|
failures: 0,
|
|
stop: false,
|
|
wake: sleeper.wake,
|
|
sleeper,
|
|
nextAt: null,
|
|
done: null,
|
|
};
|
|
|
|
this.#job = job;
|
|
job.done = this.#timelapseLoop(job, settings, raw)
|
|
.catch((err) => this.#log.error(`timelapse aborted: ${err.message}`))
|
|
.then(() => {
|
|
const summary = {
|
|
type: 'timelapse',
|
|
id,
|
|
frames: job.frames,
|
|
dir: `/media/timelapse/${id}`,
|
|
stoppedByUser: job.stop,
|
|
};
|
|
|
|
this.#lastCapture = { type: 'timelapse', url: summary.dir, at: new Date().toISOString() };
|
|
this.#log.info(`timelapse finished: ${job.frames} frames in ${id}`);
|
|
this.emit('timelapse', { ...summary, state: 'finished' });
|
|
this.emit('capture', summary);
|
|
this.#release();
|
|
return summary;
|
|
});
|
|
|
|
await fsp.writeFile(
|
|
path.join(dir, 'session.json'),
|
|
JSON.stringify({
|
|
id,
|
|
startedAt: new Date().toISOString(),
|
|
intervalSec,
|
|
count,
|
|
durationSec,
|
|
settings,
|
|
meta: this.#meta(),
|
|
}, null, 2),
|
|
).catch(() => {});
|
|
|
|
this.#log.info(
|
|
`timelapse started: every ${intervalSec}s${count ? `, ${count} frames` : ''}${durationSec ? `, ${durationSec}s` : ''} -> ${id}`,
|
|
);
|
|
|
|
return this.#jobSummary();
|
|
}
|
|
|
|
async #timelapseLoop(job, settings, raw) {
|
|
const frameLog = path.join(job.dir, 'frames.jsonl');
|
|
|
|
while (!job.stop) {
|
|
const tickStart = Date.now();
|
|
const n = job.frames + 1;
|
|
const file = path.join(job.dir, `frame_${String(n).padStart(5, '0')}.jpg`);
|
|
|
|
try {
|
|
const meta = this.#meta();
|
|
const result = await this.#captureStill(file, settings, { raw });
|
|
|
|
if (job.stop && !fs.existsSync(file)) break;
|
|
|
|
job.frames = n;
|
|
job.failures = 0;
|
|
|
|
await fsp.appendFile(
|
|
frameLog,
|
|
JSON.stringify({
|
|
n,
|
|
file: path.basename(file),
|
|
at: new Date().toISOString(),
|
|
bytes: result.bytes,
|
|
meta,
|
|
}) + '\n',
|
|
).catch(() => {});
|
|
|
|
this.emit('timelapse', { id: job.id, state: 'frame', frames: job.frames, total: job.total });
|
|
} catch (err) {
|
|
if (job.stop) break;
|
|
job.failures += 1;
|
|
this.#log.error(`timelapse frame ${n} failed (${job.failures}/3): ${err.message}`);
|
|
if (job.failures >= 3) throw new Error('3 consecutive frame failures');
|
|
}
|
|
|
|
if (job.total && job.frames >= job.total) break;
|
|
|
|
const nextAt = tickStart + job.intervalSec * 1000;
|
|
if (job.endAt && nextAt > job.endAt) break;
|
|
|
|
const wait = nextAt - Date.now();
|
|
job.nextAt = nextAt;
|
|
|
|
if (wait > 0) {
|
|
await Promise.race([
|
|
new Promise((resolve) => setTimeout(resolve, wait)),
|
|
job.sleeper.promise,
|
|
]);
|
|
} else {
|
|
this.#log.warn(`timelapse frame took longer than the ${job.intervalSec}s interval; not waiting`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async stopTimelapse() {
|
|
const job = this.#job;
|
|
if (!job || job.type !== 'timelapse') {
|
|
throw new ServiceError('no_timelapse', 'No timelapse in progress', 409);
|
|
}
|
|
|
|
job.stop = true;
|
|
job.wake();
|
|
this.#activeProc?.kill('SIGTERM');
|
|
return job.done;
|
|
}
|
|
|
|
// ───────────────────────────── file listing ─────────────────────────────
|
|
|
|
async listFiles({ limit = 50 } = {}) {
|
|
const read = async (dir, url) => {
|
|
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
const out = [];
|
|
|
|
for (const entry of entries) {
|
|
if (entry.name.endsWith('.json') || entry.name.endsWith('.jsonl')) continue;
|
|
|
|
const fullPath = path.join(dir, entry.name);
|
|
const stat = await fsp.stat(fullPath).catch(() => null);
|
|
if (!stat) continue;
|
|
|
|
if (entry.isDirectory()) {
|
|
const frames = (await fsp.readdir(fullPath).catch(() => [])).filter((name) => name.endsWith('.jpg')).length;
|
|
out.push({ name: entry.name, url: `${url}/${entry.name}`, frames, mtime: stat.mtime });
|
|
} else {
|
|
out.push({ name: entry.name, url: `${url}/${entry.name}`, bytes: stat.size, mtime: stat.mtime });
|
|
}
|
|
}
|
|
|
|
return out.sort((a, b) => b.mtime - a.mtime).slice(0, limit);
|
|
};
|
|
|
|
return {
|
|
photos: await read(this.#dirs.photos, '/media/photos'),
|
|
videos: await read(this.#dirs.videos, '/media/videos'),
|
|
timelapses: await read(this.#dirs.timelapse, '/media/timelapse'),
|
|
};
|
|
}
|
|
|
|
// ───────────────────────────── rpicam argument builder ─────────────────────────────
|
|
|
|
#controlArgs(settings, { shutterUs = settings.shutterUs } = {}) {
|
|
const args = [];
|
|
|
|
if (shutterUs > 0) args.push('--shutter', String(Math.round(shutterUs)));
|
|
if (settings.gain > 0) args.push('--gain', String(settings.gain));
|
|
if (settings.ev) args.push('--ev', String(settings.ev));
|
|
if (settings.exposureMode !== 'normal') args.push('--exposure', settings.exposureMode);
|
|
|
|
args.push(
|
|
'--brightness', String(settings.brightness),
|
|
'--contrast', String(settings.contrast),
|
|
'--saturation', String(settings.saturation),
|
|
'--sharpness', String(settings.sharpness),
|
|
);
|
|
|
|
if (settings.awbGains) args.push('--awbgains', settings.awbGains.join(','));
|
|
else args.push('--awb', settings.awb);
|
|
|
|
args.push('--denoise', settings.denoise);
|
|
|
|
if (settings.hflip) args.push('--hflip');
|
|
if (settings.vflip) args.push('--vflip');
|
|
if (settings.rotation) args.push('--rotation', String(settings.rotation));
|
|
if (settings.roi) args.push('--roi', settings.roi.join(','));
|
|
|
|
args.push(
|
|
'--autofocus-mode', settings.autofocusMode,
|
|
'--autofocus-range', settings.autofocusRange,
|
|
'--autofocus-speed', settings.autofocusSpeed,
|
|
);
|
|
|
|
if (settings.autofocusMode === 'manual' && settings.lensPosition !== null) {
|
|
args.push('--lens-position', String(settings.lensPosition));
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
#jobSummary() {
|
|
const job = this.#job;
|
|
if (!job) return null;
|
|
|
|
if (job.type === 'video') {
|
|
return {
|
|
type: 'video',
|
|
id: job.id,
|
|
elapsedSec: Math.round((Date.now() - job.startedAt) / 100) / 10,
|
|
durationSec: job.durationSec || null,
|
|
stopping: job.stopping,
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: 'timelapse',
|
|
id: job.id,
|
|
frames: job.frames,
|
|
total: job.total,
|
|
intervalSec: job.intervalSec,
|
|
elapsedSec: Math.round((Date.now() - job.startedAt) / 1000),
|
|
nextFrameAt: job.nextAt ? new Date(job.nextAt).toISOString() : null,
|
|
dir: `/media/timelapse/${job.id}`,
|
|
};
|
|
}
|
|
}
|