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)
}
}
+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)
}
}
+19 -2
View File
@@ -53,6 +53,7 @@ const elapsed = ref('00:00')
const captures = ref<CaptureItem[]>([])
const latestMedia = ref<PhoneMedia | null>(null)
const gameCanvas = ref<HTMLCanvasElement | null>(null)
const gameViewUnavailable = ref(false)
const noticeText = ref('')
const videoBitrateKbps = ref(1500)
let shutterTimer: number | undefined
@@ -291,10 +292,26 @@ function resizeGameView(entry?: ResizeObserverEntry): void {
function startGameView(): void {
if (isDevelopment || !gameCanvas.value) return
gameView = createGameView(gameCanvas.value)
try {
gameView = createGameView(gameCanvas.value, {
onContextRestored: () => {
resizeGameView()
startGameViewRenderLoop()
},
})
} catch (error) {
gameViewUnavailable.value = true
console.error('[Camera] Game-view rendering is unavailable.', error)
return
}
resizeObserver = new ResizeObserver((entries) => resizeGameView(entries[0]))
resizeObserver.observe(gameCanvas.value)
resizeGameView()
startGameViewRenderLoop()
}
function startGameViewRenderLoop(): void {
if (renderFrameId !== undefined) return
const render = () => {
if (!gameView || gameView.isLost()) {
renderFrameId = undefined
@@ -434,7 +451,7 @@ onBeforeUnmount(() => {
>
<div class="camera-viewport" @wheel.prevent="zoomWithWheel">
<canvas
v-if="!isDevelopment"
v-if="!isDevelopment && !gameViewUnavailable"
ref="gameCanvas"
class="camera-game-view"
aria-hidden="true"
+104 -7
View File
@@ -17,6 +17,7 @@ import {
ShieldAlert,
Send,
Share2,
TriangleAlert,
UserRound,
Video,
X,
@@ -116,6 +117,7 @@ const publishing = ref(false)
const feedback = ref('')
const likedPulseId = ref<string | null>(null)
const reactionPulse = ref<{ id: string; kind: 'like' | 'save' } | null>(null)
const playbackFailedIds = ref(new Set<string>())
const profileDraft = ref({
accountType: 'person',
bio: '',
@@ -250,11 +252,66 @@ async function confirmLogout(): Promise<void> {
}
function setVideoElement(id: string, element: unknown): void {
if (element instanceof HTMLVideoElement) videoElements.set(id, element)
if (element instanceof HTMLVideoElement) {
videoElements.set(id, element)
return
}
videoElements.delete(id)
setPlaybackFailed(id, false)
}
function setMusicElement(id: string, element: unknown): void {
if (element instanceof HTMLAudioElement) musicElements.set(id, element)
if (element instanceof HTMLAudioElement) {
musicElements.set(id, element)
return
}
musicElements.delete(id)
}
function setPlaybackFailed(id: string, failed: boolean): void {
const next = new Set(playbackFailedIds.value)
if (failed) next.add(id)
else next.delete(id)
playbackFailedIds.value = next
}
async function playFeedVideo(
id: string,
element: HTMLVideoElement,
showNotice = false,
): Promise<boolean> {
setPlaybackFailed(id, false)
try {
await element.play()
} catch (error) {
setPlaybackFailed(id, true)
console.error(`[FlipTok] Could not play video ${id}.`, error)
if (showNotice) notify(t('errors.video_not_found'))
return false
}
const music = musicElements.get(id)
if (music) {
try {
await music.play()
} catch (error) {
console.error(`[FlipTok] Could not play music for video ${id}.`, error)
}
}
return true
}
function handleFeedVideoError(id: string, event: Event): void {
setPlaybackFailed(id, true)
const code = (event.currentTarget as HTMLVideoElement).error?.code
console.error(
`[FlipTok] Video ${id} could not be decoded or loaded${code ? ` (media error ${code})` : ''}.`,
)
}
function retryPlayback(video: FlipTokVideo): void {
const element = videoElements.get(video.id)
if (element) void playFeedVideo(video.id, element, true)
}
function chooseMusicTrack(trackId: string): void {
@@ -305,10 +362,8 @@ function observeVideos(): void {
otherAudio?.pause()
}
})
void video.play().catch(() => undefined)
const id = video.dataset.id
const music = id ? musicElements.get(id) : undefined
if (music) void music.play().catch(() => undefined)
if (id) void playFeedVideo(id, video)
if (id) void nuiCall('fliptok:view', { id })
} else {
video.pause()
@@ -327,8 +382,7 @@ function togglePlayback(video: FlipTokVideo): void {
if (!element) return
const music = musicElements.get(video.id)
if (element.paused) {
void element.play()
if (music) void music.play().catch(() => undefined)
void playFeedVideo(video.id, element, true)
} else {
element.pause()
music?.pause()
@@ -339,6 +393,7 @@ function prepareFeedVideo(
video: FlipTokVideo,
element: HTMLVideoElement,
): void {
setPlaybackFailed(video.id, false)
element.volume = (Number(video.original_volume) || 0) / 100
const start = Math.min(
(Number(video.trim_start_ms) || 0) / 1000,
@@ -943,6 +998,7 @@ onBeforeUnmount(() => {
@timeupdate="
enforceVideoTrim(video, $event.target as HTMLVideoElement)
"
@error="handleFeedVideoError(video.id, $event)"
/>
<audio
v-if="video.music_url"
@@ -959,6 +1015,17 @@ onBeforeUnmount(() => {
@click="handleVideoClick(video)"
@dblclick.prevent="handleVideoDoubleClick(video)"
/>
<button
v-if="playbackFailedIds.has(video.id)"
type="button"
class="video-playback-fallback"
:aria-label="`${t('errors.video_not_found')} ${phone.t('Common.start')}`"
@click.stop="retryPlayback(video)"
>
<TriangleAlert />
<strong>{{ t('errors.video_not_found') }}</strong>
<span>{{ phone.t('Common.start') }}</span>
</button>
<Transition name="double-like">
<Heart
v-if="likedPulseId === video.id"
@@ -1932,6 +1999,36 @@ onBeforeUnmount(() => {
rgba(0, 0, 0, 0.74)
);
}
.video-playback-fallback {
position: absolute;
z-index: 10;
top: 50%;
left: 50%;
width: min(230px, 72%);
display: grid;
justify-items: center;
gap: 7px;
border: 1px solid rgb(255 255 255 / 24%);
border-radius: 18px;
padding: 16px;
background: rgb(18 18 20 / 88%);
color: #fff;
text-align: center;
transform: translate(-50%, -50%);
}
.video-playback-fallback svg {
width: 28px;
height: 28px;
color: #ff9f0a;
}
.video-playback-fallback strong {
font-size: 12px;
}
.video-playback-fallback span {
color: #64a8ff;
font-size: 11px;
font-weight: 700;
}
.video-copy {
position: absolute;
left: 13px;
+65 -6
View File
@@ -84,10 +84,13 @@ const imageZoom = ref(1)
const imagePan = ref({ x: 0, y: 0 })
const landscapeViewer = ref(false)
const dragging = ref(false)
const videoPlaybackError = ref(false)
const dragStart = ref({ panX: 0, panY: 0, x: 0, y: 0 })
let observer: IntersectionObserver | null = null
let toastTimer: number | undefined
let pendingDeleteCorrelation = ''
let dragTarget: HTMLElement | null = null
let dragPointerId: number | null = null
const imageStyle = computed(() => ({
cursor:
@@ -227,6 +230,7 @@ function openMedia(entry: PhoneMedia): void {
landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = entry
videoPlaybackError.value = false
imageZoom.value = 1
imagePan.value = { x: 0, y: 0 }
}
@@ -254,6 +258,7 @@ function closeMedia(): void {
landscapeViewer.value = false
phone.setCameraLandscape(false)
selected.value = null
videoPlaybackError.value = false
deleteDialogOpened.value = false
stopDragging()
}
@@ -295,6 +300,9 @@ function startDragging(event: PointerEvent): void {
setZoom(2)
return
}
dragTarget = event.currentTarget as HTMLElement
dragPointerId = event.pointerId
dragTarget.setPointerCapture(event.pointerId)
dragging.value = true
dragStart.value = {
panX: imagePan.value.x,
@@ -302,8 +310,6 @@ function startDragging(event: PointerEvent): void {
x: event.clientX,
y: event.clientY,
}
window.addEventListener('pointermove', moveImage)
window.addEventListener('pointerup', stopDragging)
}
function moveImage(event: PointerEvent): void {
@@ -316,8 +322,44 @@ function moveImage(event: PointerEvent): void {
function stopDragging(): void {
dragging.value = false
window.removeEventListener('pointermove', moveImage)
window.removeEventListener('pointerup', stopDragging)
if (
dragTarget &&
dragPointerId !== null &&
dragTarget.hasPointerCapture(dragPointerId)
) {
dragTarget.releasePointerCapture(dragPointerId)
}
dragTarget = null
dragPointerId = null
}
function moveImageWithKeyboard(event: KeyboardEvent): void {
if (imageZoom.value <= 1) return
const step = event.shiftKey ? 48 : 24
const offsets: Partial<Record<string, { x: number; y: number }>> = {
ArrowDown: { x: 0, y: -step },
ArrowLeft: { x: step, y: 0 },
ArrowRight: { x: -step, y: 0 },
ArrowUp: { x: 0, y: step },
}
const offset = offsets[event.key]
if (!offset) return
event.preventDefault()
event.stopPropagation()
imagePan.value = {
x: imagePan.value.x + offset.x,
y: imagePan.value.y + offset.y,
}
}
async function initializeVideo(event: Event): Promise<void> {
orientToMedia(event)
videoPlaybackError.value = false
try {
await (event.currentTarget as HTMLVideoElement).play()
} catch {
// The native controls remain visible when embedded CEF blocks autoplay.
}
}
async function deleteSelected(): Promise<void> {
@@ -569,18 +611,33 @@ onBeforeUnmount(() => {
:alt="phone.t('Apps.photos.photoAlt')"
:style="imageStyle"
draggable="false"
tabindex="0"
@load="orientToMedia"
@pointerdown="startDragging"
@pointermove="moveImage"
@pointerup="stopDragging"
@pointercancel="stopDragging"
@lostpointercapture="stopDragging"
@keydown="moveImageWithKeyboard"
@dblclick="setZoom(imageZoom === 1 ? 2 : 1)"
/>
<video
v-else
:src="selected.url"
controls
autoplay
playsinline
@loadedmetadata="orientToMedia"
@loadedmetadata="initializeVideo"
@error="videoPlaybackError = true"
></video>
<k-block
v-if="selected.mediaType === 'video' && videoPlaybackError"
strong
inset
class="gallery-error"
role="alert"
>
{{ phone.t('Apps.photos.errors.unsupported') }}
</k-block>
</div>
</div>
@@ -776,6 +833,8 @@ onBeforeUnmount(() => {
position: absolute;
top: 50%;
left: 50%;
width: 720px;
height: 368px;
width: 100cqh;
height: 100cqw;
transform: translate(-50%, -50%) rotate(90deg);
+1 -2
View File
@@ -4091,7 +4091,6 @@ function easyShareHistoryForScenario(testScenario) {
}
app.post('/api/:endpoint', (request, response) => {
console.log(`[NUI] ${request.params.endpoint}`, request.body)
const endpoint = request.params.endpoint
const testScenario = String(request.body._testScenario ?? '')
if (lifecycleEndpoints.has(endpoint)) {
@@ -7235,7 +7234,7 @@ app.post('/api/:endpoint', (request, response) => {
: messageType === 'gif'
? 'image/gif'
: messageType === 'video'
? 'video/mp4'
? 'video/webm'
: null,
media_payload:
messageType === 'voice'
+2 -2
View File
@@ -1,11 +1,11 @@
Config.Media = {
GiphyApiKey = "",
GiphyApiKeyConvar = "sky_phone_giphy_api_key",
GifPageSize = 24,
GifRating = "pg-13",
UrlMaxLength = 2048,
AllowedGifHosts = { "giphy.com" },
FiveManage = {
ApiKey = "e4UZ9y39JfkxHoZMAgRUVK6KMQsNCKPJ", -- Dashboard -> Tokens -> create a token with Media access.
ApiKeyConvar = "sky_phone_fivemanage_api_key",
BaseUrl = "https://api.fivemanage.com/api/v3/file",
RequestTimeoutMs = 10000,
UploadTimeoutMs = 25000,
+6 -2
View File
@@ -538,12 +538,16 @@ Bridge.Callbacks.Register("sky_phone:darkchat:send", function(source, data)
media_waveform = voice.waveform
elseif message_type == "image" or message_type == "video" then
local media_type = message_type == "image" and "photo" or "video"
local media_url, media_error = SkyPhoneMedia.ResolveOwnedMedia(source, data.mediaAssetId, media_type)
local media_url, media_error, resolved_mime = SkyPhoneMedia.ResolveOwnedMedia(
source,
data.mediaAssetId,
media_type
)
if not media_url then
return { success = false, error = media_error }
end
media_payload = media_url
media_mime = message_type == "image" and "image/jpeg" or "video/mp4"
media_mime = resolved_mime
elseif message_type == "share" then
local share
local share_error
+32
View File
@@ -377,6 +377,7 @@ local schema = {
{ name = "url", type = "TEXT NOT NULL" },
{ name = "remote_id", type = "VARCHAR(128) NOT NULL" },
{ name = "media_type", type = "ENUM('photo', 'video') NOT NULL" },
{ name = "mime_type", type = "VARCHAR(120) NULL" },
{ name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
},
primaryKey = "id",
@@ -2524,6 +2525,37 @@ Bridge.Database.Query([[
ALTER TABLE `sky_phone_darkchat_messages`
MODIFY COLUMN `message_type` ENUM('text', 'emoji', 'gif', 'voice', 'image', 'video', 'share', 'system') NOT NULL DEFAULT 'text'
]], {})
Bridge.Database.Query([[
UPDATE `sky_phone_media`
SET `mime_type` = CASE
WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webm' THEN 'video/webm'
WHEN `media_type` = 'video' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.mp4' THEN 'video/mp4'
WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.png' THEN 'image/png'
WHEN `media_type` = 'photo' AND LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.webp' THEN 'image/webp'
WHEN `media_type` = 'photo' AND (
LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpg'
OR LOWER(SUBSTRING_INDEX(`url`, '?', 1)) LIKE '%.jpeg'
) THEN 'image/jpeg'
ELSE NULL
END
WHERE `mime_type` IS NULL OR `mime_type` = ''
]], {})
Bridge.Database.Query([[
UPDATE `sky_phone_sms_messages` message
INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload`
SET message.`media_mime` = media.`mime_type`
WHERE message.`message_type` = 'video'
AND media.`mime_type` IN ('video/webm', 'video/mp4')
AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`)
]], {})
Bridge.Database.Query([[
UPDATE `sky_phone_darkchat_messages` message
INNER JOIN `sky_phone_media` media ON media.`url` = message.`media_payload`
SET message.`media_mime` = media.`mime_type`
WHERE message.`message_type` = 'video'
AND media.`mime_type` IN ('video/webm', 'video/mp4')
AND (message.`media_mime` IS NULL OR message.`media_mime` <> media.`mime_type`)
]], {})
Bridge.Database.Query([[
ALTER TABLE `sky_phone_marketplace_images`
MODIFY COLUMN `gradient` VARCHAR(2200) NOT NULL
+55 -22
View File
@@ -3,14 +3,32 @@ SkyPhoneMedia = {}
local pending_uploads = {}
local pending_deletes = {}
local allowed_remote_mimes = {
photo = {
["image/jpeg"] = true,
["image/png"] = true,
["image/webp"] = true,
},
video = {
["video/mp4"] = true,
["video/webm"] = true,
},
}
local function media_config()
return Config.Media.FiveManage
end
local function media_api_key()
local convar = media_config().ApiKeyConvar
if type(convar) ~= "string" or convar == "" then
return ""
end
return GetConvar(convar, "")
end
local function api_configured()
local api_key = media_config().ApiKey
return type(api_key) == "string" and api_key ~= "" and api_key ~= "YOUR_API_TOKEN"
return media_api_key() ~= ""
end
local function http_request(url, method, body, headers, timeout_ms)
@@ -63,7 +81,7 @@ local function request_presigned_url()
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
"GET",
"",
{ ["Authorization"] = config.ApiKey },
{ ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
local data, response_error = decode_response(response)
@@ -86,7 +104,7 @@ local function get_remote_file(remote_id)
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
"GET",
"",
{ ["Authorization"] = config.ApiKey },
{ ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
return decode_response(response)
@@ -101,7 +119,7 @@ local function delete_remote_file(remote_id)
("%s/%s"):format(tostring(config.BaseUrl):gsub("/+$", ""), remote_id),
"DELETE",
"",
{ ["Authorization"] = config.ApiKey },
{ ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
if response.status < 200 or response.status >= 300 then
@@ -153,7 +171,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
params[#params + 1] = value
end
local rows = Bridge.Database.Query(([[
SELECT `url`, `media_type` FROM `sky_phone_media`
SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
@@ -172,7 +190,7 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
)
return nil, "invalid_attachment"
end
return media.url
return media.url, nil, media.mime_type
end
local function upload_result(source, correlation_id, success, error_code, media)
@@ -224,18 +242,29 @@ local function verify_remote_upload(state, remote_id, uploaded_url)
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
return nil, "invalid_upload"
end
local remote_type = tostring(remote.type or remote.mimeType or ""):lower()
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
if remote_mime == "" and allowed_remote_mimes[state.media_type][remote_type] then
remote_mime = remote_type
end
if remote_type == "" then
remote_type = remote_mime
end
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
return nil, "invalid_media_type"
end
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
return nil, "invalid_media_type"
end
if remote_mime ~= "" and not allowed_remote_mimes[state.media_type][remote_mime] then
return nil, "invalid_media_type"
end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token then
return nil, "invalid_upload_token"
end
return {
mime_type = allowed_remote_mimes[state.media_type][remote_mime] and remote_mime or state.mime_type,
remote_id = remote_id,
url = remote.url or uploaded_url,
}
@@ -255,7 +284,7 @@ Bridge.Callbacks.Register("sky_phone:gallery:list", function(source, data)
if not owner then
return error_response
end
data = data or {}
data = type(data) == "table" and data or {}
local limit = math.max(1, math.min(math.floor(tonumber(data.limit) or Config.Media.PageSize), 100))
local offset = math.max(0, math.floor(tonumber(data.offset) or 0))
local media_type = data.mediaType
@@ -342,7 +371,7 @@ Bridge.Callbacks.Register("sky_phone:messages:gifs", function(source, data)
if #query > 60 or offset < 0 or offset > 500 then
return { success = false, error = "invalid_request" }
end
local api_key = Config.Media.GiphyApiKey
local api_key = GetConvar(Config.Media.GiphyApiKeyConvar, "")
if api_key == "" then
return { success = false, error = "gif_provider_unconfigured" }
end
@@ -420,7 +449,7 @@ end)
RegisterNetEvent("sky_phone:media:request-upload", function(data)
local src = source
data = data or {}
data = type(data) == "table" and data or {}
local correlation_id = data.correlationId
local media_type = data.mediaType
if type(correlation_id) ~= "string" or #correlation_id > 80
@@ -454,6 +483,9 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
capture_token = capture_token,
correlation_id = correlation_id,
media_type = media_type,
mime_type = media_type == "video" and "video/webm"
or ({ png = "image/png", webp = "image/webp" })[tostring(Config.Media.Photo.Encoding):lower()]
or "image/jpeg",
owner = owner,
source = src,
}
@@ -474,7 +506,7 @@ end)
RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local src = source
data = data or {}
data = type(data) == "table" and data or {}
local request_id = data.requestId
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
@@ -496,14 +528,14 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local result
if owner.account_id then
result = Bridge.Database.Query([[
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
VALUES (?, NULL, ?, ?, ?)
]], { owner.account_id, verified.url, verified.remote_id, state.media_type })
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`)
VALUES (?, NULL, ?, ?, ?, ?)
]], { owner.account_id, verified.url, verified.remote_id, state.media_type, verified.mime_type })
else
result = Bridge.Database.Query([[
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`)
VALUES (NULL, ?, ?, ?, ?)
]], { owner.imei, verified.url, verified.remote_id, state.media_type })
INSERT INTO `sky_phone_media` (`account_id`, `device_imei`, `url`, `remote_id`, `media_type`, `mime_type`)
VALUES (NULL, ?, ?, ?, ?, ?)
]], { owner.imei, verified.url, verified.remote_id, state.media_type, verified.mime_type })
end
pending_uploads[request_id] = nil
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
@@ -522,7 +554,7 @@ end)
RegisterNetEvent("sky_phone:media:cancel-upload", function(data)
local src = source
local request_id = data and data.requestId
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if state and state.source == src and not state.completing then
pending_uploads[request_id] = nil
@@ -532,7 +564,7 @@ end)
RegisterNetEvent("sky_phone:media:fail-upload", function(data)
local src = source
local request_id = data and data.requestId
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
return
@@ -550,7 +582,7 @@ end)
RegisterNetEvent("sky_phone:media:delete", function(data)
local src = source
data = data or {}
data = type(data) == "table" and data or {}
local correlation_id = data.correlationId
local media_id = tonumber(data.id)
if type(correlation_id) ~= "string" or #correlation_id > 80 or not media_id then
@@ -646,6 +678,7 @@ AddEventHandler("playerDropped", function()
end)
if not api_configured() then
print("^3[sky_phone] Camera and Gallery uploads are disabled until Config.Media.FiveManage.ApiKey is set in config/media.lua.^7")
print(("^3[sky_phone] Camera and Gallery uploads are disabled until the %s server convar is set.^7")
:format(tostring(media_config().ApiKeyConvar)))
end
end)
+7 -4
View File
@@ -32,7 +32,7 @@ local attachment_assets = {
local attachment_mimes = {
gif = "image/gif",
image = "image/jpeg",
video = "video/mp4",
video = "video/webm",
}
local function allowed_media_url(value)
@@ -208,7 +208,9 @@ local function validate_attachment(source, device, message_type, data)
return nil
end
local payload = data.mediaAssetId
if not valid_attachment_asset(message_type, payload) then
local built_in_asset = valid_attachment_asset(message_type, payload)
local mime = built_in_asset and attachment_mimes[message_type] or nil
if not built_in_asset then
if message_type == "gif" then
return nil
end
@@ -226,7 +228,7 @@ local function validate_attachment(source, device, message_type, data)
params = { media_id, device.imei }
end
local rows = Bridge.Database.Query(([[
SELECT `url`, `media_type` FROM `sky_phone_media`
SELECT `url`, `media_type`, `mime_type` FROM `sky_phone_media`
WHERE `id` = ? AND %s
LIMIT 1
]]):format(condition), params)
@@ -241,6 +243,7 @@ local function validate_attachment(source, device, message_type, data)
return nil
end
payload = media.url
mime = type(media.mime_type) == "string" and media.mime_type ~= "" and media.mime_type or nil
end
local duration = nil
if message_type == "video" and data.mediaDurationMs ~= nil then
@@ -252,7 +255,7 @@ local function validate_attachment(source, device, message_type, data)
end
return {
duration = duration,
mime = attachment_mimes[message_type],
mime = mime,
payload = payload,
}
end