Release Vulpes OS 2.7-preview.24

This commit is contained in:
OverSu 2026-11-04 12:52:20 +01:00
commit 97ef99f827
135 changed files with 7717 additions and 2524 deletions

View file

@ -1,7 +1,18 @@
// The Gaia camera UI consumes mozCameras. Only live MediaStream capabilities are exposed here.
(() => {
if (location.hostname !== 'camera.localhost') return;
const videoMime = typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus') ? 'video/webm;codecs=vp8,opus' : null;
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(),
@ -143,7 +154,7 @@
}
async function discover(stream, name) {
if (stream.vulpesNative) {
cameras = ['back', 'front'];
cameras = stream.vulpesCameraMetadata.cameras || ['back', 'front'];
return;
}
// Device enumeration may wait indefinitely for focus in embedded desktop contexts.
@ -178,19 +189,26 @@
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;
// Convert NV21 in a shader instead of blocking Gaia's UI with one JS loop
// per pixel. The two textures contain Y and interleaved V/U respectively.
// 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});
if (!gl) {
const context2d = gl ? null : canvas.getContext('2d', {alpha:false});
if (!gl && !context2d) {
await call({action:'stop'});
throw new DOMException('Camera preview requires WebGL', 'NotSupportedError');
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 = [];
@ -200,12 +218,14 @@
wakeLock?.unlock(); wakeLock = null;
closing = call({action:'stop'}).finally(() => {
if (stream) stream.vulpesClosed = true;
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);
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;
};
@ -217,6 +237,7 @@
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;
@ -253,11 +274,11 @@
gl.uniform1i(gl.getUniformLocation(program,name),index);
});
gl.viewport(0,0,canvas.width,canvas.height);
const pixels = meta.width * meta.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'});
@ -267,11 +288,32 @@
if (data.length !== w * h * 3 / 2) throw Error('Invalid native camera frame');
sequence = frame.sequence; timing.sensorFrames = sequence;
const start=performance.now();
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);
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;
};
@ -302,9 +344,9 @@
if (!stopped) { recordError(error); await stop().catch(recordError); }
return;
}
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/30-(performance.now()-started)));
if (!stopped) timer = setTimeout(next, Math.max(0, 1000/frameRate-(performance.now()-started)));
};
timer = setTimeout(next, 1000/30);
timer = setTimeout(next, 1000/frameRate);
return stream;
} catch (error) { await stop().catch(() => {}); throw error; }
}
@ -408,7 +450,8 @@
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 profile = {containerFormat:'webm',video:{width:size.width,height:size.height,codec:'vp8',bitrate:1500000,framerate:30},audio:{codec:'opus',samplerate:48000,channels:1,bitrate:64000}};
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,
@ -500,16 +543,55 @@
if(!videoMime)throw new DOMException('Video unavailable','NotSupportedError');
if(recording || startingRecording)throw new DOMException('Recording in progress','InvalidStateError');
startingRecording=true;
let audio, captured;
let audio = null, captured, copyTimer, recordingCanvas, recordingContext;
try {
audio=await navigator.mediaDevices.getUserMedia({audio:true,video:false});
// 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.
captured=native ? stream.vulpesPreviewCanvas.captureStream(30) : new MediaStream(stream.getVideoTracks().map(t=>t.clone()));
for(const track of audio.getAudioTracks())captured.addTrack(track);
const recorder=new MediaRecorder(captured,{mimeType:videoMime,videoBitsPerSecond:1500000,audioBitsPerSecond:64000});
const chunks=[];let bytes=0,failed=false,finish;
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=>{
@ -521,9 +603,11 @@
};
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:'video/webm'});
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;
@ -536,16 +620,44 @@
} 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};
recorder.start(500);
// 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;}
},