Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fxmanifest.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions game/client/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const clientUploadTokenMap = new Map<string, string>();
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()}`;
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions game/client/protocols/nui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
}
});
105 changes: 83 additions & 22 deletions game/nui/src/capture-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createGameView> | null = null;
#canvas: HTMLCanvasElement | null = null;
Expand Down Expand Up @@ -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');
Expand All @@ -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<StreamTargetChunk>({
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 }),
Expand Down Expand Up @@ -137,6 +134,70 @@ export class CaptureStream {
await this.#output.start();
}

private createHttpWritableStream(uploadToken: string, serverEndpoint: string): WritableStream<StreamTargetChunk> {
return new WritableStream<StreamTargetChunk>({
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<StreamTargetChunk> {
return new WritableStream<StreamTargetChunk>({
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) {
Expand Down
28 changes: 27 additions & 1 deletion game/server/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -43,6 +43,7 @@ eventController<RequestUploadToken, string>(
);

import { processUpload, uploadFile, base64ToBuffer } from './process-upload';
import { appendFile } from 'node:fs/promises';

onNet('screencapture:capture-screen', async (token: string, base64Data: string) => {
try {
Expand Down Expand Up @@ -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);
}
});
52 changes: 30 additions & 22 deletions game/server/koa-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 };
Expand All @@ -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<void> {
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.
Expand Down
Loading