FIX - stabilize CEF media capture and uploads

This commit is contained in:
DerEchteAlec
2026-08-12 18:59:14 +02:00
parent c3b7a0871a
commit ff7e00596b
15 changed files with 912 additions and 179 deletions
+91 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { gameViewGeometry } from '@/utils/gameView'
import { createGameView, gameViewGeometry } from '@/utils/gameView'
describe('gameViewGeometry', () => {
it('center-crops a widescreen game view for 3:4 portrait output', () => {
@@ -60,3 +60,92 @@ describe('gameViewGeometry', () => {
])
})
})
describe('createGameView', () => {
it('recreates graphics resources and resumes after context restoration', () => {
const gl = {
ARRAY_BUFFER: 1,
CLAMP_TO_EDGE: 2,
COLOR_BUFFER_BIT: 4,
COMPILE_STATUS: 5,
DYNAMIC_DRAW: 6,
FLOAT: 7,
FRAGMENT_SHADER: 8,
LINK_STATUS: 9,
MIRRORED_REPEAT: 10,
NEAREST: 11,
REPEAT: 12,
RGBA: 13,
STATIC_DRAW: 14,
TEXTURE_2D: 15,
TEXTURE_MAG_FILTER: 16,
TEXTURE_MIN_FILTER: 17,
TEXTURE_WRAP_S: 18,
TEXTURE_WRAP_T: 19,
TRIANGLE_STRIP: 20,
UNSIGNED_BYTE: 21,
VERTEX_SHADER: 22,
attachShader: vi.fn(),
bindBuffer: vi.fn(),
bindTexture: vi.fn(),
bufferData: vi.fn(),
clear: vi.fn(),
clearColor: vi.fn(),
compileShader: vi.fn(),
createBuffer: vi.fn(() => ({})),
createProgram: vi.fn(() => ({})),
createShader: vi.fn(() => ({})),
createTexture: vi.fn(() => ({})),
deleteBuffer: vi.fn(),
deleteProgram: vi.fn(),
deleteShader: vi.fn(),
deleteTexture: vi.fn(),
drawArrays: vi.fn(),
enableVertexAttribArray: vi.fn(),
finish: vi.fn(),
getAttribLocation: vi.fn((_program, name: string) =>
name === 'a_position' ? 0 : 1,
),
getExtension: vi.fn(() => ({ loseContext: vi.fn() })),
getProgramInfoLog: vi.fn(() => ''),
getProgramParameter: vi.fn(() => true),
getShaderInfoLog: vi.fn(() => ''),
getShaderParameter: vi.fn(() => true),
getUniformLocation: vi.fn(() => ({})),
linkProgram: vi.fn(),
shaderSource: vi.fn(),
texImage2D: vi.fn(),
texParameterf: vi.fn(),
uniform1i: vi.fn(),
useProgram: vi.fn(),
vertexAttribPointer: vi.fn(),
viewport: vi.fn(),
}
const canvas = Object.assign(new EventTarget(), {
getContext: () => gl,
height: 0,
width: 0,
}) as unknown as HTMLCanvasElement
const restored = vi.fn()
vi.spyOn(console, 'error').mockImplementation(() => undefined)
vi.spyOn(console, 'info').mockImplementation(() => undefined)
const view = createGameView(canvas, { onContextRestored: restored })
view.resize(540, 720, 1920, 1080, 2)
const lost = new Event('webglcontextlost', { cancelable: true })
canvas.dispatchEvent(lost)
expect(lost.defaultPrevented).toBe(true)
expect(view.isLost()).toBe(true)
canvas.dispatchEvent(new Event('webglcontextrestored'))
expect(view.isLost()).toBe(false)
expect(restored).toHaveBeenCalledOnce()
expect(gl.createProgram).toHaveBeenCalledTimes(2)
expect(canvas.width).toBe(540)
expect(canvas.height).toBe(720)
view.render()
expect(gl.drawArrays).toHaveBeenCalledOnce()
view.dispose()
})
})
+177 -78
View File
@@ -31,6 +31,8 @@ export interface GameView {
}
export interface GameViewOptions {
onContextLost?: () => void
onContextRestored?: () => void
preserveDrawingBuffer?: boolean
}
@@ -113,80 +115,180 @@ export function createGameView(
let lost = false
let disposed = false
let program: WebGLProgram | null = null
let positionBuffer: WebGLBuffer | null = null
let texcoordBuffer: WebGLBuffer | null = null
let texture: WebGLTexture | null = null
let lastSize: {
height: number
sourceHeight: number
sourceWidth: number
width: number
zoom: number
} | null = null
const releaseResources = (): void => {
if (positionBuffer) gl.deleteBuffer(positionBuffer)
if (texcoordBuffer) gl.deleteBuffer(texcoordBuffer)
if (texture) gl.deleteTexture(texture)
if (program) gl.deleteProgram(program)
positionBuffer = null
texcoordBuffer = null
texture = null
program = null
}
const initializeResources = (): void => {
releaseResources()
const nextProgram = gl.createProgram()
if (!nextProgram) throw new Error('game_view_program_unavailable')
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
gl.attachShader(nextProgram, vertexShader)
gl.attachShader(nextProgram, fragmentShader)
gl.linkProgram(nextProgram)
gl.deleteShader(vertexShader)
gl.deleteShader(fragmentShader)
if (!gl.getProgramParameter(nextProgram, gl.LINK_STATUS)) {
const error = gl.getProgramInfoLog(nextProgram)
gl.deleteProgram(nextProgram)
throw new Error(error || 'game_view_program_failed')
}
gl.useProgram(nextProgram)
const positionLocation = gl.getAttribLocation(nextProgram, 'a_position')
const texcoordLocation = gl.getAttribLocation(nextProgram, 'a_texcoord')
if (positionLocation < 0 || texcoordLocation < 0) {
gl.deleteProgram(nextProgram)
throw new Error('game_view_attributes_unavailable')
}
const nextPositionBuffer = gl.createBuffer()
const nextTexcoordBuffer = gl.createBuffer()
const nextTexture = gl.createTexture()
if (!nextPositionBuffer || !nextTexcoordBuffer || !nextTexture) {
if (nextPositionBuffer) gl.deleteBuffer(nextPositionBuffer)
if (nextTexcoordBuffer) gl.deleteBuffer(nextTexcoordBuffer)
if (nextTexture) gl.deleteTexture(nextTexture)
gl.deleteProgram(nextProgram)
throw new Error('game_view_resources_unavailable')
}
program = nextProgram
positionBuffer = nextPositionBuffer
texcoordBuffer = nextTexcoordBuffer
texture = nextTexture
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.DYNAMIC_DRAW,
)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
gl.STATIC_DRAW,
)
gl.enableVertexAttribArray(texcoordLocation)
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
// game backbuffer. These calls are intentionally not redundant.
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
gl.clearColor(0, 0, 0, 1)
}
const applySize = (): void => {
if (!lastSize || !positionBuffer || !texcoordBuffer) return
const geometry = gameViewGeometry(
lastSize.sourceWidth,
lastSize.sourceHeight,
lastSize.width,
lastSize.height,
lastSize.zoom,
)
canvas.width = lastSize.width
canvas.height = lastSize.height
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
geometry.textureCoordinates,
gl.DYNAMIC_DRAW,
)
gl.viewport(0, 0, lastSize.width, lastSize.height)
}
const onContextLost = (event: Event) => {
event.preventDefault()
lost = true
console.error('[Camera] Game-view WebGL context lost.')
options.onContextLost?.()
}
const onContextRestored = () => {
if (disposed) return
try {
initializeResources()
lost = false
applySize()
console.info('[Camera] Game-view WebGL context restored.')
options.onContextRestored?.()
} catch (error) {
lost = true
console.error('[Camera] Could not restore the game-view WebGL context.', error)
}
}
canvas.addEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
const program = gl.createProgram()
if (!program) throw new Error('game_view_program_unavailable')
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER))
gl.attachShader(
program,
compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER),
canvas.addEventListener(
'webglcontextrestored',
onContextRestored as EventListener,
false,
)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
throw new Error(gl.getProgramInfoLog(program) || 'game_view_program_failed')
try {
initializeResources()
} catch (error) {
canvas.removeEventListener(
'webglcontextlost',
onContextLost as EventListener,
false,
)
canvas.removeEventListener(
'webglcontextrestored',
onContextRestored as EventListener,
false,
)
releaseResources()
throw error
}
gl.useProgram(program)
const positionLocation = gl.getAttribLocation(program, 'a_position')
const texcoordLocation = gl.getAttribLocation(program, 'a_texcoord')
if (positionLocation < 0 || texcoordLocation < 0) {
throw new Error('game_view_attributes_unavailable')
}
const positionBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.DYNAMIC_DRAW,
)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
const texcoordBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]),
gl.STATIC_DRAW,
)
gl.enableVertexAttribArray(texcoordLocation)
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0)
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
// CitizenFX watches this exact wrap-mode sequence and replaces the seeded pixel with the live
// game backbuffer. These calls are intentionally not redundant.
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.MIRRORED_REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
gl.texParameterf(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.uniform1i(gl.getUniformLocation(program, 'u_texture'), 0)
gl.clearColor(0, 0, 0, 1)
return {
canvas,
@@ -198,11 +300,18 @@ export function createGameView(
onContextLost as EventListener,
false,
)
canvas.removeEventListener(
'webglcontextrestored',
onContextRestored as EventListener,
false,
)
if (!lost) releaseResources()
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
isLost: () => lost,
render() {
if (disposed || lost) return
if (disposed || lost || !program) return
gl.useProgram(program)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
gl.finish()
@@ -214,25 +323,15 @@ export function createGameView(
sourceHeight = window.innerHeight,
zoom = 1,
) {
if (disposed || lost) return
canvas.width = width
canvas.height = height
const geometry = gameViewGeometry(
lastSize = {
height,
sourceWidth,
sourceHeight,
width,
height,
zoom,
)
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, geometry.positions, gl.DYNAMIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer)
gl.bufferData(
gl.ARRAY_BUFFER,
geometry.textureCoordinates,
gl.DYNAMIC_DRAW,
)
gl.viewport(0, 0, width, height)
}
if (disposed || lost) return
applySize()
},
}
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from 'vitest'
import {
bindMediaRecorderError,
setBoundedMapEntry,
stopMediaRecorder,
} from '@/utils/mediaRecorder'
class FakeRecorder extends EventTarget {
state: RecordingState = 'recording'
stop = vi.fn(() => {
this.state = 'inactive'
this.dispatchEvent(new Event('stop'))
})
}
describe('media recorder lifecycle', () => {
it('resolves from the recorder stop event', async () => {
const recorder = new FakeRecorder()
await stopMediaRecorder(recorder as unknown as MediaRecorder)
expect(recorder.stop).toHaveBeenCalledOnce()
expect(recorder.state).toBe('inactive')
})
it('runs recorder error cleanup only for the current generation', () => {
const staleRecorder = new FakeRecorder()
const currentRecorder = new FakeRecorder()
const cleanup = vi.fn()
let generation = 1
const unbindStale = bindMediaRecorderError(
staleRecorder as unknown as MediaRecorder,
() => generation === 1,
cleanup,
)
generation = 2
staleRecorder.dispatchEvent(new Event('error'))
expect(cleanup).not.toHaveBeenCalled()
unbindStale()
bindMediaRecorderError(
currentRecorder as unknown as MediaRecorder,
() => generation === 2,
cleanup,
)
currentRecorder.dispatchEvent(new Event('error'))
currentRecorder.dispatchEvent(new Event('error'))
expect(cleanup).toHaveBeenCalledOnce()
})
it('keeps pending recording buffers bounded and evicts the oldest', () => {
const pending = new Map<string, number>()
setBoundedMapEntry(pending, 'first', 1, 2)
setBoundedMapEntry(pending, 'second', 2, 2)
setBoundedMapEntry(pending, 'third', 3, 2)
expect([...pending.entries()]).toEqual([
['second', 2],
['third', 3],
])
})
})
+62
View File
@@ -0,0 +1,62 @@
export function bindMediaRecorderError(
recorder: MediaRecorder,
isCurrent: () => boolean,
onError: (event: Event) => void,
): () => void {
let bound = true
const handleError = (event: Event): void => {
if (!bound || !isCurrent()) return
bound = false
recorder.removeEventListener('error', handleError)
onError(event)
}
recorder.addEventListener('error', handleError)
return () => {
if (!bound) return
bound = false
recorder.removeEventListener('error', handleError)
}
}
export async function stopMediaRecorder(recorder: MediaRecorder): Promise<void> {
if (recorder.state === 'inactive') return
await new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
recorder.removeEventListener('stop', onStop)
recorder.removeEventListener('error', onError)
}
const onStop = (): void => {
cleanup()
resolve()
}
const onError = (): void => {
cleanup()
reject(new Error('media_recorder_stop_failed'))
}
recorder.addEventListener('stop', onStop, { once: true })
recorder.addEventListener('error', onError, { once: true })
try {
recorder.stop()
} catch (error) {
cleanup()
reject(error)
}
})
}
export function setBoundedMapEntry<Key, Value>(
entries: Map<Key, Value>,
key: Key,
value: Value,
maximumSize: number,
): void {
entries.delete(key)
entries.set(key, value)
while (entries.size > Math.max(0, maximumSize)) {
const oldest = entries.keys().next()
if (oldest.done) break
entries.delete(oldest.value)
}
}