sb::progress encapsulates save progress objects, stats, and achievements. sb::progress::Progress objects track progress using stats, achievements, and any arbitrary user created game data. The data can be saved and loaded through JSON serialization. The sb::progress::Progress class is also used to unlock achievements and update stats. They can optionally be synced directly with an app's corresponding Stat and Achievement objects on Steam through the progress object. The sb::progress::Stat and sb::progress::Achievement classes represent user defined stats and achievements. They have corresponding objects that parse and load stats and achievements from a config file. Other changes include: JSON/Configuration: - Upgrade nlohmann::json to v3.11.3 - Use nlohmann::json::merge_patch to merge JSON into configuration, which provides recursive merge - Move json_from_file from Configuration to sb:: since it was already static and provides general access to a JSON object - Move Configuration into sb:: namespace - Move Configuration::access into sb::json_access to share with Progress class Steam API: - Automatically request stats when Steam API is initialized - Add responses for events to Steam API event loop: UserStatsReceived, UserStatsStored, UserAchievementStored - Add call for storing stats on Steam's servers - Add publicly accessible boolean that controls whether or not sb::init tries to load Steam Testing: - Capture log messages with stdout in the test program before logging is initialized in the Game object - In test cases that run a game, record start time within the mainloop to prevent the start time from lagging when the game takes a while to start running - New tests: sb::progress, sb::json_access, setting stats and achievements with the Steam API - Add another Dockerized Ubuntu build launching that launches the test on the container after it is built Bug fix: - Remove PostMix processor when Recorder object is destroyed to prevent the processor function (which doesn't exist anymore) from being called Linting: - superxbr.cpp, Delegate.hpp
372 lines
13 KiB
C++
372 lines
13 KiB
C++
/* +-------------------------------------------------------+
|
|
____/ \____ /| Open source game framework licensed to freely use, |
|
|
\ / / | copy, and modify - created for dank.game |
|
|
+--\ ^__^ /--+ | |
|
|
| ~/ \~ | | Download at https://open.shampoo.ooo/shampoo/spacebox |
|
|
| ~~~~~~~~~~~~ | +-------------------------------------------------------+
|
|
| SPACE ~~~~~ | /
|
|
| ~~~~~~~ BOX |/
|
|
+-------------*/
|
|
|
|
#include "gif-h/gif.h"
|
|
#include "Game.hpp"
|
|
#include "extension.hpp"
|
|
#include "Recorder.hpp"
|
|
|
|
using namespace sb;
|
|
|
|
/* Create a Recorder instance. Subscribe to command input and set audio callback. */
|
|
Recorder::Recorder(sb::Configuration& configuration, sb::Display& display) : configuration(configuration), display(display)
|
|
{
|
|
Mix_SetPostMix(Recorder::process_audio, this);
|
|
}
|
|
|
|
Recorder::~Recorder()
|
|
{
|
|
Mix_SetPostMix(nullptr, nullptr);
|
|
}
|
|
|
|
void Recorder::toggle()
|
|
{
|
|
if (is_recording)
|
|
{
|
|
end_recording();
|
|
}
|
|
else if (!writing_recording)
|
|
{
|
|
start_recording();
|
|
}
|
|
else
|
|
{
|
|
sb::Log::log("Writing in progress, cannot start recording", sb::Log::WARN);
|
|
}
|
|
}
|
|
|
|
/* Returns length of a recorded video frame in seconds */
|
|
float Recorder::frame_length()
|
|
{
|
|
return configuration("recording", "video frame length");
|
|
}
|
|
|
|
void Recorder::capture_screen()
|
|
{
|
|
SDL_Surface* surface = display.screen_surface();
|
|
fs::path directory = configuration("recording", "screenshot directory").get<std::string>();
|
|
fs::create_directories(directory);
|
|
std::string prefix = configuration("recording", "screenshot prefix").get<std::string>();
|
|
std::string extension = configuration("recording", "screenshot extension").get<std::string>();
|
|
int zfill = configuration("recording", "screenshot zfill");
|
|
fs::path path = sb::get_next_file_name(directory, zfill, prefix, extension);
|
|
IMG_SavePNG(surface, path.string().c_str());
|
|
SDL_FreeSurface(surface);
|
|
std::ostringstream message;
|
|
message << "saved screenshot to " << path;
|
|
sb::Log::log(message);
|
|
}
|
|
|
|
/* Writes a video of what was just displayed on the screen up until the function was called. The length
|
|
* of the video is determined by the stash length written in the configuration. This is accomplished by
|
|
* writing the contents of the most recent Stash object. */
|
|
void Recorder::grab_stash()
|
|
{
|
|
if (!is_recording and !writing_recording)
|
|
{
|
|
int length = configuration("recording", "max stash length");
|
|
std::ostringstream message;
|
|
message << "stashing most recent " << length << " seconds of video";
|
|
sb::Log::log(message);
|
|
most_recent_stash = current_stash;
|
|
current_stash = Stash();
|
|
writing_recording = true;
|
|
std::function<void()> f = std::bind(&Recorder::write_most_recent_frames, this);
|
|
std::thread writing {f};
|
|
writing.detach();
|
|
}
|
|
else
|
|
{
|
|
sb::Log::log("recording in progress, cannot grab most recent frames");
|
|
}
|
|
}
|
|
|
|
void Recorder::write_most_recent_frames()
|
|
{
|
|
make_directory();
|
|
write_stash_frames(&most_recent_stash);
|
|
open_audio_file();
|
|
while (!most_recent_stash.audio_buffers.empty())
|
|
{
|
|
write_audio(most_recent_stash.audio_buffers.front(), most_recent_stash.audio_buffer_lengths.front());
|
|
most_recent_stash.audio_buffers.erase(most_recent_stash.audio_buffers.begin());
|
|
most_recent_stash.audio_buffer_lengths.erase(most_recent_stash.audio_buffer_lengths.begin());
|
|
}
|
|
audio_file.close();
|
|
if (configuration("recording", "write mp4"))
|
|
{
|
|
write_mp4();
|
|
}
|
|
std::ostringstream message;
|
|
message << "wrote video frames to " << current_video_directory;
|
|
sb::Log::log(message);
|
|
writing_recording = false;
|
|
}
|
|
|
|
void Recorder::start_recording()
|
|
{
|
|
if (!writing_recording)
|
|
{
|
|
sb::Log::log("starting recording");
|
|
is_recording = true;
|
|
video_stashes.push_back(Stash());
|
|
make_directory();
|
|
open_audio_file();
|
|
}
|
|
else
|
|
{
|
|
sb::Log::log("writing in progress, cannot start recording", sb::Log::WARN);
|
|
}
|
|
}
|
|
|
|
void Recorder::open_audio_file()
|
|
{
|
|
std::stringstream audio_path;
|
|
audio_path << current_video_directory.string() << ".raw";
|
|
current_audio_path = audio_path.str();
|
|
audio_file.open(audio_path.str(), std::ios::binary);
|
|
}
|
|
|
|
void Recorder::add_frame()
|
|
{
|
|
glm::ivec2 size = display.window_size();
|
|
int bytes = sb::Display::bpp / 8 * size.x * size.y;
|
|
unsigned char* pixels = new unsigned char[bytes];
|
|
display.screen_pixels(pixels, size.x, size.y);
|
|
int max_length = configuration("recording", "max stash length");
|
|
float length = frame_length() * current_stash.pixel_buffers.size();
|
|
if (length > max_length)
|
|
{
|
|
delete[] current_stash.pixel_buffers.front();
|
|
current_stash.pixel_buffers.erase(current_stash.pixel_buffers.begin());
|
|
current_stash.flipped.erase(current_stash.flipped.begin());
|
|
}
|
|
current_stash.pixel_buffers.push_back(pixels);
|
|
current_stash.flipped.push_back(true);
|
|
if (is_recording)
|
|
{
|
|
unsigned char* vid_pixels = new unsigned char[bytes];
|
|
memcpy(vid_pixels, pixels, bytes);
|
|
video_stashes.back().pixel_buffers.push_back(vid_pixels);
|
|
video_stashes.back().flipped.push_back(true);
|
|
if (video_stashes.back().pixel_buffers.size() * frame_length() > max_length)
|
|
{
|
|
std::function<void(Stash*)> f = std::bind(&Recorder::write_stash_frames, this, std::placeholders::_1);
|
|
std::thread writing(f, &video_stashes.back());
|
|
writing.detach();
|
|
video_stashes.push_back(Stash(video_stashes.back().frame_offset + video_stashes.back().pixel_buffers.size()));
|
|
}
|
|
}
|
|
}
|
|
|
|
float Recorder::get_memory_size() const
|
|
{
|
|
glm::ivec2 window = display.window_size();
|
|
int bytes_per_frame = sb::Display::bpp / 8 * window.x * window.y;
|
|
int size_in_bytes = 0;
|
|
for (const Stash& stash : in_game_stashes)
|
|
{
|
|
size_in_bytes += stash.pixel_buffers.size() * bytes_per_frame;
|
|
for (const int& length : stash.audio_buffer_lengths)
|
|
{
|
|
size_in_bytes += length;
|
|
}
|
|
}
|
|
for (const Stash& stash : video_stashes)
|
|
{
|
|
size_in_bytes += stash.pixel_buffers.size() * bytes_per_frame;
|
|
}
|
|
size_in_bytes += current_stash.pixel_buffers.size() * bytes_per_frame;
|
|
for (const int& length : current_stash.audio_buffer_lengths)
|
|
{
|
|
size_in_bytes += length;
|
|
}
|
|
size_in_bytes += most_recent_stash.pixel_buffers.size() * bytes_per_frame;
|
|
for (const int& length : most_recent_stash.audio_buffer_lengths)
|
|
{
|
|
size_in_bytes += length;
|
|
}
|
|
return size_in_bytes / 1'000'000.0;
|
|
}
|
|
|
|
void Recorder::log_video_memory_size() const
|
|
{
|
|
std::ostringstream message;
|
|
message << "Video memory size is " << std::fixed << std::setprecision(2) << get_memory_size() << "MB";
|
|
sb::Log::log(message);
|
|
}
|
|
|
|
void Recorder::make_directory()
|
|
{
|
|
fs::path root = configuration("recording", "video directory").get<std::string>();
|
|
fs::create_directories(root);
|
|
fs::path directory = sb::get_next_file_name(root, 5, "video-");
|
|
fs::create_directories(directory);
|
|
current_video_directory = directory;
|
|
}
|
|
|
|
void Recorder::write_stash_frames(Stash* stash)
|
|
{
|
|
SDL_Log("Writing stash offset %i to %s...", stash->frame_offset, current_video_directory.string().c_str());
|
|
SDL_Surface* frame;
|
|
GifWriter gif_writer;
|
|
float gif_frame_length = configuration("recording", "gif frame length");
|
|
fs::path gif_path = sb::get_next_file_name(current_video_directory, 3, "gif-", ".gif");
|
|
float elapsed = 0, last_gif_write = 0, gif_write_overflow = 0;
|
|
for (int ii = stash->frame_offset; !stash->pixel_buffers.empty(); ii++)
|
|
{
|
|
frame = display.screen_surface_from_pixels(stash->pixel_buffers.front(), stash->flipped.front());
|
|
std::stringstream name;
|
|
name << sb::pad(ii, 5) << ".png";
|
|
fs::path path = current_video_directory / name.str();
|
|
IMG_SavePNG(frame, path.string().c_str());
|
|
if (ii == stash->frame_offset || elapsed - last_gif_write + gif_write_overflow >= gif_frame_length)
|
|
{
|
|
if (ii == stash->frame_offset)
|
|
{
|
|
GifBegin(&gif_writer, gif_path.string().c_str(), frame->w, frame->h, gif_frame_length * 100);
|
|
}
|
|
else
|
|
{
|
|
gif_write_overflow += elapsed - (last_gif_write + gif_frame_length);
|
|
last_gif_write = elapsed;
|
|
}
|
|
SDL_Surface* converted = SDL_ConvertSurfaceFormat(frame, SDL_PIXELFORMAT_ABGR8888, 0);
|
|
GifWriteFrame(&gif_writer, (const uint8_t*) converted->pixels, frame->w, frame->h, gif_frame_length * 100);
|
|
}
|
|
elapsed += frame_length();
|
|
delete[] stash->pixel_buffers.front();
|
|
stash->pixel_buffers.erase(stash->pixel_buffers.begin());
|
|
stash->flipped.erase(stash->flipped.begin());
|
|
SDL_FreeSurface(frame);
|
|
}
|
|
GifEnd(&gif_writer);
|
|
}
|
|
|
|
void Recorder::keep_stash()
|
|
{
|
|
in_game_stashes.push_back(current_stash);
|
|
current_stash = Stash();
|
|
auto max_stashes = configuration("recording", "max in game stashes");
|
|
if (in_game_stashes.size() > max_stashes)
|
|
{
|
|
Stash& stash = in_game_stashes.front();
|
|
while (not stash.pixel_buffers.empty())
|
|
{
|
|
delete[] stash.pixel_buffers.back();
|
|
stash.pixel_buffers.pop_back();
|
|
stash.flipped.pop_back();
|
|
}
|
|
in_game_stashes.erase(in_game_stashes.begin());
|
|
}
|
|
}
|
|
|
|
void Recorder::end_recording()
|
|
{
|
|
std::cout << "Ending recording..." << std::endl;
|
|
audio_file.close();
|
|
is_recording = false;
|
|
writing_recording = true;
|
|
std::function<void()> f = std::bind(&Recorder::finish_writing_video, this);
|
|
std::thread finishing(f);
|
|
finishing.detach();
|
|
}
|
|
|
|
void Recorder::finish_writing_video()
|
|
{
|
|
write_stash_frames(&video_stashes.back());
|
|
int count;
|
|
while (true)
|
|
{
|
|
count = 0;
|
|
for (Stash& stash : video_stashes)
|
|
{
|
|
count += stash.pixel_buffers.size();
|
|
}
|
|
if (count == 0)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
video_stashes.clear();
|
|
if (configuration("recording", "write mp4"))
|
|
{
|
|
write_mp4();
|
|
}
|
|
std::cout << "Wrote video frames to " << current_video_directory.string() << std::endl;
|
|
writing_recording = false;
|
|
}
|
|
|
|
/* Launch a system command that calls ffmpeg to write an x264 encoded MP4 from the image frames written.
|
|
* This requires ffmpeg to be installed on the user's system. Might only work on Linux (?) */
|
|
void Recorder::write_mp4()
|
|
{
|
|
glm::ivec2 size = display.window_size();
|
|
std::ostringstream mp4_command;
|
|
std::string pixel_format = configuration("recording", "mp4 pixel format").get<std::string>();
|
|
int audio_frequency;
|
|
Mix_QuerySpec(&audio_frequency, nullptr, nullptr);
|
|
fs::path images_match = current_video_directory / "%05d.png";
|
|
int frame_count = 0;
|
|
fs::directory_iterator directory = fs::directory_iterator(current_video_directory);
|
|
for (auto iter = fs::begin(directory); iter != fs::end(directory); iter++)
|
|
{
|
|
frame_count++;
|
|
}
|
|
float video_length = frame_count * frame_length();
|
|
mp4_command << "ffmpeg -f s16le -ac 2 -ar " << audio_frequency << " -i " << current_audio_path.string() <<
|
|
" -f image2 -framerate " << (1.0f / frame_length()) <<
|
|
" -i " << images_match.string() << " -s " << size.x << "x" << size.y << " -t " << video_length <<
|
|
" -c:v libx264 -crf 17 -pix_fmt " << pixel_format << " " <<
|
|
current_video_directory.string() << ".mp4";
|
|
std::string mp4_command_str = mp4_command.str();
|
|
std::cout << mp4_command_str << std::endl;
|
|
std::system(mp4_command_str.c_str());
|
|
}
|
|
|
|
void Recorder::write_audio(Uint8* stream, int len)
|
|
{
|
|
audio_file.write(reinterpret_cast<char*>(stream), len);
|
|
}
|
|
|
|
void Recorder::update(float timestamp)
|
|
{
|
|
if (is_recording && get_memory_size() > configuration("recording", "max video memory"))
|
|
{
|
|
end_recording();
|
|
}
|
|
animation.frame_length(frame_length());
|
|
animation.update(timestamp);
|
|
}
|
|
|
|
void Recorder::process_audio(void* context, Uint8* stream, int len)
|
|
{
|
|
Recorder* recorder = static_cast<Recorder*>(context);
|
|
if (recorder->configuration("recording", "enabled"))
|
|
{
|
|
int max_length = recorder->configuration("recording", "max stash length");
|
|
float length = recorder->frame_length() * recorder->current_stash.pixel_buffers.size();
|
|
if (length > max_length)
|
|
{
|
|
delete[] recorder->current_stash.audio_buffers.front();
|
|
recorder->current_stash.audio_buffers.erase(recorder->current_stash.audio_buffers.begin());
|
|
recorder->current_stash.audio_buffer_lengths.erase(recorder->current_stash.audio_buffer_lengths.begin());
|
|
}
|
|
Uint8* stream_copy = new Uint8[len];
|
|
std::memcpy(stream_copy, stream, len);
|
|
recorder->current_stash.audio_buffers.push_back(stream_copy);
|
|
recorder->current_stash.audio_buffer_lengths.push_back(len);
|
|
if (recorder->is_recording)
|
|
{
|
|
recorder->write_audio(stream_copy, len);
|
|
}
|
|
}
|
|
}
|