spacebox/src/Recorder.cpp
Cocktail Frank 29a694200d Add sb::progress for save files, stats, and achievements
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
2024-10-05 20:43:48 -04:00

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);
}
}
}