/* =========================================================================== Copyright (C) 2005-2006 Tim Angus Copyright (C) 2018-2023 Gian 'myT' Schellenbaum This file is part of Quake III Arena source code. Quake III Arena source code is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. Quake III Arena source code is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Quake III Arena source code; if not, write to the Free Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA =========================================================================== */ // captures audio/video to an AVI (raw BGR or MJPEG, PCM) file or pipes raw data to FFmpeg #include "client.h" #include "snd_local.h" #define INDEX_FILE_EXTENSION ".index.dat" #define MAX_RIFF_CHUNKS 16 #define PCM_BUFFER_SIZE 44100 #define MAX_AVI_BUFFER 4096 // flags for the "AVIH" RIFF chunk #define AVIH_HASINDEX_BIT 0x010 #define AVIH_ISINTERLEAVED_BIT 0x100 struct audioFormat_t { int rate; int format; int channels; int bits; int sampleSize; int totalBytes; }; struct aviFileData_t { qbool isPiped; qbool isFileOpen; fileHandle_t mainFile; char fileName[256 + 64]; // extra room for the "videos/" prefix and name suffix int fileSize; int moviOffset; int moviSize; fileHandle_t indexFile; int numIndices; int frameRate; int framePeriod; int width; int height; int numVideoFrames; int maxRecordSize; qbool useMotionJpeg; qbool hasAudio; audioFormat_t audioFormat; int numAudioFrames; int chunkStack[MAX_RIFF_CHUNKS]; int chunkStackTop; byte* captureBuffer; byte* encodeBuffer; }; enum closeMode_t { CM_SEQUENCE_COMPLETE, // last in the sequence -> safe to clear sounds etc. CM_SEQUENCE_INCOMPLETE // NOT last in the sequence -> we'll open a new one }; static aviFileData_t afd; static byte buffer[MAX_AVI_BUFFER]; static int bufferWriteIndex; static qbool CloseAVI( closeMode_t closeMode ); static void AbortCapture( const char* message ) { Com_Printf("^1ERROR: %s\n", message); Cbuf_AddText(clc.newDemoPlayer ? "demo_stopvideo\n" : "stopvideo\n"); } static void Safe_FS_Write( const void* buff, int len, fileHandle_t f ) { if (f <= 0) { return; } if (FS_Write(buff, len, f) < len) { AbortCapture("Failed to write to video file/pipe"); } } static void Safe_FS_Close( fileHandle_t* file ) { if (*file > 0) { FS_FCloseFile(*file); *file = 0; } } static void Safe_Z_Free( byte** memory ) { if (*memory != NULL) { Z_Free(*memory); *memory = NULL; } } static void WRITE_STRING( const char* s ) { Com_Memcpy(&buffer[bufferWriteIndex], s, strlen(s)); bufferWriteIndex += strlen(s); } static void WRITE_4BYTES( int x ) { buffer[bufferWriteIndex + 0] = (byte)((x >> 0) & 0xFF); buffer[bufferWriteIndex + 1] = (byte)((x >> 8) & 0xFF); buffer[bufferWriteIndex + 2] = (byte)((x >> 16) & 0xFF); buffer[bufferWriteIndex + 3] = (byte)((x >> 24) & 0xFF); bufferWriteIndex += 4; } static void WRITE_2BYTES( int x ) { buffer[bufferWriteIndex + 0] = (byte)((x >> 0) & 0xFF); buffer[bufferWriteIndex + 1] = (byte)((x >> 8) & 0xFF); bufferWriteIndex += 2; } static void START_CHUNK( const char* s ) { if (afd.chunkStackTop == MAX_RIFF_CHUNKS) { AbortCapture("Top of chunkstack breached"); return; } afd.chunkStack[afd.chunkStackTop] = bufferWriteIndex; afd.chunkStackTop++; WRITE_STRING(s); WRITE_4BYTES(0); } static void END_CHUNK() { if (afd.chunkStackTop <= 0) { AbortCapture("Bottom of chunkstack breached"); return; } const int endIndex = bufferWriteIndex; afd.chunkStackTop--; bufferWriteIndex = afd.chunkStack[afd.chunkStackTop]; bufferWriteIndex += 4; WRITE_4BYTES(endIndex - bufferWriteIndex - 4); bufferWriteIndex = endIndex; bufferWriteIndex = PAD(bufferWriteIndex, 2); } void CL_WriteAVIHeader() { bufferWriteIndex = 0; afd.chunkStackTop = 0; START_CHUNK("RIFF"); { WRITE_STRING("AVI "); { START_CHUNK("LIST"); { WRITE_STRING("hdrl"); WRITE_STRING("avih"); WRITE_4BYTES(56); //"avih" "chunk" size WRITE_4BYTES(afd.framePeriod); //dwMicroSecPerFrame WRITE_4BYTES(afd.maxRecordSize * afd.frameRate); //dwMaxBytesPerSec WRITE_4BYTES(0); //dwReserved1 if (afd.isPiped) WRITE_4BYTES(AVIH_ISINTERLEAVED_BIT); else WRITE_4BYTES(AVIH_ISINTERLEAVED_BIT | AVIH_HASINDEX_BIT); WRITE_4BYTES(afd.numVideoFrames); //dwTotalFrames WRITE_4BYTES(0); //dwInitialFrame if (afd.hasAudio) //dwStreams WRITE_4BYTES(2); else WRITE_4BYTES(1); WRITE_4BYTES(afd.maxRecordSize); //dwSuggestedBufferSize WRITE_4BYTES(afd.width); //dwWidth WRITE_4BYTES(afd.height); //dwHeight WRITE_4BYTES(0); //dwReserved[ 0 ] WRITE_4BYTES(0); //dwReserved[ 1 ] WRITE_4BYTES(0); //dwReserved[ 2 ] WRITE_4BYTES(0); //dwReserved[ 3 ] START_CHUNK("LIST"); { WRITE_STRING("strl"); WRITE_STRING("strh"); WRITE_4BYTES(56); //"strh" "chunk" size WRITE_STRING("vids"); //fccType //fccHandler if (!afd.isPiped && afd.useMotionJpeg) WRITE_STRING("MJPG"); else WRITE_4BYTES(0); //raw BGR WRITE_4BYTES(0); //dwFlags WRITE_4BYTES(0); //dwPriority WRITE_4BYTES(0); //dwInitialFrame WRITE_4BYTES(1); //dwTimescale WRITE_4BYTES(afd.frameRate); //dwDataRate WRITE_4BYTES(0); //dwStartTime WRITE_4BYTES(afd.numVideoFrames); //dwDataLength WRITE_4BYTES(afd.maxRecordSize); //dwSuggestedBufferSize WRITE_4BYTES(-1); //dwQuality WRITE_4BYTES(0); //dwSampleSize WRITE_2BYTES(0); //rcFrame WRITE_2BYTES(0); //rcFrame WRITE_2BYTES(afd.width); //rcFrame WRITE_2BYTES(afd.height); //rcFrame WRITE_STRING("strf"); WRITE_4BYTES(40); //"strf" "chunk" size WRITE_4BYTES(40); //biSize WRITE_4BYTES(afd.width); //biWidth WRITE_4BYTES(afd.height); //biHeight WRITE_2BYTES(1); //biPlanes WRITE_2BYTES(24); //biBitCount //biCompression and biSizeImage if (!afd.isPiped && afd.useMotionJpeg) { // We specify the pixel count WRITE_STRING("MJPG"); WRITE_4BYTES(afd.width * afd.height); } else { // We must either specify 0 or the total byte count per image WRITE_4BYTES(0); // raw BGR WRITE_4BYTES(afd.width * afd.height * 3); } WRITE_4BYTES(0); //biXPelsPetMeter WRITE_4BYTES(0); //biYPelsPetMeter WRITE_4BYTES(0); //biClrUsed WRITE_4BYTES(0); //biClrImportant } END_CHUNK(); if (afd.hasAudio) { START_CHUNK("LIST"); { WRITE_STRING("strl"); WRITE_STRING("strh"); WRITE_4BYTES(56); //"strh" "chunk" size WRITE_STRING("auds"); WRITE_4BYTES(0); //FCC WRITE_4BYTES(0); //dwFlags WRITE_4BYTES(0); //dwPriority WRITE_4BYTES(0); //dwInitialFrame WRITE_4BYTES(afd.audioFormat.sampleSize); //dwTimescale WRITE_4BYTES(afd.audioFormat.sampleSize * afd.audioFormat.rate); //dwDataRate WRITE_4BYTES(0); //dwStartTime WRITE_4BYTES(afd.audioFormat.totalBytes / afd.audioFormat.sampleSize); //dwDataLength WRITE_4BYTES(0); //dwSuggestedBufferSize WRITE_4BYTES(-1); //dwQuality WRITE_4BYTES(afd.audioFormat.sampleSize); //dwSampleSize WRITE_2BYTES(0); //rcFrame WRITE_2BYTES(0); //rcFrame WRITE_2BYTES(0); //rcFrame WRITE_2BYTES(0); //rcFrame WRITE_STRING("strf"); WRITE_4BYTES(18); //"strf" "chunk" size WRITE_2BYTES(afd.audioFormat.format); //wFormatTag WRITE_2BYTES(afd.audioFormat.channels); //nChannels WRITE_4BYTES(afd.audioFormat.rate); //nSamplesPerSec WRITE_4BYTES(afd.audioFormat.sampleSize * afd.audioFormat.rate); //nAvgBytesPerSec WRITE_2BYTES(afd.audioFormat.sampleSize); //nBlockAlign WRITE_2BYTES(afd.audioFormat.bits); //wBitsPerSample WRITE_2BYTES(0); //cbSize } END_CHUNK(); } } END_CHUNK(); afd.moviOffset = bufferWriteIndex; START_CHUNK("LIST"); { WRITE_STRING("movi"); } } } } // creates an AVI file and gets it into a state where writing the actual data can begin static qbool OpenAVI( const char* fileNameNoExt, qbool reOpen ) { static char avi_fileNameNoExt[MAX_QPATH]; static int avi_fileNameIndex; if (!afd.isPiped && reOpen) CloseAVI(CM_SEQUENCE_INCOMPLETE); if (afd.isFileOpen) return qfalse; Com_Memset(&afd, 0, sizeof(aviFileData_t)); afd.isPiped = cl_ffmpeg->integer != 0; // don't start if a framerate has not been chosen if (cl_aviFrameRate->integer <= 0) { Com_Printf("^1ERROR: cl_aviFrameRate must be >= 1\n"); return qfalse; } if (afd.isPiped) { const char* fileExtension = cl_ffmpegOutExt->string; while (*fileExtension == '.') { fileExtension++; } if (cl_ffmpegOutPath->string[0] == '\0') { const char* homePath = Cvar_VariableString("fs_homepath"); const char* final = FS_BuildOSPath(homePath, NULL, va("videos/%s.%s", fileNameNoExt, fileExtension)); Com_sprintf(afd.fileName, sizeof(afd.fileName), final); } else { Com_sprintf(afd.fileName, sizeof(afd.fileName), "%s/%s.%s", cl_ffmpegOutPath->string, fileNameNoExt, fileExtension); FS_ReplaceSeparators(afd.fileName); } // @NOTE: can't double quote the executable's path with _popen const char* pipeCommand; if (cl_ffmpegLog->integer != 0) { pipeCommand = va("%s -f avi -i - %s -y \"%s\" > \"%s.log.txt\" 2>&1", cl_ffmpegExePath->string, cl_ffmpegCommand->string, afd.fileName, afd.fileName); } else { pipeCommand = va("%s -f avi -i - %s -y \"%s\"", cl_ffmpegExePath->string, cl_ffmpegCommand->string, afd.fileName); } afd.mainFile = FS_OpenPipeWrite(pipeCommand); if (afd.mainFile <= 0) { Com_Printf("^1ERROR: failed to open video process for writing\n"); return qfalse; } } else { if (reOpen) { avi_fileNameIndex++; } else { Q_strncpyz(avi_fileNameNoExt, fileNameNoExt, sizeof(avi_fileNameNoExt)); avi_fileNameIndex = 0; } Com_sprintf(afd.fileName, sizeof(afd.fileName), "videos/%s_%03d.avi", avi_fileNameNoExt, avi_fileNameIndex); afd.mainFile = FS_FOpenFileWrite(afd.fileName); afd.indexFile = FS_FOpenFileWrite(va("%s" INDEX_FILE_EXTENSION, afd.fileName)); if (afd.mainFile <= 0 || afd.indexFile <= 0) { Safe_FS_Close(&afd.mainFile); Safe_FS_Close(&afd.indexFile); Com_Printf("^1ERROR: failed to open video file for writing\n"); return qfalse; } } afd.frameRate = cl_aviFrameRate->integer; afd.framePeriod = (int)(1000000.0f / afd.frameRate); afd.width = cls.glconfig.vidWidth; afd.height = cls.glconfig.vidHeight; afd.useMotionJpeg = !afd.isPiped && cl_aviMotionJpeg->integer != 0; const int maxByteCount = PAD(afd.width, 4) * afd.height * 4; afd.captureBuffer = (byte*)Z_Malloc(maxByteCount); afd.encodeBuffer = (byte*)Z_Malloc(maxByteCount); afd.audioFormat.rate = dma.speed; afd.audioFormat.format = WAV_FORMAT_PCM; afd.audioFormat.channels = dma.channels; afd.audioFormat.bits = dma.samplebits; afd.audioFormat.sampleSize = (afd.audioFormat.bits / 8) * afd.audioFormat.channels; if (afd.audioFormat.rate % afd.frameRate) { int suggestRate = afd.frameRate; while ((afd.audioFormat.rate % suggestRate) && suggestRate) --suggestRate; Com_Printf("^3WARNING: cl_aviFrameRate is not a divisor of the audio rate, suggesting %d\n", suggestRate); } afd.hasAudio = qfalse; if (Cvar_VariableIntegerValue("s_initsound")) { afd.hasAudio = (afd.audioFormat.bits == 16 && afd.audioFormat.channels == 2); if (!afd.hasAudio) { Com_Printf("^3WARNING: Audio capture needs 16-bit stereo"); } } // This doesn't write a real header, but allocates the // correct amount of space at the beginning of the file CL_WriteAVIHeader(); Safe_FS_Write(buffer, bufferWriteIndex, afd.mainFile); afd.fileSize = bufferWriteIndex; if (!afd.isPiped) { bufferWriteIndex = 0; START_CHUNK("idx1"); Safe_FS_Write(buffer, bufferWriteIndex, afd.indexFile); } afd.moviSize = 4; // For the "movi" header signature afd.isFileOpen = qtrue; Com_Printf("Recording to %s\n", afd.fileName); return qtrue; } qbool CL_OpenAVIForWriting( const char* fileNameNoExt ) { return OpenAVI(fileNameNoExt, qfalse); } static qbool CheckFileSize( int bytesToAdd ) { const unsigned int newFileSize = afd.fileSize + // Current file size bytesToAdd + // What we want to add (afd.numIndices * 16) + // The index 4; // The index size // "AVI " limit is 1 GB (AVI 1.0) // "AVIX" limit is 2 GB (AVI 2.0 / OpenDML) if (!afd.isPiped && newFileSize > (1 << 30)) { OpenAVI(NULL, qtrue); return qtrue; } return qfalse; } void CL_WriteAVIVideoFrame( const byte* imageBuffer, int size ) { if (!afd.isFileOpen) return; // Chunk header + contents + padding if (CheckFileSize(8 + size + 2)) return; bufferWriteIndex = 0; WRITE_STRING("00dc"); WRITE_4BYTES(size); const int chunkOffset = afd.fileSize - afd.moviOffset - 8; const int chunkSize = 8 + size; const int paddingSize = PAD(size, 2) - size; const byte padding[4] = { 0 }; Safe_FS_Write(buffer, 8, afd.mainFile); Safe_FS_Write(imageBuffer, size, afd.mainFile); Safe_FS_Write(padding, paddingSize, afd.mainFile); afd.fileSize += (chunkSize + paddingSize); afd.numVideoFrames++; afd.moviSize += (chunkSize + paddingSize); if (size > afd.maxRecordSize) afd.maxRecordSize = size; // Write index data if (!afd.isPiped) { bufferWriteIndex = 0; WRITE_STRING("00dc"); //dwIdentifier WRITE_4BYTES(0x00000010); //dwFlags (all frames are KeyFrames) WRITE_4BYTES(chunkOffset); //dwOffset WRITE_4BYTES(size); //dwLength Safe_FS_Write(buffer, 16, afd.indexFile); } afd.numIndices++; } void CL_WriteAVIAudioFrame( const byte* pcmBuffer, int size ) { static byte pcmCaptureBuffer[PCM_BUFFER_SIZE] = { 0 }; static int bytesInBuffer = 0; if (!afd.hasAudio) return; if (!afd.isFileOpen) return; // Chunk header + contents + padding if (CheckFileSize(8 + bytesInBuffer + size + 2)) return; if (bytesInBuffer + size > PCM_BUFFER_SIZE) { Com_Printf("^3WARNING: Audio capture buffer overflow -- truncating\n"); size = PCM_BUFFER_SIZE - bytesInBuffer; } Com_Memcpy(&pcmCaptureBuffer[bytesInBuffer], pcmBuffer, size); bytesInBuffer += size; // Only write if we have a frame's worth of audio const int bytesToWrite = (int)ceil((float)afd.audioFormat.rate / (float)afd.frameRate) * afd.audioFormat.sampleSize; if (bytesInBuffer >= bytesToWrite) { bufferWriteIndex = 0; WRITE_STRING("01wb"); WRITE_4BYTES(bytesInBuffer); const int chunkOffset = afd.fileSize - afd.moviOffset - 8; const int chunkSize = 8 + bytesInBuffer; const int paddingSize = PAD(bytesInBuffer, 2) - bytesInBuffer; const byte padding[4] = { 0 }; Safe_FS_Write(buffer, 8, afd.mainFile); Safe_FS_Write(pcmCaptureBuffer, bytesInBuffer, afd.mainFile); Safe_FS_Write(padding, paddingSize, afd.mainFile); afd.fileSize += (chunkSize + paddingSize); afd.numAudioFrames++; afd.moviSize += (chunkSize + paddingSize); afd.audioFormat.totalBytes += bytesInBuffer; // Write index data if (!afd.isPiped) { bufferWriteIndex = 0; WRITE_STRING("01wb"); //dwIdentifier WRITE_4BYTES(0); //dwFlags WRITE_4BYTES(chunkOffset); //dwOffset WRITE_4BYTES(bytesInBuffer); //dwLength Safe_FS_Write(buffer, 16, afd.indexFile); } afd.numIndices++; bytesInBuffer = 0; } } void CL_TakeVideoFrame() { if (!afd.isFileOpen) return; re.TakeVideoFrame(afd.width, afd.height, afd.captureBuffer, afd.encodeBuffer, afd.useMotionJpeg); } static qbool FixIndices() { int indexSize = afd.numIndices * 16; FS_Seek(afd.indexFile, 4, FS_SEEK_SET); bufferWriteIndex = 0; WRITE_4BYTES(indexSize); Safe_FS_Write(buffer, bufferWriteIndex, afd.indexFile); Safe_FS_Close(&afd.indexFile); // needed in case the follow-up open fails // Open the temp index file const char* const idxFileName = va("%s" INDEX_FILE_EXTENSION, afd.fileName); if ((indexSize = FS_FOpenFileRead(idxFileName, &afd.indexFile, qtrue)) <= 0) { return qfalse; } // Append index to end of avi file int indexRemainder = indexSize; while (indexRemainder > MAX_AVI_BUFFER) { FS_Read(buffer, MAX_AVI_BUFFER, afd.indexFile); Safe_FS_Write(buffer, MAX_AVI_BUFFER, afd.mainFile); afd.fileSize += MAX_AVI_BUFFER; indexRemainder -= MAX_AVI_BUFFER; } FS_Read(buffer, indexRemainder, afd.indexFile); Safe_FS_Write(buffer, indexRemainder, afd.mainFile); afd.fileSize += indexRemainder; // Close and remove temp index file Safe_FS_Close(&afd.indexFile); FS_HomeRemove(idxFileName); // Write the real header FS_Seek(afd.mainFile, 0, FS_SEEK_SET); CL_WriteAVIHeader(); bufferWriteIndex = 4; WRITE_4BYTES(afd.fileSize - 8); // "RIFF" size bufferWriteIndex = afd.moviOffset + 4; // Skip "LIST" WRITE_4BYTES(afd.moviSize); Safe_FS_Write(buffer, bufferWriteIndex, afd.mainFile); return qtrue; } // Closes the AVI file and optionally writes an index chunk static qbool CloseAVI( closeMode_t closeMode ) { if (!afd.isFileOpen) return qfalse; afd.isFileOpen = qfalse; qbool result = qtrue; if (!afd.isPiped) { result = FixIndices(); } Safe_Z_Free(&afd.captureBuffer); Safe_Z_Free(&afd.encodeBuffer); Safe_FS_Close(&afd.mainFile); Safe_FS_Close(&afd.indexFile); Com_Printf("Processed %d:%d V:A frames for %s\n", afd.numVideoFrames, afd.numAudioFrames, afd.fileName); if (closeMode == CM_SEQUENCE_COMPLETE) { S_StopAllSounds(); } return result; } qbool CL_CloseAVI() { return CloseAVI(CM_SEQUENCE_COMPLETE); } qbool CL_VideoRecording() { return afd.isFileOpen; }