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
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { Camera, Pause, Play } from 'lucide-vue-next'
import { Camera, Pause, Play, TriangleAlert } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
import type { SmsMessageType } from '@/types/messages'
type MessageAttachment = {
@@ -11,7 +12,9 @@ type MessageAttachment = {
}
const props = defineProps<{ message: MessageAttachment }>()
const phone = usePhoneStore()
const playing = ref(false)
const playbackFailed = ref(false)
const video = ref<HTMLVideoElement>()
const imageStyles: Record<string, string> = {
@@ -55,9 +58,18 @@ async function toggleVideo(): Promise<void> {
playing.value = !playing.value
return
}
if (video.value.paused) await video.value.play()
else video.value.pause()
playing.value = !video.value.paused
if (!video.value.paused) {
video.value.pause()
return
}
playbackFailed.value = false
try {
await video.value.play()
} catch (error) {
playing.value = false
playbackFailed.value = true
console.error('[Messages] Could not play the attached video.', error)
}
}
function durationLabel(milliseconds: number | null): string {
@@ -85,8 +97,13 @@ function durationLabel(milliseconds: number | null): string {
v-else-if="message.message_type === 'video'"
type="button"
class="messages-attachment messages-attachment--video"
:class="{ playing }"
:class="{ playing, 'messages-attachment--failed': playbackFailed }"
:style="{ background }"
:aria-label="
playbackFailed
? phone.t('Apps.photos.errors.unsupported')
: phone.t('Apps.photos.videoAlt')
"
@click="toggleVideo"
>
<video
@@ -95,15 +112,25 @@ function durationLabel(milliseconds: number | null): string {
:src="mediaUrl"
playsinline
preload="metadata"
@play="playing = true"
@pause="playing = false"
@ended="playing = false"
@error="playbackFailed = true"
/>
<span
><Pause v-if="playing" :size="22" fill="currentColor" /><Play
><TriangleAlert v-if="playbackFailed" :size="22" /><Pause
v-else-if="playing"
:size="22"
fill="currentColor"
/><Play
v-else
:size="22"
fill="currentColor"
/></span>
<small>{{ durationLabel(message.media_duration_ms) }}</small>
<small v-if="playbackFailed">{{
phone.t('Apps.photos.errors.unsupported')
}}</small>
<small v-else>{{ durationLabel(message.media_duration_ms) }}</small>
</button>
<div v-else class="messages-attachment messages-attachment--gif">
<img
+191 -45
View File
@@ -4,6 +4,11 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
import type { UploadReady } from '@/types/media'
import { createGameView, type GameView } from '@/utils/gameView'
import {
bindMediaRecorderError,
setBoundedMapEntry,
stopMediaRecorder,
} from '@/utils/mediaRecorder'
import { nuiCall } from '@/utils/nui'
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
@@ -12,6 +17,7 @@ type PendingVideo = { blob: Blob; fileName: string }
const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>()
const maxPendingVideos = 3
const captureFps = 30
const maxCaptureEdge = 720
const portraitAspect = 3 / 4
@@ -23,12 +29,16 @@ let gameView: GameView | null = null
let renderFrameId: number | undefined
let lastRenderAt = 0
let recorder: MediaRecorder | null = null
let recordingStarting = false
let stream: MediaStream | null = null
let microphoneStream: MediaStream | null = null
let chunks: RecordingChunk[] = []
let lastChunkAt = 0
let lastChunkTimecode: number | null = null
let recordingStartedAt = 0
let recordingGeneration = 0
let flushTimer: number | undefined
let removeRecorderErrorListener: (() => void) | null = null
function captureDimensions(): { height: number; width: number } {
return landscape
@@ -54,7 +64,11 @@ function ensureGameView(): GameView {
if (gameView && !gameView.isLost()) return gameView
gameView?.dispose()
const dimensions = captureDimensions()
gameView = createGameView(canvasRef.value)
gameView = createGameView(canvasRef.value, {
onContextRestored: () => {
if (recorder?.state === 'recording') startRenderLoop()
},
})
gameView.resize(
dimensions.width,
dimensions.height,
@@ -93,6 +107,7 @@ function resetRecording(): void {
chunks = []
lastChunkAt = 0
lastChunkTimecode = null
recordingStartedAt = 0
}
function stopTracks(): void {
@@ -103,8 +118,22 @@ function stopTracks(): void {
}
function cleanupRecording(): void {
if (recorder && recorder.state !== 'inactive') recorder.stop()
recordingGeneration += 1
recordingStarting = false
const activeRecorder = recorder
recorder = null
removeRecorderErrorListener?.()
removeRecorderErrorListener = null
if (activeRecorder) {
activeRecorder.ondataavailable = null
if (activeRecorder.state !== 'inactive') {
try {
activeRecorder.stop()
} catch (error) {
console.error('[Camera] Could not stop the failed media recorder.', error)
}
}
}
stopTracks()
if (flushTimer !== undefined) window.clearInterval(flushTimer)
flushTimer = undefined
@@ -114,7 +143,7 @@ function cleanupRecording(): void {
}
async function startRecording(data: Record<string, unknown>): Promise<void> {
if (recorder) return
if (recorder || recordingStarting) return
if (typeof MediaRecorder === 'undefined') {
window.postMessage(
{
@@ -129,7 +158,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
if (Number.isFinite(configuredBitrate) && configuredBitrate > 0) {
bitrateBps = Math.round(configuredBitrate * 1000)
}
startRenderLoop()
try {
startRenderLoop()
} catch (error) {
console.error('[Camera] Could not start game-view recording.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
recordingStarting = true
const generation = ++recordingGeneration
resetRecording()
const videoStream = canvasRef.value?.captureStream(captureFps) ?? null
if (!videoStream) {
@@ -138,15 +182,23 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
}
if (data.microphoneEnabled === true) {
try {
microphoneStream = await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
const acquiredMicrophoneStream =
await navigator.mediaDevices.getUserMedia({
audio: {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
},
})
if (generation !== recordingGeneration) {
acquiredMicrophoneStream.getTracks().forEach((track) => track.stop())
videoStream.getTracks().forEach((track) => track.stop())
return
}
microphoneStream = acquiredMicrophoneStream
} catch {
videoStream.getTracks().forEach((track) => track.stop())
if (generation !== recordingGeneration) return
cleanupRecording()
window.postMessage(
{
@@ -162,16 +214,51 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
...videoStream.getVideoTracks(),
...(microphoneStream?.getAudioTracks() ?? []),
])
if (generation !== recordingGeneration) {
stopTracks()
stopRenderLoop()
return
}
const mimeType = [
'video/webm;codecs=vp8,opus',
'video/webm;codecs=vp8',
'video/webm',
].find((type) => MediaRecorder.isTypeSupported(type))
recorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
try {
recorder = new MediaRecorder(stream, {
...(mimeType ? { mimeType } : {}),
audioBitsPerSecond: 128_000,
videoBitsPerSecond: bitrateBps,
})
} catch (error) {
console.error('[Camera] Could not create the media recorder.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'unsupported', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
const activeRecorder = recorder
removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder,
() =>
generation === recordingGeneration && recorder === activeRecorder,
(event) => {
console.error('[Camera] Media recorder failed while recording.', event)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
},
)
recorder.ondataavailable = (event) => {
if (!event.data.size) return
const now = Date.now()
@@ -186,7 +273,22 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
lastChunkAt = now
chunks.push({ blob: event.data, durationMs })
}
recorder.start()
try {
recorder.start()
} catch (error) {
console.error('[Camera] Could not start the media recorder.', error)
cleanupRecording()
window.postMessage(
{
data: { error: 'capture_failed', success: false },
type: 'camera:recordError',
},
'*',
)
return
}
recordingStartedAt = Date.now()
recordingStarting = false
flushTimer = window.setInterval(() => {
if (recorder?.state === 'recording') recorder.requestData()
}, 1000)
@@ -196,37 +298,78 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
async function stopRecording(data: Record<string, unknown>): Promise<void> {
const correlationId = String(data.correlationId ?? '')
if (!recorder || recorder.state === 'inactive' || !correlationId) return
const generation = recordingGeneration
const activeRecorder = recorder
postRecordState(false, true)
recorder.requestData()
await new Promise((resolve) => window.setTimeout(resolve, 120))
recorder.stop()
await new Promise((resolve) => window.setTimeout(resolve, 120))
if (flushTimer !== undefined) window.clearInterval(flushTimer)
flushTimer = undefined
stopTracks()
recorder = null
stopRenderLoop()
const durationMs = chunks.reduce((sum, entry) => sum + entry.durationMs, 0)
let blob = new Blob(
chunks.map((entry) => entry.blob),
{ type: 'video/webm' },
)
blob = await (
fixWebmDuration as unknown as (
source: Blob,
duration: number,
options: { logger: boolean },
) => Promise<Blob>
)(blob, durationMs, { logger: false })
resetRecording()
pendingVideos.set(correlationId, {
blob,
fileName: `camera-${correlationId}.webm`,
})
await nuiCall('media:requestUpload', {
correlationId,
mediaType: 'video',
})
const stopErrorListener = removeRecorderErrorListener
stopErrorListener?.()
if (removeRecorderErrorListener === stopErrorListener) {
removeRecorderErrorListener = null
}
try {
await stopMediaRecorder(activeRecorder)
if (generation !== recordingGeneration) return
const durationMs = Math.max(
chunks.reduce((sum, entry) => sum + entry.durationMs, 0),
recordingStartedAt ? Date.now() - recordingStartedAt : 0,
)
let blob = new Blob(
chunks.map((entry) => entry.blob),
{ type: 'video/webm' },
)
blob = await (
fixWebmDuration as unknown as (
source: Blob,
duration: number,
options: { logger: boolean },
) => Promise<Blob>
)(blob, durationMs, { logger: false })
if (generation !== recordingGeneration) return
setBoundedMapEntry(
pendingVideos,
correlationId,
{ blob, fileName: `camera-${correlationId}.webm` },
maxPendingVideos,
)
const response = await nuiCall('media:requestUpload', {
correlationId,
mediaType: 'video',
})
if (generation !== recordingGeneration) return
if (!response.success) {
pendingVideos.delete(correlationId)
window.postMessage(
{
data: { correlationId, error: 'request_failed', success: false },
type: 'media:uploadResult',
},
'*',
)
}
} catch (error) {
if (generation === recordingGeneration) {
console.error('[Camera] Could not finalize the video recording.', error)
pendingVideos.delete(correlationId)
window.postMessage(
{
data: { correlationId, error: 'capture_failed', success: false },
type: 'media:uploadResult',
},
'*',
)
}
} finally {
if (generation === recordingGeneration || recorder === activeRecorder) {
if (recorder === activeRecorder) {
recorder = null
}
stopTracks()
stopRenderLoop()
resetRecording()
}
}
}
async function renderFrames(view: GameView, count: number): Promise<void> {
@@ -381,6 +524,9 @@ function onMessage(event: MessageEvent): void {
}
} else if (message.type === 'media:uploadReady') {
void uploadReady(message.data as UploadReady)
} else if (message.type === 'media:uploadResult') {
const correlationId = String(message.data?.correlationId ?? '')
if (correlationId) pendingVideos.delete(correlationId)
}
}