119 lines
5.1 KiB
JavaScript
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();
|
|
}
|
|
}
|