VulpesOS/host/gaia-camera.js
2026-11-04 12:52:20 +01:00

802 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here.
(() => {
if (location.hostname !== 'camera.localhost') return;
const supportedMime = candidates => typeof MediaRecorder !== 'undefined' &&
typeof MediaRecorder.isTypeSupported === 'function' ?
candidates.find(type => MediaRecorder.isTypeSupported(type)) || null : null;
const videoOnlyMime = supportedMime([
'video/webm;codecs=vp8', 'video/webm;codecs=vp9', 'video/webm'
]);
const videoAudioMime = supportedMime([
'video/webm;codecs=vp8,opus', 'video/webm;codecs=vp9,opus'
]);
const nativeCaptureAvailable = window.VulpesPlatform !== 'tundra' ||
typeof HTMLCanvasElement.prototype.captureStream === 'function';
const videoMime = nativeCaptureAvailable ? videoOnlyMime : null;
window.VulpesVideoExtension = videoMime ? 'webm' : null;
let cameras = ['back'],
devices = new Map(),
current = null,
generation = 0;
let cameraError = null;
let opening = Promise.resolve(), closing = Promise.resolve();
const releases = new WeakMap();
const nativeViews = new WeakMap();
function releaseStream(stream, preview) {
if (releases.has(stream)) return releases.get(stream);
if (current === stream) current = null;
const release = Promise.resolve().then(async () => {
await stream.vulpesFinishRecording?.();
emit(stream, 'previewstatechange', {newState:'stopped'});
// Stop the capture producer before detaching its consumers. Gecko can
// otherwise block in srcObject=null while a GPU-backed frame is in use.
stream.getTracks().forEach(track => track.stop());
for (const video of document.querySelectorAll('video')) {
if (nativeViews.get(video) === stream) {
nativeViews.delete(video);
stream.vulpesPreviewCanvas.remove();
video.style.display = '';
}
if (video.srcObject === stream) {
video.pause(); video.srcObject = null;
if ('vulpesCapture' in video.dataset) video.remove();
}
}
preview?.remove();
try { await stream.vulpesCameraClose?.(); }
finally { stream.getTracks().forEach(track => track.stop()); }
});
releases.set(stream, release);
closing = release.catch(recordError);
return release;
}
function recordError(error) {
cameraError = { name: error.name || 'Error', message: error.message || '' };
console.warn('Vulpes Camera:', cameraError.name, cameraError.message);
}
function renderError() {
const node = document.querySelector('.overlay[data-type="request-fail"] #overlay-text');
if (!node || !cameraError) return;
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
const messages = {
NotAllowedError: [
'Accès à la caméra refusé. Autorisez Photo à utiliser la caméra dans les permissions du système ou du navigateur.',
'Camera access denied. Allow Camera to use the device in system or browser permissions.',
],
NotSupportedError: [
'L’accès au capteur photo n’est pas encore disponible sur ce modèle.',
'Access to the camera sensor is not yet available on this model.',
],
NotFoundError: [
'Aucune caméra détectée. Vérifiez son branchement.',
'No camera detected. Check its connection.',
],
NotReadableError: [
'Impossible de démarrer la caméra. Elle peut être occupée, ou son accès matériel a échoué.',
'Unable to start the camera. It may be busy, or hardware access has failed.',
],
OverconstrainedError: [
'Le format demandé n’est pas disponible sur cette caméra.',
'The requested format is not available on this camera.',
],
AbortError: [
'L’ouverture de la caméra a été interrompue. Revenez dans Photo pour réessayer.',
'Camera startup was interrupted. Return to Camera to try again.',
],
};
const text = (messages[cameraError.name] || [
'Impossible d’ouvrir la caméra.',
'Unable to open the camera.',
])[fr ? 0 : 1];
node.removeAttribute('data-l10n-id');
if (node.textContent !== text) node.textContent = text;
}
new MutationObserver(renderError).observe(document.documentElement, {
subtree: true,
childList: true,
});
addEventListener('localized', renderError);
const nativeHidden = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden').get;
let frameVisible = true;
Object.defineProperty(document, 'hidden', {
get: () => !frameVisible || nativeHidden.call(document),
});
Object.defineProperty(document, 'visibilityState', {
get: () => (document.hidden ? 'hidden' : 'visible'),
});
addEventListener('message', (event) => {
if (
event.source !== parent ||
event.origin !== 'http://system.localhost:' + location.port ||
event.data?.type !== 'vulpes-camera-visibility'
)
return;
const visible = event.data.visible === true;
if (visible === frameVisible) return;
frameVisible = visible;
document.dispatchEvent(new Event('visibilitychange', { bubbles: true }));
});
const nativeSource = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'srcObject');
Object.defineProperty(HTMLMediaElement.prototype, 'mozSrcObject', {
configurable: true,
get() {
return nativeViews.get(this) || nativeSource.get.call(this);
},
set(stream) {
const old = nativeViews.get(this);
if (old) {
old.vulpesPreviewCanvas.remove();
nativeViews.delete(this);
this.style.display = '';
}
if (stream?.vulpesPreviewCanvas) {
// The HAL already delivers NV21 frames. Render them directly in Gaia's
// viewfinder instead of capturing the WebGL canvas into a second GPU
// video pipeline, which stalls on repeated switches on Sargo.
nativeViews.set(this, stream);
const canvas = stream.vulpesPreviewCanvas;
canvas.className = this.className;
canvas.dataset.vulpesNativePreview = '';
canvas.style.cssText = 'display:block;width:100%;height:100%;pointer-events:none';
this.style.display = 'none';
this.parentNode.append(canvas);
} else nativeSource.set.call(this, stream);
},
});
const playVideo = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
return nativeViews.has(this) ? Promise.resolve() : playVideo.call(this);
};
function emit(target, type, detail = {}) {
const event = new Event(type);
Object.assign(event, detail);
target.dispatchEvent(event);
}
async function discover(stream, name) {
if (stream.vulpesNative) {
cameras = stream.vulpesCameraMetadata.cameras || ['back', 'front'];
return;
}
// Device enumeration may wait indefinitely for focus in embedded desktop contexts.
// The already-open stream remains usable even when no device list is exposed.
let timeout;
const entries = await Promise.race([
navigator.mediaDevices.enumerateDevices(),
new Promise((resolve) => (timeout = setTimeout(() => resolve([]), 1500))),
]).finally(() => clearTimeout(timeout));
const list = entries.filter((d) => d.kind === 'videoinput');
const settings = stream.getVideoTracks()[0].getSettings();
const active = list.find((d) => d.deviceId === settings.deviceId) || list[0];
const back = list.find((d) => /back|rear|environment/i.test(d.label));
const front = list.find((d) => /front|user|face/i.test(d.label));
// Unlabelled webcams must not exchange identities every time we switch.
const previousBack = list.find(d => d.deviceId === devices.get('back'));
const previousFront = list.find(d => d.deviceId === devices.get('front'));
const first = back || previousBack ||
((settings.facingMode === 'user' || name === 'front') && list.length > 1
? list.find(d => d !== active) : active);
devices = new Map();
if (first) devices.set('back', first.deviceId);
const second = [front, previousFront].find(d => d && d !== first) || list.find(d => d !== first);
if (second) devices.set('front', second.deviceId);
cameras = devices.size ? [...devices.keys()] : ['back'];
}
// Keep one graphics context per app. Losing/recreating it at every lens switch
// can exhaust the mobile driver while captured video frames are still in use.
let nativeCanvas = null;
async function nativeStream(name) {
let session;
const call = (params) => VulpesCompat.call('platform.cameraPreview', {...params, session});
const meta = await call({action:'start', camera:name === 'front' ? 'front' : 'back'});
session = meta.session;
const frameRate = meta.frameRate || 30;
const canvas = nativeCanvas || (nativeCanvas = document.createElement('canvas'));
const sideways = meta.orientation % 180 !== 0;
const width = sideways ? meta.height : meta.width;
const height = sideways ? meta.width : meta.height;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
// Prefer the GPU converter, but older feature-phone runtimes may expose no
// WebGL context at all. Their 320x240 preview is small enough for 2D canvas.
const gl = canvas.getContext('webgl', {alpha:false, antialias:false, preserveDrawingBuffer:true});
const context2d = gl ? null : canvas.getContext('2d', {alpha:false});
if (!gl && !context2d) {
await call({action:'stop'});
throw new DOMException('Camera preview has no supported canvas renderer', 'NotSupportedError');
}
const rawCanvas = context2d ? document.createElement('canvas') : null;
if (rawCanvas) { rawCanvas.width=meta.width; rawCanvas.height=meta.height; }
const rawContext = rawCanvas?.getContext('2d', {alpha:false});
const imageData = rawContext?.createImageData(meta.width,meta.height);
const pixels = meta.width * meta.height;
const timing = {frames:0, startedAt:performance.now(), conversionMs:0, transferMs:0, sensorFrames:0};
let stopped = false, timer, stream, wakeLock, sequence = -1, closing, program, buffer;
const textures = [], shaders = [];
const stop = () => {
if (closing) return closing;
stopped = true; clearTimeout(timer);
wakeLock?.unlock(); wakeLock = null;
closing = call({action:'stop'}).finally(() => {
if (stream) stream.vulpesClosed = true;
if (gl) {
textures.forEach(texture => gl.deleteTexture(texture));
shaders.forEach(value => gl.deleteShader(value));
if (buffer) gl.deleteBuffer(buffer);
if (program) gl.deleteProgram(program);
// The next lens reuses this context after its old stream has stopped.
gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT);
} else context2d.clearRect(0,0,canvas.width,canvas.height);
});
return closing;
};
try {
function shader(type, source) {
const value = gl.createShader(type);
shaders.push(value);
gl.shaderSource(value, source); gl.compileShader(value);
if (!gl.getShaderParameter(value, gl.COMPILE_STATUS)) throw Error('Camera shader compilation failed');
return value;
}
if (gl) {
program = gl.createProgram();
gl.attachShader(program, shader(gl.VERTEX_SHADER, `
attribute vec2 position; varying vec2 coord;
void main() { gl_Position=vec4(position,0.,1.); coord=vec2(position.x*.5+.5,.5-position.y*.5); }
`));
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, `
precision mediump float; varying vec2 coord;
uniform sampler2D luma; uniform sampler2D chroma; uniform float rotation;
void main() {
vec2 p=coord;
if(rotation==90.) p=vec2(coord.y,1.-coord.x);
else if(rotation==270.) p=vec2(1.-coord.y,coord.x);
else if(rotation==180.) p=1.-coord;
float y=1.16438356*(texture2D(luma,p).r-16./255.);
vec4 vu=texture2D(chroma,p);
float v=vu.r-128./255.; float u=vu.a-128./255.;
gl_FragColor=vec4(y+1.59602678*v,y-.39176229*u-.81296764*v,y+2.01723214*u,1.);
}
`));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw Error('Camera shader link failed');
gl.useProgram(program);
buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER,buffer);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
const position=gl.getAttribLocation(program,'position');
gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
gl.uniform1f(gl.getUniformLocation(program,'rotation'),meta.orientation);
['luma','chroma'].forEach((name,index) => {
const texture=gl.createTexture();textures.push(texture);gl.activeTexture(gl.TEXTURE0+index);gl.bindTexture(gl.TEXTURE_2D,texture);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
gl.uniform1i(gl.getUniformLocation(program,name),index);
});
gl.viewport(0,0,canvas.width,canvas.height);
gl.activeTexture(gl.TEXTURE0);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE,meta.width,meta.height,0,gl.LUMINANCE,gl.UNSIGNED_BYTE,null);
gl.activeTexture(gl.TEXTURE1);
gl.texImage2D(gl.TEXTURE_2D,0,gl.LUMINANCE_ALPHA,meta.width/2,meta.height/2,0,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,null);
}
const draw = async () => {
const requested = performance.now();
const frame = await call({action:'frame'});
timing.transferMs += performance.now()-requested;
if (stopped || !frame.data || frame.sequence === sequence) return false;
const data = new Uint8Array(frame.data), w = meta.width, h = meta.height;
if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
sequence = frame.sequence; timing.sensorFrames = sequence;
const start=performance.now();
if (gl) {
gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D,textures[0]);
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w,h,gl.LUMINANCE,gl.UNSIGNED_BYTE,data.subarray(0,pixels));
gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D,textures[1]);
gl.texSubImage2D(gl.TEXTURE_2D,0,0,0,w/2,h/2,gl.LUMINANCE_ALPHA,gl.UNSIGNED_BYTE,data.subarray(pixels));
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
} else {
const rgba=imageData.data;
for(let y=0;y<h;y++) {
const row=y*w,uv=pixels+(y>>1)*w;
for(let x=0;x<w;x++) {
const yy=Math.max(0,data[row+x]-16),pair=uv+(x&~1);
const v=data[pair]-128,u=data[pair+1]-128,c=298*yy,offset=(row+x)*4;
rgba[offset]=Math.max(0,Math.min(255,(c+409*v+128)>>8));
rgba[offset+1]=Math.max(0,Math.min(255,(c-100*u-208*v+128)>>8));
rgba[offset+2]=Math.max(0,Math.min(255,(c+516*u+128)>>8));
rgba[offset+3]=255;
}
}
rawContext.putImageData(imageData,0,0);
context2d.save();
if(meta.orientation===90){context2d.translate(canvas.width,0);context2d.rotate(Math.PI/2);}
else if(meta.orientation===180){context2d.translate(canvas.width,canvas.height);context2d.rotate(Math.PI);}
else if(meta.orientation===270){context2d.translate(0,canvas.height);context2d.rotate(-Math.PI/2);}
context2d.drawImage(rawCanvas,0,0);context2d.restore();
}
timing.frames++;timing.conversionMs+=performance.now()-start;
return true;
};
const deadline = Date.now() + 6000;
while (!(await draw())) {
if (Date.now() > deadline) throw new DOMException('No camera frames', 'NotReadableError');
await new Promise(resolve => setTimeout(resolve, 50));
}
stream = new MediaStream();
stream.vulpesPreviewCanvas = canvas;
stream.vulpesClosed = false;
// A canvas has no video element's implicit screen wake lock.
wakeLock = navigator.requestWakeLock?.('screen');
stream.vulpesTiming = timing;
stream.vulpesNative = true;
stream.vulpesCameraMetadata = meta;
stream.vulpesCameraControl = call;
stream.vulpesCameraClose = stop;
for (const track of stream.getTracks()) {
const originalStop = track.stop.bind(track);
track.stop = () => { stop().catch(recordError); originalStop(); };
}
const next = async () => {
if (stopped) return;
const started = performance.now();
try { await draw(); }
catch (error) {
if (!stopped) { recordError(error); await stop().catch(recordError); }
return;
}
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/frameRate-(performance.now()-started)));
};
timer = setTimeout(next, 1000/frameRate);
return stream;
} catch (error) { await stop().catch(() => {}); throw error; }
}
async function captureVideo(constraints, ticket, platform) {
// track.stop() returns before some Android camera drivers finish closing.
// Retry the same sensor, never replace a refused/missing camera silently.
for (let attempt = 0; ; attempt++) {
if (document.hidden || ticket !== generation)
throw new DOMException('Camera request superseded or hidden', 'AbortError');
try { return await navigator.mediaDevices.getUserMedia(constraints); }
catch (error) {
if (platform !== 'android' || error.name !== 'NotReadableError' || attempt >= 2) throw error;
await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
}
}
}
function getCamera(name, config = {}) {
const ticket = ++generation;
const request = opening.then(() => openCamera(name, config, ticket));
opening = request.catch(() => {});
return request;
}
async function openCamera(name, config, ticket) {
if (config.mode && !['picture', ...(videoMime ? ['video'] : [])].includes(config.mode))
throw new DOMException('Unsupported capture mode', 'NotSupportedError');
if (current) await releaseStream(current);
await closing;
if (document.hidden || ticket !== generation)
throw new DOMException('Camera request superseded or hidden', 'AbortError');
const selected = devices.get(name);
cameraError = null;
let stream;
try {
const platform = await VulpesCompat.call('platform.capabilities', {});
if (platform.platform === 'tundra' && !platform.camera)
throw new DOMException('Native camera adapter unavailable', 'NotSupportedError');
stream = platform.platform === 'tundra' ? await nativeStream(name) : await captureVideo({
audio: false,
video: {
...(selected
? { deviceId: { exact: selected } }
: { facingMode: { ideal: name === 'front' ? 'user' : 'environment' } }),
width: { ideal: 1920 },
height: { ideal: 1080 },
},
}, ticket, platform.platform);
} catch (error) {
recordError(error);
throw error;
}
if (document.hidden || ticket !== generation) {
await releaseStream(stream);
const error = new DOMException('Camera hidden', 'AbortError');
recordError(error);
throw error;
}
current = stream;
let preview;
try {
await discover(stream, name);
if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError');
const track = stream.getVideoTracks()[0];
const native = stream.vulpesCameraMetadata;
let size;
if (native) {
// Preview dimensions and still photos come directly from the HAL.
// The native canvas needs no hidden capture video.
const sideways = native.orientation % 180 !== 0;
size = {width:sideways ? native.height : native.width,
height:sideways ? native.width : native.height};
} else {
preview = document.createElement('video');
preview.muted = true;
preview.playsInline = true;
preview.srcObject = stream;
preview.dataset.vulpesCapture = '';
preview.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none';
document.body.append(preview);
let previewTimeout;
await Promise.race([
preview.play(),
new Promise((_, reject) => {
previewTimeout = setTimeout(
() => reject(new DOMException('Camera preview timed out', 'NotReadableError')),
10000,
);
}),
]).finally(() => clearTimeout(previewTimeout));
const settings = stream.getVideoTracks()[0].getSettings();
size = {width:preview.videoWidth || settings.width,
height:preview.videoHeight || settings.height};
}
if (document.hidden || ticket !== generation)
throw new DOMException('Camera hidden', 'AbortError');
const pictureSizes = native?.pictureSizes || [size];
let pictureSize = pictureSizes.reduce((a,b)=>a.width*a.height>b.width*b.height?a:b);
let flashMode = 'off', flashUpdate = Promise.resolve();
let focusArea = null, zoom = 1, mode = config.mode || 'picture', recording, startingRecording = false;
const frameRate = native?.frameRate || 30;
const profile = {containerFormat:'webm',video:{width:size.width,height:size.height,codec:videoMime?.includes('vp9')?'vp9':'vp8',bitrate:frameRate < 10 ? 500000 : 1500000,framerate:frameRate},audio:{codec:'opus',samplerate:48000,channels:1,bitrate:64000}};
const configuration = () => ({
mode,
previewSize: size,
pictureSize,
recorderProfile: videoMime ? 'webm' : null,
});
Object.assign(stream, {
capabilities: {
pictureSizes,
previewSizes: [size],
thumbnailSizes: [],
recorderProfiles: videoMime ? {webm:profile} : {},
flashModes: native?.flashModes?.length ? native.flashModes : ['off'],
focusModes: native ? (native.focusModes.includes('auto') ? ['auto','infinity'] : ['fixed']) : ['fixed'],
sceneModes: ['auto'],
whiteBalanceModes: ['auto'],
isoModes: ['auto'],
zoomRatios: native?.zoomRatios || [1],
maxFocusAreas: native?.maxFocusAreas || 0,
maxMeteringAreas: 0,
maxDetectedFaces: 0,
},
resumeContinuousFocus() {},
sensorAngle: 0,
focusMode: 'fixed',
flashMode: 'off',
zoom: 1,
async setConfiguration(value) {
if (value.pictureSize) stream.setPictureSize(value.pictureSize);
if (value.mode && !['picture',...(videoMime ? ['video'] : [])].includes(value.mode))
throw new DOMException('Video unavailable', 'NotSupportedError');
if(value.mode)mode=value.mode;
queueMicrotask(() => emit(stream, 'previewstatechange', { newState: 'started' }));
return configuration();
},
setPictureSize(value) {
const selected = pictureSizes.find(s=>s.width===value.width && s.height===value.height);
if (!selected) throw new DOMException('Unsupported picture size','NotSupportedError');
pictureSize = selected;
},
getPictureSize: () => pictureSize,
setFocusAreas(areas) {
focusArea = areas?.[0] || null;
},
setMeteringAreas() {},
async autoFocus() {
if (!native?.focusModes.includes('auto')) return;
emit(stream,'focus',{newState:'focusing'});
let area = null;
if (focusArea) {
const rotate = (x,y) => native.orientation===90 ? [y,-x] :
native.orientation===270 ? [-y,x] : native.orientation===180 ? [-x,-y] : [x,y];
const points = [rotate(focusArea.left,focusArea.top),rotate(focusArea.right,focusArea.bottom)];
const clamp = n=>Math.max(-1000,Math.min(1000,Math.round(n)));
area={left:clamp(Math.min(...points.map(p=>p[0]))),right:clamp(Math.max(...points.map(p=>p[0]))),
top:clamp(Math.min(...points.map(p=>p[1]))),bottom:clamp(Math.max(...points.map(p=>p[1])))};
}
try {
await stream.vulpesCameraControl({action:'focus',area});
emit(stream,'focus',{newState:'focused'});
} catch(error) {emit(stream,'focus',{newState:'failed'});throw error;}
},
setThumbnailSize() {},
async takePicture() {
await flashUpdate;
if (native) {
const result=await stream.vulpesCameraControl({action:'picture',size:pictureSize});
emit(stream,'shutter');
return new Blob([result.data],{type:'image/jpeg'});
}
if (track.readyState !== 'live' || !preview.videoWidth)
throw new DOMException('Camera inactive', 'InvalidStateError');
const canvas = document.createElement('canvas');
canvas.width = preview.videoWidth;
canvas.height = preview.videoHeight;
canvas.getContext('2d').drawImage(preview, 0, 0);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) throw new Error('JPEG capture failed');
emit(stream, 'shutter');
return blob;
},
resumePreview() {
emit(stream, 'previewstatechange', { newState: 'started' });
},
release() {
return releaseStream(stream, preview);
},
async startRecording(options, storage, filepath) {
if(!videoMime)throw new DOMException('Video unavailable','NotSupportedError');
if(recording || startingRecording)throw new DOMException('Recording in progress','InvalidStateError');
startingRecording=true;
let audio = null, captured, copyTimer, recordingCanvas, recordingContext;
try {
// Video capture can still be useful when audio capture is unavailable.
// Camera track failures remain fatal; only the optional audio track falls back.
try {
audio=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
} catch(error) {
// Tundra may expose the camera through its HAL while Gecko has no
// microphone capture backend. Audio is optional: that must not
// prevent the video encoder from starting.
console.info('Vulpes Camera: microphone unavailable; recording video without audio', error.name || error);
}
if(current!==stream || document.hidden)throw new DOMException('Camera closed','AbortError');
// Only recording captures the native canvas. The preview keeps its
// direct WebGL surface and the capture track is stopped before a switch.
if(native) {
// Gecko 156 on the Flip does not emit MediaRecorder data from a
// WebGL captureStream. Copy the already-rendered sensor preview
// into a small 2D canvas at the device's bounded frame rate.
const source=stream.vulpesPreviewCanvas;
recordingCanvas=document.createElement('canvas');
recordingCanvas.width=source.width;recordingCanvas.height=source.height;
recordingContext=recordingCanvas.getContext('2d',{alpha:false});
if(!recordingContext)throw new DOMException('Video canvas unavailable','NotSupportedError');
captured=recordingCanvas.captureStream(frameRate);
} else captured=new MediaStream(stream.getVideoTracks().map(t=>t.clone()));
for(const track of audio?.getAudioTracks() || [])captured.addTrack(track);
const recorderMime=audio && videoAudioMime ? videoAudioMime : videoOnlyMime;
if (audio && !videoAudioMime) {
audio.getTracks().forEach(track => track.stop());
audio = null;
}
const recorderOptions={mimeType:recorderMime,
videoBitsPerSecond:profile.video.bitrate,
...(audio ? {audioBitsPerSecond:profile.audio.bitrate} : {})};
let recorder;
try {
recorder=new MediaRecorder(captured,recorderOptions);
} catch(error) {
if(!audio || !videoAudioMime)throw error;
// Some encoders report Opus support but fail to initialize it
// for a live device stream. Retry video-only instead of losing
// the entire recording.
audio.getTracks().forEach(track=>track.stop());audio=null;
captured.getAudioTracks().forEach(track=>captured.removeTrack(track));
recorder=new MediaRecorder(captured,{mimeType:videoOnlyMime,
videoBitsPerSecond:profile.video.bitrate});
}
const chunks=[];let bytes=0,failed=false,finish,sizeLimitTimer;
const done=new Promise(resolve=>{finish=resolve;});
const cap=Math.min(options.maxFileSizeBytes || Infinity,32*1024*1024);
recorder.ondataavailable=event=>{
if(event.data.size) {chunks.push(event.data);bytes+=event.data.size;}
if(bytes>=cap && recorder.state==='recording') {
emit(stream,'recorderstatechange',{newState:'FileSizeLimitReached'});
recorder.stop();
}
};
recorder.onerror=()=>{failed=true;emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});};
recorder.onstop=async()=>{
clearTimeout(sizeLimitTimer);
try {
if(failed || !bytes)throw Error('Empty or failed recording');
const blob=new Blob(chunks,{type:recorder.mimeType || recorderMime || 'video/webm'});
if(blob.size>cap)throw new Error('Video exceeds the available storage limit');
await storage.addNamed(blob,filepath);
const canvas=document.createElement('canvas');
const source=native ? stream.vulpesPreviewCanvas : preview;
canvas.width=native ? source.width : preview.videoWidth;
canvas.height=native ? source.height : preview.videoHeight;
canvas.getContext('2d').drawImage(source,0,0,canvas.width,canvas.height);
const poster=await new Promise(resolve=>canvas.toBlob(resolve,'image/jpeg',0.9));
if(!poster)throw Error('Video poster failed');
emit(stream,'poster',{data:poster});
} catch(error) {
recordError(error);emit(stream,'recorderstatechange',{newState:'MediaRecorderFailed'});
} finally {
clearTimeout(copyTimer);
captured.getTracks().forEach(t=>t.stop());
recordingCanvas?.remove();
chunks.length=0;recording=null;finish();
emit(stream,'recorderstatechange',{newState:'Stopped'});
}
};
recording={recorder,done};
// Gecko 156's ARM/feature-phone media recorder can enter the
// inactive state without firing dataavailable/onstop when a
// timeslice is supplied. Collect the final chunk on stop instead.
recorder.start();
if(recordingCanvas) {
const track=captured.getVideoTracks()[0];
const copyFrame=()=>{
if(recorder.state!=='recording')return;
try {recordingContext.drawImage(stream.vulpesPreviewCanvas,0,0,recordingCanvas.width,recordingCanvas.height);track.requestFrame?.();}
catch(error) {recordError(error);}
if(recorder.state==='recording')copyTimer=setTimeout(copyFrame,Math.max(40,1000/frameRate));
};
recordingContext.drawImage(stream.vulpesPreviewCanvas,0,0,recordingCanvas.width,recordingCanvas.height);
track.requestFrame?.();
copyTimer=setTimeout(copyFrame,Math.max(40,1000/frameRate));
}
if(Number.isFinite(cap)) {
const bitrate=Math.max(1,profile.video.bitrate||500000);
sizeLimitTimer=setTimeout(()=>{
if(recorder.state==='recording') {
emit(stream,'recorderstatechange',{newState:'FileSizeLimitReached'});
recorder.stop();
}
},Math.max(1000,Math.floor(cap*8/bitrate*1000*.95)));
}
emit(stream,'recorderstatechange',{newState:'Started'});
} catch(error) {
clearTimeout(copyTimer);
captured?.getTracks().forEach(t=>t.stop());audio?.getTracks().forEach(t=>t.stop());
recordingCanvas?.remove();
recording=null;throw error;
} finally {startingRecording=false;}
},
stopRecording() {
if(recording?.recorder.state==='recording')recording.recorder.stop();
},
async vulpesFinishRecording() {
if(!recording)return;
const done=recording.done;stream.stopRecording();await done;
},
});
if (native) Object.defineProperty(stream,'flashMode',{
configurable:true, get:()=>flashMode,
set(value) {
if (!stream.capabilities.flashModes.includes(value) || value===flashMode) return;
const previous=flashMode;
flashMode=value;
flashUpdate=flashUpdate.catch(()=>{}).then(()=>stream.vulpesCameraControl({action:'flash',mode:value}));
flashUpdate.catch(error=>{if(flashMode===value)flashMode=previous;recordError(error);});
}
});
if (native) Object.defineProperty(stream,'zoom',{
configurable:true, get:()=>zoom,
set(value) {
if (!Number.isFinite(value)) return;
const ratios=native.zoomRatios;
const nearest=ratios.reduce((a,b)=>Math.abs(a-value)<Math.abs(b-value)?a:b);
zoom=nearest;
stream.vulpesCameraControl({action:'zoom',zoom:nearest}).then(result=>{zoom=result.zoom;})
.catch(recordError);
}
});
dispatchEvent(
new CustomEvent('vulpes-camera-capabilities', {
detail: { cameras: [...cameras], flash: stream.capabilities.flashModes.length > 1, video: !!videoMime },
}),
);
return { camera: stream, configuration: configuration() };
} catch (error) {
recordError(error);
await releaseStream(stream, preview).catch(recordError);
throw error;
}
}
Object.defineProperty(navigator, 'mozCameras', {
value: { getListOfCameras: () => [...cameras], getCamera },
});
const stop = () => {
// A permission dialog can hide the document before any stream exists.
// Keep that request valid; its completion still checks visibility.
if (!current) return;
++generation;
releaseStream(current).catch(recordError);
};
addEventListener('pagehide', stop);
addEventListener('visibilitychange', () => {
if (document.hidden) stop();
});
// Preserve familiar positions. Unavailable controls remain visible, labelled and inert.
function updateControls() {
const app = window.app;
if (!app || !document.querySelector('.hud')) return;
app.camera.cameraList = [...cameras];
app.settings.cameras.filterOptions(cameras);
document.querySelector('.hud').setAttribute('camera-enabled', String(cameras.length > 1));
const controls = [
[
'.js-flash',
!current || current.capabilities?.flashModes?.length < 2,
'Flash indisponible dans cette Preview',
'Flash unavailable in this Preview',
'flash-off',
],
[
'.js-camera',
cameras.length < 2,
'Une seule caméra disponible',
'Only one camera available',
'toggle-camera-rear',
],
['.mode-switch', !videoMime, 'Vidéo pas encore disponible', 'Video is not available yet', null],
];
const fr = (document.documentElement.lang || navigator.language).startsWith('fr');
for (const [selector, disabled, french, english, icon] of controls) {
const e = document.querySelector(selector);
if (!e) continue;
e.classList.toggle('vulpes-camera-unavailable', disabled);
e.setAttribute('aria-disabled', String(disabled));
if (!disabled) {
e.removeAttribute('title');
if(selector==='.js-camera')e.setAttribute('aria-label', fr ? 'Changer de caméra' : 'Switch camera');
}
if (disabled) {
e.removeAttribute('data-l10n-id');
e.setAttribute('aria-label', fr ? french : english);
e.title = fr ? french : english;
for (const child of e.querySelectorAll('[role=button]')) {
child.setAttribute('aria-disabled', 'true');
child.removeAttribute('data-l10n-id');
child.setAttribute('aria-label', e.title);
}
if (icon) e.dataset.icon = icon;
}
}
}
let bound = false;
const binding = setInterval(() => {
if (!window.app || !document.querySelector('.hud') || bound) return;
bound = true;
clearInterval(binding);
app.on('settings:configured', updateControls);
app.on('localized', updateControls);
updateControls();
}, 100);
addEventListener('pagehide', () => clearInterval(binding));
addEventListener('vulpes-camera-capabilities', updateControls);
// A pinch belongs to the viewfinder, never to the surrounding Gaia document.
addEventListener('touchmove', event => {
if (event.touches.length > 1) event.preventDefault();
}, {passive:false});
addEventListener(
'click',
(event) => {
if (event.target.closest?.('.vulpes-camera-unavailable')) {
event.preventDefault();
event.stopImmediatePropagation();
}
},
true,
);
for (const name of ['touchstart', 'touchmove', 'touchend', 'pointerdown'])
addEventListener(
name,
(event) => {
if (event.target.closest?.('.vulpes-camera-unavailable')) {
event.preventDefault();
event.stopImmediatePropagation();
}
},
{ capture: true, passive: false },
);
})();