VulpesOS/tundra/runtime/camera-preview.py
OverSu 1311bed5e9 Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source.

Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
2026-10-13 00:19:45 +02:00

280 lines
13 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Bounded libhybris preview worker. Frames stay in memory; no microphone access.
The parent owns stdin. EOF or five seconds without a request closes the sensor.
A pbuffer and external texture are required even for the software NV21 callback.
"""
import argparse
import fcntl
import struct
import ctypes as C
import json
import os
import select
import sys
import time
def function(lib, name, args, result=None):
value = getattr(lib, name)
value.argtypes, value.restype = args, result
return value
class Listener(C.Structure):
_fields_ = [(name, C.c_void_p) for name in
('error', 'shutter', 'focus', 'zoom', 'raw', 'jpeg', 'texture', 'context', 'preview')]
class Camera:
width, height = 640, 480
def __init__(self, index):
self.camera = None
self.display = self.surface = self.context = None
self.frame = None
self.jpeg = None
self.focus_done = False
self.count = self.errors = 0
self.texture_pending = False
os.environ['HYBRIS_EGLPLATFORM'] = 'null'
os.environ['__EGL_VENDOR_LIBRARY_FILENAMES'] = '/usr/share/glvnd/egl_vendor.d/10_libhybris.json'
self.egl = C.CDLL('libEGL.so.1')
self.gl = C.CDLL('libGLESv2.so.2')
self.lib = C.CDLL('libcamera.so.1')
P, I, U = C.c_void_p, C.c_int, C.c_uint
self.display = function(self.egl, 'eglGetDisplay', [P], P)(None)
if not function(self.egl, 'eglInitialize', [P, P, P], U)(self.display, None, None):
raise RuntimeError('CAMERA_EGL_INIT_FAILED')
attrs = (I * 13)(0x3033, 1, 0x3040, 4, 0x3024, 8, 0x3023, 8, 0x3022, 8, 0x3021, 8, 0x3038)
config, count = P(), I()
function(self.egl, 'eglChooseConfig', [P, P, P, I, P], U)(self.display, attrs, C.byref(config), 1, C.byref(count))
if not count.value:
raise RuntimeError('CAMERA_EGL_CONFIG_FAILED')
function(self.egl, 'eglBindAPI', [U], U)(0x30A0)
self.context = function(self.egl, 'eglCreateContext', [P, P, P, P], P)(self.display, config, None, (I * 3)(0x3098, 2, 0x3038))
self.surface = function(self.egl, 'eglCreatePbufferSurface', [P, P, P], P)(self.display, config, (I * 5)(0x3057, 16, 0x3056, 16, 0x3038))
if not function(self.egl, 'eglMakeCurrent', [P, P, P, P], U)(self.display, self.surface, self.surface, self.context):
raise RuntimeError('CAMERA_EGL_CONTEXT_FAILED')
texture = U()
function(self.gl, 'glGenTextures', [I, P])(1, C.byref(texture))
@C.CFUNCTYPE(None, P, U, P)
def preview(data, size, _context):
if size == self.width * self.height * 3 // 2:
self.frame = C.string_at(data, size)
self.count += 1
@C.CFUNCTYPE(None, P)
def error(_context):
self.errors += 1
@C.CFUNCTYPE(None, P)
def available(_context):
self.texture_pending = True
@C.CFUNCTYPE(None, P)
def focused(_context):
self.focus_done = True
@C.CFUNCTYPE(None, P, U, P)
def jpeg(data, size, _context):
if 0 < size <= 32 * 1024 * 1024:
self.jpeg = C.string_at(data, size)
self.callbacks = (preview, error, available, focused, jpeg)
self.listener = Listener()
for name, callback in [('preview', preview), ('error', error), ('texture', available), ('focus', focused), ('jpeg', jpeg)]:
setattr(self.listener, name, C.cast(callback, P).value)
self.camera = function(self.lib, 'android_camera_connect_by_id', [I, P], P)(index, C.byref(self.listener))
if not self.camera:
raise RuntimeError('CAMERA_CONNECT_FAILED')
facing, orientation = I(), I()
function(self.lib, 'android_camera_get_device_info', [I, P, P], I)(index, C.byref(facing), C.byref(orientation))
self.orientation = orientation.value
function(self.lib, 'android_camera_set_preview_size', [P, I, I])(self.camera, self.width, self.height)
function(self.lib, 'android_camera_set_preview_format', [P, I])(self.camera, 1) # YUV420SP / NV21
function(self.lib, 'android_camera_set_preview_texture', [P, I])(self.camera, texture.value)
function(self.lib, 'android_camera_set_preview_callback_mode', [P, I], I)(self.camera, 1)
function(self.lib, 'android_camera_start_preview', [P])(self.camera)
self.parameters = self.read_parameters()
self.picture_sizes = [tuple(map(int, size.split('x'))) for size in self.parameters.get('picture-size-values', '').split(',') if 'x' in size]
self.picture_size = max(self.picture_sizes, key=lambda size:size[0]*size[1])
self.zoom_ratios = [int(n)/100 for n in self.parameters.get('zoom-ratios', '100').split(',')]
def read_parameters(self):
# libhybris exposes the flattened parameters through a stdout dump.
# Capture it in RAM so it cannot corrupt the worker's binary protocol.
libc = C.CDLL(None)
fd = os.memfd_create('camera-parameters')
saved = os.dup(1)
try:
libc.fflush(None)
os.dup2(fd, 1)
function(self.lib, 'android_camera_dump_parameters', [C.c_void_p])(self.camera)
libc.fflush(None)
finally:
os.dup2(saved, 1)
os.close(saved)
try:
os.lseek(fd, 0, 0)
raw = os.read(fd, 65536).decode()
return dict(item.split('=', 1) for item in raw.strip().split(';') if '=' in item)
finally:
os.close(fd)
def metadata(self):
return {'ready': True, 'width': self.width, 'height': self.height,
'orientation': self.orientation, 'format': 'nv21',
'pictureSizes': [{'width':w,'height':h} for w,h in self.picture_sizes],
'focusModes': self.parameters.get('focus-mode-values', 'fixed').split(','),
'maxFocusAreas': min(1,int(self.parameters.get('max-num-focus-areas', 0))),
'zoomRatios': self.zoom_ratios}
def control(self, command):
P, I = C.c_void_p, C.c_int
action = command.get('action')
if action == 'zoom':
ratio = command.get('zoom')
if not isinstance(ratio, (int,float)) or not self.zoom_ratios[0] <= ratio <= self.zoom_ratios[-1]:
raise ValueError('CAMERA_INVALID_ZOOM')
index = min(range(len(self.zoom_ratios)), key=lambda i:abs(self.zoom_ratios[i]-ratio))
function(self.lib,'android_camera_set_zoom',[P,I])(self.camera,index)
actual = int(self.read_parameters().get('zoom',index))
if not 0 <= actual < len(self.zoom_ratios): raise RuntimeError('CAMERA_INVALID_ZOOM_REPLY')
return {'zoom':self.zoom_ratios[actual]}
if action == 'focus':
if 'auto' not in self.metadata()['focusModes']:
raise ValueError('CAMERA_FOCUS_UNAVAILABLE')
function(self.lib,'android_camera_stop_autofocus',[P])(self.camera)
function(self.lib,'android_camera_set_auto_focus_mode',[P,I])(self.camera,2)
area = command.get('area')
if area is not None:
values = [area.get(k) for k in ('top','left','bottom','right')]
if any(not isinstance(n,int) or not -1000 <= n <= 1000 for n in values) or values[0]>=values[2] or values[1]>=values[3]:
raise ValueError('CAMERA_INVALID_FOCUS_AREA')
if not self.metadata()['maxFocusAreas']:
raise ValueError('CAMERA_FOCUS_AREA_UNAVAILABLE')
region = (I*5)(*values,1000)
function(self.lib,'android_camera_set_focus_region',[P,P])(self.camera,region)
else:
function(self.lib,'android_camera_reset_focus_region',[P])(self.camera)
self.focus_done = False
function(self.lib,'android_camera_start_autofocus',[P])(self.camera)
deadline = time.monotonic()+4
while not self.focus_done and time.monotonic()<deadline:
self.tick(); time.sleep(.01)
if not self.focus_done:
raise RuntimeError('CAMERA_FOCUS_TIMEOUT')
return {'completed':True}
if action == 'picture':
size = command.get('size') or dict(zip(('width','height'),self.picture_size))
pair = (size.get('width'),size.get('height'))
if pair not in self.picture_sizes:
raise ValueError('CAMERA_INVALID_PICTURE_SIZE')
function(self.lib,'android_camera_set_picture_size',[P,I,I])(self.camera,*pair)
function(self.lib,'android_camera_set_jpeg_quality',[P,I])(self.camera,95)
function(self.lib,'android_camera_set_rotation',[P,I])(self.camera,self.orientation)
self.jpeg = None
try:
function(self.lib,'android_camera_take_snapshot',[P])(self.camera)
deadline = time.monotonic()+12
while self.jpeg is None and time.monotonic()<deadline:
self.tick(); time.sleep(.01)
if self.jpeg is None:
raise RuntimeError('CAMERA_CAPTURE_TIMEOUT')
return self.jpeg
finally:
function(self.lib,'android_camera_start_preview',[P])(self.camera)
raise ValueError('CAMERA_INVALID_COMMAND')
def tick(self):
if self.texture_pending:
self.texture_pending = False
function(self.lib, 'android_camera_update_preview_texture', [C.c_void_p])(self.camera)
def close(self):
P = C.c_void_p
if self.camera:
function(self.lib, 'android_camera_stop_preview', [P])(self.camera)
function(self.lib, 'android_camera_disconnect', [P])(self.camera)
self.camera = None
if self.display:
function(self.egl, 'eglMakeCurrent', [P, P, P, P], C.c_uint)(self.display, None, None, None)
if self.surface:
function(self.egl, 'eglDestroySurface', [P, P], C.c_uint)(self.display, self.surface)
if self.context:
function(self.egl, 'eglDestroyContext', [P, P], C.c_uint)(self.display, self.context)
function(self.egl, 'eglTerminate', [P], C.c_uint)(self.display)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--camera', type=int, choices=(0, 1), default=0)
parser.add_argument('--probe', action='store_true', help='Count frames for five seconds, retain no images')
args = parser.parse_args()
output = os.fdopen(os.dup(1), 'wb', buffering=0)
try:
# A preview packet is 450 KiB. A larger pipe avoids waking the parent
# for each 64 KiB fragment; unsupported kernels retain their default.
fcntl.fcntl(output.fileno(), fcntl.F_SETPIPE_SZ, 1024 * 1024)
except OSError:
pass
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
camera = None
try:
camera = Camera(args.camera)
started = last_request = time.monotonic()
if not args.probe:
metadata = json.dumps(camera.metadata()).encode()
output.write(struct.pack('<I', len(metadata)) + metadata)
output.flush()
while time.monotonic() - last_request < 5:
camera.tick()
if args.probe:
time.sleep(.01)
continue
if not select.select([sys.stdin], [], [], .01)[0]:
continue
command = sys.stdin.readline(4096)
if not command or len(command)>=4096:
break
last_request = time.monotonic()
if command == 'frame\n':
data = camera.frame or b''
packet = memoryview(struct.pack('<II', camera.count & 0xffffffff, len(data)) + data)
while packet:
written = output.write(packet)
if not written:
raise BrokenPipeError('Camera preview pipe closed')
packet = packet[written:]
else:
try:
result = camera.control(json.loads(command))
if isinstance(result,bytes):
data = json.dumps({'ok':True,'jpegLength':len(result)}).encode()
else:
data = json.dumps({'ok':True,'result':result}).encode()
except (ValueError,RuntimeError,TypeError,AttributeError) as error:
result = None
data = json.dumps({'ok':False,'error':str(error)}).encode()
output.write(struct.pack('<I',len(data))+data)
if isinstance(result,bytes):
output.write(result)
output.flush()
last_request = time.monotonic()
if args.probe:
output.write((json.dumps({'camera': args.camera, 'frames': camera.count, 'errors': camera.errors,
'width': camera.width, 'height': camera.height, 'orientation': camera.orientation,
'seconds': round(time.monotonic() - started, 2), 'imagesRetained': False})+'\n').encode())
if not camera.count or camera.errors:
return 1
return 0
finally:
if camera:
camera.close()
if __name__ == '__main__':
sys.exit(main())