VulpesOS/host/NativeCamera.sys.mjs
2026-11-04 12:52:20 +01:00

119 lines
5.1 KiB
JavaScript

// Only Platform's authorized camera application may own this worker.
import {Subprocess} from 'resource://gre/modules/Subprocess.sys.mjs';
import {setTimeout, clearTimeout} from 'resource://gre/modules/Timer.sys.mjs';
export class NativeCamera {
constructor(device = '') {
this.device = device;
this.previewSize = device === 'beatles' ? {width:320,height:240} : {width:640,height:480};
this.session = null;
this.queue = Promise.resolve();
this.closing = Promise.resolve();
this.nextSession = 0;
}
request(actor, params) {
const work = this.queue.then(() => this.perform(actor, params));
this.queue = work.catch(() => {});
return work;
}
async packet(session, metadata = false) {
let timer;
try {
return await Promise.race([
(async () => {
const pipe=session.process.stdout;
const header=new DataView(await pipe.read(metadata ? 4 : 8));
const sequence=metadata ? null : header.getUint32(0,true);
const length=header.getUint32(metadata ? 0 : 4,true);
if(metadata) {
if(length>65536) throw Error('CAMERA_INVALID_FORMAT');
return pipe.readJSON(length);
}
const expected = this.session.width * this.session.height * 3 / 2;
if(length!==0 && length!==expected) throw Error('CAMERA_INVALID_FRAME');
return {sequence,data:length ? await pipe.read(length) : null};
})(),
new Promise((_, reject) => { timer=setTimeout(()=>reject(Error('CAMERA_TIMEOUT')),15000); }),
]);
} finally { clearTimeout(timer); }
}
async perform(actor, params) {
if (params.action === 'stop') {
if (this.session?.actor === actor && this.session.id === params.session) await this.close();
return null;
}
if (params.action === 'start') {
if (this.session && this.session.actor !== actor) throw Error('CAMERA_BUSY');
await this.close();
if (this.device === 'beatles' && params.camera === 'front') throw Error('CAMERA_UNAVAILABLE');
const beatles = this.device === 'beatles';
const process = await Subprocess.call({command:beatles?'/usr/bin/python3.11':'/usr/bin/python3',
arguments:beatles
? ['-u','/opt/vulpes/tundra/runtime/beatles-camera-client.py']
: ['-u', '/etc/tundra/camera-preview.py', '--camera', params.camera === 'front' ? '1' : '0',
'--width',String(this.previewSize.width),'--height',String(this.previewSize.height)],
stderr:'ignore'});
this.session = {actor, process, id: ++this.nextSession};
try {
const metadata = await this.packet(this.session, true);
if (!metadata.ready || metadata.format !== 'nv21' ||
metadata.width !== this.previewSize.width || metadata.height !== this.previewSize.height)
throw Error('CAMERA_INVALID_FORMAT');
this.session.width = metadata.width;
this.session.height = metadata.height;
return {...metadata, session: this.session.id,
// The 2720 Flip has 400 MB of RAM and copies each frame through
// CameraService, Gecko IPC and a software canvas. Polling at the
// sensor's nominal rate exhausts its zram; keep its preview bounded.
frameRate: beatles ? 5 : 30,
cameras: beatles ? ['back'] : ['back','front']};
} catch (error) { this.close(); throw error; }
}
if (!this.session || this.session.actor !== actor || this.session.id !== params.session)
throw Error('CAMERA_NOT_OPEN');
if (!['frame','focus','zoom','picture','flash'].includes(params.action)) throw Error('CAMERA_INVALID_COMMAND');
let controlRejected = false;
try {
if (params.action === 'frame') {
await this.session.process.stdin.write('frame\n');
return await this.packet(this.session);
}
const command = JSON.stringify(params);
if (command.length>2048) throw Error('CAMERA_INVALID_COMMAND');
await this.session.process.stdin.write(command+'\n');
const reply = await this.packet(this.session, true);
if (!reply.ok) {
controlRejected = true;
throw Error(reply.error || 'CAMERA_CONTROL_FAILED');
}
if (params.action === 'picture') {
if (!Number.isInteger(reply.jpegLength) || reply.jpegLength<4 || reply.jpegLength>32*1024*1024)
throw Error('CAMERA_INVALID_JPEG');
return {data:await this.session.process.stdout.read(reply.jpegLength)};
}
return reply.result;
} catch (error) {
// A complete error reply leaves the protocol synchronized. A focus
// timeout must not discard a camera whose preview is still running.
if (!controlRejected) this.close();
throw error;
}
}
close() {
const session = this.session;
this.session = null;
if (!session) return this.closing;
session.process.stdin.close().catch(() => {});
const timer = setTimeout(() => session.process.kill(), 1500);
this.closing = session.process.wait().finally(() => clearTimeout(timer));
return this.closing;
}
cleanup(actor) {
if (this.session?.actor === actor) this.close();
}
}