diff --git a/services/camera.mjs b/services/camera.mjs index 93e48e6..8d334f3 100644 --- a/services/camera.mjs +++ b/services/camera.mjs @@ -1,4 +1,3 @@ - // Camera service (rpicam-apps wrapper) // // libcamera lets only ONE process own the camera, so everything here goes through a single @@ -401,11 +400,21 @@ export class CameraService extends EventEmitter { }, this.#cfg.streamIdleStopMs); } + // Resolve exposure and frame timing once so preview and recording don't silently + // choose different shutter speeds or incompatible frame rates. + #cameraPlan(settings, requestedFps) { + const shutterUs = settings.shutterUs > 0 ? settings.shutterUs : 0; + const fps = shutterUs > 0 + ? Math.min(Number(requestedFps), 1_000_000 / shutterUs) + : Number(requestedFps); + + return { settings, shutterUs, fps: Math.max(0.001, fps) }; + } + #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; + const plan = this.#cameraPlan(this.#settings, c.fps); + const { settings, shutterUs, fps } = plan; return { fps, @@ -415,8 +424,8 @@ export class CameraService extends EventEmitter { '--height', String(c.height), '--framerate', fps.toFixed(3), '--codec', 'mjpeg', - '-q', String(s.quality), - ...this.#controlArgs(s, { shutterUs: shutter }), + '-q', String(settings.quality), + ...this.#controlArgs(settings, { shutterUs }), '-o', '-', ], }; @@ -424,7 +433,7 @@ export class CameraService extends EventEmitter { #startStream() { const { fps, args } = this.#streamArgs(); - this.#log.info(`starting live view ${this.#cfg.width}x${this.#cfg.height} @ ${fps.toFixed(1)} fps`); + this.#log.info(`starting live view ${this.#cfg.width}x${this.#cfg.height} @ ${fps.toFixed(3)} 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 }; @@ -584,7 +593,8 @@ export class CameraService extends EventEmitter { #stillArgs(file, settings, { raw = false } = {}) { const c = this.#cfg; - const manual = settings.shutterUs > 0 && settings.gain > 0; + const { shutterUs } = this.#cameraPlan(settings, 1); + const manual = shutterUs > 0 && settings.gain > 0; return [ '-n', @@ -681,7 +691,9 @@ export class CameraService extends EventEmitter { const w = width || c.videoWidth; const h = height || c.videoHeight; - const f = fps || c.videoFps; + const requestedFps = Number(fps || c.videoFps); + const plan = this.#cameraPlan(settings, requestedFps); + const f = plan.fps; const base = `video_${stamp()}`; const rawFile = path.join(this.#dirs.videos, `${base}.h264`); @@ -696,7 +708,7 @@ export class CameraService extends EventEmitter { '--framerate', String(f), '--codec', 'h264', '--inline', - ...this.#controlArgs(settings), + ...this.#controlArgs(plan.settings, { shutterUs: plan.shutterUs }), '-o', rawFile, ]; @@ -750,7 +762,11 @@ export class CameraService extends EventEmitter { proc.once('close', (code, signal) => finish(code, signal)); }); - this.#log.info(`video recording started (${w}x${h} @ ${f} fps${durationSec ? `, ${durationSec}s` : ''})`); + this.#log.info( + `video recording started (${w}x${h} @ ${f.toFixed(3)} fps` + + `${f < requestedFps ? `, limited from ${requestedFps} fps by ${plan.shutterUs}µs shutter` : ''}` + + `${durationSec ? `, ${durationSec}s` : ''})`, + ); return this.#jobSummary(); } @@ -1077,4 +1093,4 @@ export class CameraService extends EventEmitter { dir: `/media/timelapse/${job.id}`, }; } -} +} \ No newline at end of file