diff --git a/fxmanifest.lua b/fxmanifest.lua index 2c14e35..ab030df 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -17,7 +17,7 @@ files { provide 'screenshot-basic' --- use 'nui' if you're having trouble uploads. nui is not yet supported for videos +-- use 'nui' if you're having trouble with uploads protocol 'http' -- bytes per second for nui protocol diff --git a/game/client/bootstrap.ts b/game/client/bootstrap.ts index f53092e..b6e76e7 100644 --- a/game/client/bootstrap.ts +++ b/game/client/bootstrap.ts @@ -11,6 +11,8 @@ export const clientUploadTokenMap = new Map(); RegisterNuiCallbackType('screenshot_created'); RegisterNuiCallbackType('screenshot_upload_proxy'); RegisterNuiCallbackType('capture_screen'); +RegisterNuiCallbackType('capture_stream_chunk'); +RegisterNuiCallbackType('capture_stream_finalize'); const protocol = GetResourceMetadata(GetCurrentResourceName(), 'protocol', 0) || 'http'; const serverEndpoint = `http://${GetCurrentServerEndpoint()}/${GetCurrentResourceName()}`; @@ -135,6 +137,16 @@ function createImageCaptureMessage(options: CaptureRequest) { } onNet('screencapture:captureStream', (token: string, options: object) => { + if (protocol === 'nui') { + return SendNUIMessage({ + ...options, + uploadToken: token, + action: 'capture-stream-start', + callbackUrl: `https://${GetCurrentResourceName()}/capture_stream_chunk`, + finalizeCallbackUrl: `https://${GetCurrentResourceName()}/capture_stream_finalize`, + }); + } + SendNUIMessage({ ...options, uploadToken: token, diff --git a/game/client/protocols/nui.ts b/game/client/protocols/nui.ts index 4308127..e6c7b06 100644 --- a/game/client/protocols/nui.ts +++ b/game/client/protocols/nui.ts @@ -2,6 +2,7 @@ import { clientCaptureMap, clientUploadTokenMap } from "../bootstrap"; import { ScreenshotCreatedBody } from "../types"; const imagesBps = parseInt(GetResourceMetadata(GetCurrentResourceName(), 'images_bps', 0), 10) || 500000; +const streamBps = parseInt(GetResourceMetadata(GetCurrentResourceName(), 'stream_bps', 0), 10) || 5000000; // screenshot-basic compatibility on('__cfx_nui:screenshot_created', (body: ScreenshotCreatedBody, cb: (arg: any) => void) => { @@ -35,4 +36,22 @@ on('__cfx_nui:capture_screen', (body: any, cb: (arg: any) => void) => { if (token) { TriggerLatentServerEvent('screencapture:capture-screen', imagesBps, token, body.data); } +}); + +on('__cfx_nui:capture_stream_chunk', (body: any, cb: (arg: any) => void) => { + cb(true); + + const { token, data } = body; + if (token && data) { + TriggerLatentServerEvent('screencapture:stream-chunk-nui', streamBps, token, data); + } +}); + +on('__cfx_nui:capture_stream_finalize', (body: any, cb: (arg: any) => void) => { + cb(true); + + const { token } = body; + if (token) { + emitNet('screencapture:stream-finalize-nui', token); + } }); \ No newline at end of file diff --git a/game/nui/src/capture-stream.ts b/game/nui/src/capture-stream.ts index ada4bfc..8c77888 100644 --- a/game/nui/src/capture-stream.ts +++ b/game/nui/src/capture-stream.ts @@ -7,14 +7,28 @@ import type { StreamTargetChunk } from 'mediabunny'; // 800 KB gives a comfortable margin. const CHUNK_SIZE = 800 * 1024; -type CaptureStreamRequest = { +type CaptureStreamHttpRequest = { action: CaptureStreamActions; uploadToken: string; serverEndpoint: string; + callbackUrl?: never; + finalizeCallbackUrl?: never; maxWidth?: number; maxHeight?: number; }; +type CaptureStreamNuiRequest = { + action: CaptureStreamActions; + uploadToken: string; + serverEndpoint?: never; + callbackUrl: string; + finalizeCallbackUrl: string; + maxWidth?: number; + maxHeight?: number; +}; + +type CaptureStreamRequest = CaptureStreamHttpRequest | CaptureStreamNuiRequest; + export class CaptureStream { #gameView: ReturnType | null = null; #canvas: HTMLCanvasElement | null = null; @@ -75,7 +89,7 @@ export class CaptureStream { } async stream(request: CaptureStreamRequest) { - const { uploadToken, serverEndpoint } = request; + const { uploadToken, serverEndpoint, callbackUrl, finalizeCallbackUrl } = request; const { width, height } = this.calculateDimensions(request); this.#canvas = document.createElement('canvas'); @@ -88,26 +102,9 @@ export class CaptureStream { // Wait for the FiveM WebGL hook to populate the game framebuffer await this.waitForFrames(3); - const writable = new WritableStream({ - async write(chunk) { - const response = await fetch(`${serverEndpoint}/stream-chunk/${uploadToken}`, { - method: 'POST', - headers: { 'Content-Type': 'application/octet-stream' }, - body: chunk.data, - }); - - if (!response.ok) { - throw new Error(`Chunk upload failed: ${response.status}`); - } - }, - - async close() { - // Called when output.finalize() closes the stream — all chunks delivered. - await fetch(`${serverEndpoint}/stream-finalize/${uploadToken}`, { - method: 'POST', - }); - }, - }); + const writable = callbackUrl + ? this.createNuiWritableStream(uploadToken, callbackUrl, finalizeCallbackUrl!) + : this.createHttpWritableStream(uploadToken, serverEndpoint!); this.#output = new Output({ format: new WebMOutputFormat({ appendOnly: true }), @@ -137,6 +134,70 @@ export class CaptureStream { await this.#output.start(); } + private createHttpWritableStream(uploadToken: string, serverEndpoint: string): WritableStream { + return new WritableStream({ + async write(chunk) { + const response = await fetch(`${serverEndpoint}/stream-chunk/${uploadToken}`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: chunk.data, + }); + + if (!response.ok) { + throw new Error(`Chunk upload failed: ${response.status}`); + } + }, + + async close() { + await fetch(`${serverEndpoint}/stream-finalize/${uploadToken}`, { + method: 'POST', + }); + }, + }); + } + + private createNuiWritableStream( + uploadToken: string, + callbackUrl: string, + finalizeCallbackUrl: string, + ): WritableStream { + return new WritableStream({ + async write(chunk) { + // Convert binary chunk to base64 for NUI callback transport + const bytes = chunk.data instanceof ArrayBuffer + ? new Uint8Array(chunk.data) + : new Uint8Array(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength); + + // Build base64 in larger batches for better performance + const BATCH = 8192; + const parts: string[] = []; + for (let i = 0; i < bytes.length; i += BATCH) { + const end = Math.min(i + BATCH, bytes.length); + parts.push(String.fromCharCode(...bytes.subarray(i, end))); + } + const base64Data = btoa(parts.join('')); + + const response = await fetch(callbackUrl, { + method: 'POST', + body: JSON.stringify({ token: uploadToken, data: base64Data }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`NUI chunk callback failed: ${response.status}`); + } + }, + + async close() { + await fetch(finalizeCallbackUrl, { + method: 'POST', + body: JSON.stringify({ token: uploadToken }), + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + } + async stop() { // Stop all media tracks so no new frames are delivered to the source if (this.#mediaStream) { diff --git a/game/server/bootstrap.ts b/game/server/bootstrap.ts index 6b9c4a7..5ec1caf 100644 --- a/game/server/bootstrap.ts +++ b/game/server/bootstrap.ts @@ -1,4 +1,4 @@ -import { createServer } from './koa-router'; +import { createServer, finalizeStream } from './koa-router'; import './export'; import { eventController } from './event'; import { RequestUploadToken, createRegularUploadData } from './types'; @@ -43,6 +43,7 @@ eventController( ); import { processUpload, uploadFile, base64ToBuffer } from './process-upload'; +import { appendFile } from 'node:fs/promises'; onNet('screencapture:capture-screen', async (token: string, base64Data: string) => { try { @@ -97,3 +98,28 @@ onNet('screencapture:PerformUploadProxy', async (token: string, base64Data: stri } } }); + +// NUI protocol: receive a base64-encoded video chunk and append it to the stream's temp file. +onNet('screencapture:stream-chunk-nui', async (token: string, base64Data: string) => { + try { + const streamData = uploadStore.getStream(token); + const chunk = base64ToBuffer(base64Data); + + await appendFile(streamData.tempFilePath, chunk); + streamData.bytesReceived += chunk.length; + } catch (err) { + console.error('[screencapture] stream-chunk-nui error:', err); + } +}); + +// NUI protocol: finalize stream — all chunks have been delivered via latent events. +onNet('screencapture:stream-finalize-nui', async (token: string) => { + try { + const streamData = uploadStore.getStream(token); + uploadStore.removeStream(token); + + await finalizeStream(streamData); + } catch (err) { + console.error('[screencapture] stream-finalize-nui error:', err); + } +}); diff --git a/game/server/koa-router.ts b/game/server/koa-router.ts index 298f994..13d5dfd 100644 --- a/game/server/koa-router.ts +++ b/game/server/koa-router.ts @@ -9,7 +9,7 @@ import { multer } from './multer'; import FormData from 'form-data'; import fetch from 'node-fetch'; -import { StreamRemoteConfig } from './types'; +import { StreamRemoteConfig, StreamUploadData } from './types'; import { UploadStore } from './upload-store'; import { processUpload } from './process-upload'; @@ -109,27 +109,7 @@ export async function createServer(uploadStore: UploadStore) { const streamData = uploadStore.getStream(token); uploadStore.removeStream(token); - if (streamData.isRemote) { - // Read the assembled file into memory, then immediately delete it — - // we do this in a try/finally so the file is always cleaned up even - // if the remote upload throws. - let videoBuffer: Buffer; - try { - videoBuffer = await readFile(streamData.tempFilePath); - } finally { - await unlink(streamData.tempFilePath).catch((err) => - console.error('[screencapture] failed to delete temp file:', err), - ); - } - - const response = await uploadStreamFile(streamData.remoteUrl!, streamData.remoteConfig!, videoBuffer!); - - streamData.callback(response); - } else { - // Node.js Buffer → Lua marshaling is broken (Buffer serialises as a - // 0-indexed table, giving #data = 0 in Lua). Pass the path string instead. - streamData.callback(streamData.tempFilePath); - } + await finalizeStream(streamData); ctx.status = 200; ctx.body = { ok: true }; @@ -145,6 +125,34 @@ export async function createServer(uploadStore: UploadStore) { setHttpCallback(app.callback()); } +// Shared finalization logic used by both the HTTP route and NUI event handler. +// Branches on isRemote: +// remote → read file → upload to URL → delete file → callback(remoteResponse) +// local → callback(tempFilePath), caller owns the file +export async function finalizeStream(streamData: StreamUploadData): Promise { + if (streamData.isRemote) { + // Read the assembled file into memory, then immediately delete it — + // we do this in a try/finally so the file is always cleaned up even + // if the remote upload throws. + let videoBuffer: Buffer; + try { + videoBuffer = await readFile(streamData.tempFilePath); + } finally { + await unlink(streamData.tempFilePath).catch((err) => + console.error('[screencapture] failed to delete temp file:', err), + ); + } + + const response = await uploadStreamFile(streamData.remoteUrl!, streamData.remoteConfig!, videoBuffer!); + + streamData.callback(response); + } else { + // Node.js Buffer → Lua marshaling is broken (Buffer serialises as a + // 0-indexed table, giving #data = 0 in Lua). Pass the path string instead. + streamData.callback(streamData.tempFilePath); + } +} + // Uploads a completed WebM video Buffer to a remote URL via multipart FormData. // Kept separate from uploadFile() since video always uses video/webm content-type // and doesn't need the base64/blob DataType branching that images require.