Continuously records gameplay in the background when a game is connected. Keeps the last N minutes (configurable) as ~30s self-contained segment files on disk, with auto-trimming and thumbnail generation per segment. C++ CortexRecorder writes .seg binary format in real-time, integrated into StreamingEngine alongside existing ClipRecorder. StreamingManager routes frames to cortex-only engine when not streaming, and enables cortex on the streaming engine during live streams for seamless coexistence. New Cortex tab in bottom navigation with enable toggle, duration presets, storage usage, and session list with thumbnails.
314 lines
10 KiB
C++
314 lines
10 KiB
C++
#include "clip_recorder.h"
|
|
#include "faststart.h"
|
|
|
|
#include <media/NdkMediaMuxer.h>
|
|
#include <media/NdkMediaFormat.h>
|
|
#include <media/NdkMediaCodec.h>
|
|
#include <android/log.h>
|
|
|
|
#include <algorithm>
|
|
#include <fcntl.h>
|
|
#include <unistd.h>
|
|
#include <cstdio>
|
|
|
|
#define TAG "ClipRecorder"
|
|
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
|
#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__)
|
|
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__)
|
|
|
|
void ClipRecorder::Configure(int width, int height,
|
|
uint32_t audioSampleRate, uint32_t audioChannels_,
|
|
int audioBitrate_) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
videoWidth = width;
|
|
videoHeight = height;
|
|
sampleRate = audioSampleRate;
|
|
audioChannels = audioChannels_;
|
|
audioBitrate = audioBitrate_;
|
|
LOGI("Configured: %dx%d, audio %uHz %uch %dbps", width, height,
|
|
audioSampleRate, audioChannels_, audioBitrate_);
|
|
}
|
|
|
|
void ClipRecorder::SetVideoFormat(const uint8_t* codecConfig, uint32_t size) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
videoCodecConfig.assign(codecConfig, codecConfig + size);
|
|
LOGI("Video codec config set: %u bytes", size);
|
|
}
|
|
|
|
void ClipRecorder::SetAudioFormat(const uint8_t* codecConfig, uint32_t size) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
audioCodecConfig.assign(codecConfig, codecConfig + size);
|
|
LOGI("Audio codec config set: %u bytes", size);
|
|
}
|
|
|
|
void ClipRecorder::Start() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
gopBuffer.clear();
|
|
currentGop = GopBuffer();
|
|
audioBuffer.clear();
|
|
active = true;
|
|
LOGI("Started");
|
|
}
|
|
|
|
void ClipRecorder::Stop() {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
active = false;
|
|
gopBuffer.clear();
|
|
currentGop = GopBuffer();
|
|
audioBuffer.clear();
|
|
videoCodecConfig.clear();
|
|
audioCodecConfig.clear();
|
|
LOGI("Stopped");
|
|
}
|
|
|
|
void ClipRecorder::FeedVideoPacket(const uint8_t* data, uint32_t size,
|
|
int64_t timestampUs, bool isKeyframe) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (!active) return;
|
|
|
|
if (isKeyframe) {
|
|
// Finalize current GOP and push to buffer
|
|
if (!currentGop.samples.empty()) {
|
|
gopBuffer.push_back(std::move(currentGop));
|
|
}
|
|
currentGop = GopBuffer();
|
|
currentGop.startTimeUs = timestampUs;
|
|
}
|
|
|
|
VideoSample sample;
|
|
sample.data.assign(data, data + size);
|
|
sample.timestampUs = timestampUs;
|
|
sample.isKeyframe = isKeyframe;
|
|
currentGop.samples.push_back(std::move(sample));
|
|
|
|
TrimBuffers();
|
|
}
|
|
|
|
void ClipRecorder::FeedAudioPacket(const uint8_t* data, uint32_t size,
|
|
int64_t timestampUs) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (!active) return;
|
|
|
|
AudioSample sample;
|
|
sample.data.assign(data, data + size);
|
|
sample.timestampUs = timestampUs;
|
|
audioBuffer.push_back(std::move(sample));
|
|
|
|
TrimBuffers();
|
|
}
|
|
|
|
void ClipRecorder::TrimBuffers() {
|
|
// Trim oldest GOPs to stay under MAX_BUFFER_DURATION_US
|
|
if (gopBuffer.size() < 2) return;
|
|
|
|
int64_t newestTs = 0;
|
|
if (!currentGop.samples.empty()) {
|
|
newestTs = currentGop.samples.back().timestampUs;
|
|
} else if (!gopBuffer.empty() && !gopBuffer.back().samples.empty()) {
|
|
newestTs = gopBuffer.back().samples.back().timestampUs;
|
|
}
|
|
if (newestTs == 0) return;
|
|
|
|
while (!gopBuffer.empty()) {
|
|
int64_t oldestTs = gopBuffer.front().startTimeUs;
|
|
if (newestTs - oldestTs > MAX_BUFFER_DURATION_US && gopBuffer.size() > 1) {
|
|
gopBuffer.pop_front();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Trim audio samples older than oldest remaining video
|
|
if (!gopBuffer.empty()) {
|
|
int64_t videoStart = gopBuffer.front().startTimeUs;
|
|
while (!audioBuffer.empty() && audioBuffer.front().timestampUs < videoStart) {
|
|
audioBuffer.pop_front();
|
|
}
|
|
}
|
|
}
|
|
|
|
bool ClipRecorder::FlushClip(const std::string& outputDir) {
|
|
// Copy data under lock
|
|
std::vector<GopBuffer> gopsCopy;
|
|
std::vector<AudioSample> audioCopy;
|
|
std::vector<uint8_t> videoCsdCopy;
|
|
std::vector<uint8_t> audioCsdCopy;
|
|
int w, h;
|
|
uint32_t sr, ch;
|
|
int abr;
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
if (!active) return false;
|
|
if (gopBuffer.empty()) {
|
|
LOGW("FlushClip: no complete GOPs");
|
|
return false;
|
|
}
|
|
if (videoCodecConfig.empty()) {
|
|
LOGW("FlushClip: no video codec config");
|
|
return false;
|
|
}
|
|
|
|
gopsCopy.assign(gopBuffer.begin(), gopBuffer.end());
|
|
audioCopy.assign(audioBuffer.begin(), audioBuffer.end());
|
|
videoCsdCopy = videoCodecConfig;
|
|
audioCsdCopy = audioCodecConfig;
|
|
w = videoWidth;
|
|
h = videoHeight;
|
|
sr = sampleRate;
|
|
ch = audioChannels;
|
|
abr = audioBitrate;
|
|
}
|
|
|
|
// Mux to temp file
|
|
std::string tempPath = outputDir + "/preview_clip_tmp.mp4";
|
|
std::string finalPath = outputDir + "/preview_clip.mp4";
|
|
|
|
int fd = open(tempPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
|
if (fd < 0) {
|
|
LOGE("FlushClip: failed to open temp file: %s", tempPath.c_str());
|
|
return false;
|
|
}
|
|
|
|
AMediaMuxer* muxer = AMediaMuxer_new(fd, AMEDIAMUXER_OUTPUT_FORMAT_MPEG_4);
|
|
if (!muxer) {
|
|
LOGE("FlushClip: failed to create muxer");
|
|
close(fd);
|
|
return false;
|
|
}
|
|
|
|
// Video track — split Annex-B SPS+PPS into separate csd-0 (SPS) and csd-1 (PPS)
|
|
// MediaCodec CODEC_CONFIG output: [00 00 00 01 SPS] [00 00 00 01 PPS]
|
|
// AMediaMuxer expects csd-0 = SPS NAL (with start code), csd-1 = PPS NAL (with start code)
|
|
const uint8_t* csd = videoCsdCopy.data();
|
|
size_t csdSize = videoCsdCopy.size();
|
|
size_t spsLen = csdSize;
|
|
size_t ppsOffset = 0;
|
|
size_t ppsLen = 0;
|
|
|
|
// Find the second start code (00 00 00 01) to split SPS from PPS
|
|
for (size_t i = 4; i + 3 < csdSize; i++) {
|
|
if (csd[i] == 0x00 && csd[i+1] == 0x00 && csd[i+2] == 0x00 && csd[i+3] == 0x01) {
|
|
spsLen = i;
|
|
ppsOffset = i;
|
|
ppsLen = csdSize - i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
LOGI("FlushClip: csd total=%zu, SPS=%zu bytes, PPS=%zu bytes", csdSize, spsLen, ppsLen);
|
|
|
|
AMediaFormat* videoFormat = AMediaFormat_new();
|
|
AMediaFormat_setString(videoFormat, AMEDIAFORMAT_KEY_MIME, "video/avc");
|
|
AMediaFormat_setInt32(videoFormat, AMEDIAFORMAT_KEY_WIDTH, w);
|
|
AMediaFormat_setInt32(videoFormat, AMEDIAFORMAT_KEY_HEIGHT, h);
|
|
AMediaFormat_setBuffer(videoFormat, "csd-0", csd, spsLen);
|
|
if (ppsLen > 0) {
|
|
AMediaFormat_setBuffer(videoFormat, "csd-1", csd + ppsOffset, ppsLen);
|
|
}
|
|
|
|
ssize_t videoTrack = AMediaMuxer_addTrack(muxer, videoFormat);
|
|
AMediaFormat_delete(videoFormat);
|
|
|
|
if (videoTrack < 0) {
|
|
LOGE("FlushClip: failed to add video track");
|
|
AMediaMuxer_delete(muxer);
|
|
close(fd);
|
|
return false;
|
|
}
|
|
|
|
// Audio track (optional — may not have audio config)
|
|
ssize_t audioTrack = -1;
|
|
if (!audioCsdCopy.empty()) {
|
|
AMediaFormat* audioFormat = AMediaFormat_new();
|
|
AMediaFormat_setString(audioFormat, AMEDIAFORMAT_KEY_MIME, "audio/mp4a-latm");
|
|
AMediaFormat_setInt32(audioFormat, AMEDIAFORMAT_KEY_SAMPLE_RATE, sr);
|
|
AMediaFormat_setInt32(audioFormat, AMEDIAFORMAT_KEY_CHANNEL_COUNT, ch);
|
|
AMediaFormat_setInt32(audioFormat, AMEDIAFORMAT_KEY_BIT_RATE, abr);
|
|
AMediaFormat_setBuffer(audioFormat, "csd-0", audioCsdCopy.data(), audioCsdCopy.size());
|
|
|
|
audioTrack = AMediaMuxer_addTrack(muxer, audioFormat);
|
|
AMediaFormat_delete(audioFormat);
|
|
|
|
if (audioTrack < 0) {
|
|
LOGW("FlushClip: failed to add audio track, continuing video-only");
|
|
audioTrack = -1;
|
|
}
|
|
}
|
|
|
|
media_status_t status = AMediaMuxer_start(muxer);
|
|
if (status != AMEDIA_OK) {
|
|
LOGE("FlushClip: failed to start muxer: %d", status);
|
|
AMediaMuxer_delete(muxer);
|
|
close(fd);
|
|
return false;
|
|
}
|
|
|
|
// Compute base timestamp for zero-based output
|
|
int64_t baseTs = gopsCopy.front().startTimeUs;
|
|
|
|
// Write video samples
|
|
int videoSamplesWritten = 0;
|
|
for (const auto& gop : gopsCopy) {
|
|
for (const auto& sample : gop.samples) {
|
|
AMediaCodecBufferInfo info;
|
|
info.offset = 0;
|
|
info.size = static_cast<int32_t>(sample.data.size());
|
|
info.presentationTimeUs = sample.timestampUs - baseTs;
|
|
info.flags = sample.isKeyframe ? AMEDIACODEC_BUFFER_FLAG_KEY_FRAME : 0;
|
|
|
|
AMediaMuxer_writeSampleData(muxer, videoTrack,
|
|
sample.data.data(), &info);
|
|
videoSamplesWritten++;
|
|
}
|
|
}
|
|
|
|
// Write audio samples
|
|
int audioSamplesWritten = 0;
|
|
if (audioTrack >= 0) {
|
|
for (const auto& sample : audioCopy) {
|
|
// Only include audio within the video time range
|
|
int64_t relTs = sample.timestampUs - baseTs;
|
|
if (relTs < 0) continue;
|
|
|
|
AMediaCodecBufferInfo info;
|
|
info.offset = 0;
|
|
info.size = static_cast<int32_t>(sample.data.size());
|
|
info.presentationTimeUs = relTs;
|
|
info.flags = 0;
|
|
|
|
AMediaMuxer_writeSampleData(muxer, audioTrack,
|
|
sample.data.data(), &info);
|
|
audioSamplesWritten++;
|
|
}
|
|
}
|
|
|
|
AMediaMuxer_stop(muxer);
|
|
AMediaMuxer_delete(muxer);
|
|
close(fd);
|
|
|
|
LOGI("FlushClip: muxed %d video + %d audio samples to temp file",
|
|
videoSamplesWritten, audioSamplesWritten);
|
|
|
|
// Apply faststart (move moov atom to front)
|
|
if (!MoovFastStart(tempPath, finalPath)) {
|
|
LOGW("FlushClip: faststart failed, using non-optimized file");
|
|
rename(tempPath.c_str(), finalPath.c_str());
|
|
} else {
|
|
unlink(tempPath.c_str());
|
|
}
|
|
|
|
LOGI("FlushClip: clip ready at %s", finalPath.c_str());
|
|
|
|
if (clipReadyCallback) {
|
|
clipReadyCallback(finalPath);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void ClipRecorder::SetClipReadyCallback(ClipReadyCallback cb) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
clipReadyCallback = std::move(cb);
|
|
}
|