549 lines
16 KiB
C++
549 lines
16 KiB
C++
// Copyright 2009 Dolphin Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
#include "VideoCommon/FrameDumpFFMpeg.h"
|
|
|
|
#if defined(__FreeBSD__)
|
|
#define __STDC_CONSTANT_MACROS 1
|
|
#endif
|
|
|
|
#include <array>
|
|
#include <sstream>
|
|
#include <string>
|
|
|
|
#include <fmt/chrono.h>
|
|
#include <fmt/format.h>
|
|
|
|
extern "C" {
|
|
#include <libavcodec/avcodec.h>
|
|
#include <libavformat/avformat.h>
|
|
#include <libavutil/error.h>
|
|
#include <libavutil/log.h>
|
|
#include <libavutil/mathematics.h>
|
|
#include <libavutil/opt.h>
|
|
#include <libavutil/pixdesc.h>
|
|
#include <libswscale/swscale.h>
|
|
}
|
|
|
|
#include "Common/ChunkFile.h"
|
|
#include "Common/FileUtil.h"
|
|
#include "Common/Logging/Log.h"
|
|
#include "Common/Logging/LogManager.h"
|
|
#include "Common/MsgHandler.h"
|
|
#include "Common/StringUtil.h"
|
|
|
|
#include "Core/Config/MainSettings.h"
|
|
#include "Core/ConfigManager.h"
|
|
#include "Core/HW/SystemTimers.h"
|
|
#include "Core/HW/VideoInterface.h"
|
|
#include "Core/System.h"
|
|
|
|
#include "VideoCommon/FrameDumper.h"
|
|
#include "VideoCommon/OnScreenDisplay.h"
|
|
#include "VideoCommon/VideoConfig.h"
|
|
|
|
struct FrameDumpContext
|
|
{
|
|
AVFormatContext* format = nullptr;
|
|
AVStream* stream = nullptr;
|
|
AVCodecContext* codec = nullptr;
|
|
AVFrame* src_frame = nullptr;
|
|
AVFrame* scaled_frame = nullptr;
|
|
SwsContext* sws = nullptr;
|
|
|
|
s64 last_pts = AV_NOPTS_VALUE;
|
|
|
|
int width = 0;
|
|
int height = 0;
|
|
|
|
u64 start_ticks = 0;
|
|
u32 savestate_index = 0;
|
|
|
|
bool gave_vfr_warning = false;
|
|
};
|
|
|
|
namespace
|
|
{
|
|
AVRational GetTimeBaseForCurrentRefreshRate()
|
|
{
|
|
auto& vi = Core::System::GetInstance().GetVideoInterface();
|
|
int num;
|
|
int den;
|
|
av_reduce(&num, &den, int(vi.GetTargetRefreshRateDenominator()),
|
|
int(vi.GetTargetRefreshRateNumerator()), std::numeric_limits<int>::max());
|
|
return AVRational{num, den};
|
|
}
|
|
|
|
void InitAVCodec()
|
|
{
|
|
static bool first_run = true;
|
|
if (first_run)
|
|
{
|
|
av_log_set_level(AV_LOG_DEBUG);
|
|
av_log_set_callback([](void* ptr, int level, const char* fmt, va_list vl) {
|
|
if (level < 0)
|
|
level = AV_LOG_DEBUG;
|
|
if (level >= 0)
|
|
level &= 0xff;
|
|
|
|
if (level > av_log_get_level())
|
|
return;
|
|
|
|
auto log_level = Common::Log::LogLevel::LNOTICE;
|
|
if (level >= AV_LOG_ERROR && level < AV_LOG_WARNING)
|
|
log_level = Common::Log::LogLevel::LERROR;
|
|
else if (level >= AV_LOG_WARNING && level < AV_LOG_INFO)
|
|
log_level = Common::Log::LogLevel::LWARNING;
|
|
else if (level >= AV_LOG_INFO && level < AV_LOG_DEBUG)
|
|
log_level = Common::Log::LogLevel::LINFO;
|
|
else if (level >= AV_LOG_DEBUG)
|
|
// keep libav debug messages visible in release build of dolphin
|
|
log_level = Common::Log::LogLevel::LINFO;
|
|
|
|
// Don't perform this formatting if the log level is disabled
|
|
auto* log_manager = Common::Log::LogManager::GetInstance();
|
|
if (log_manager != nullptr &&
|
|
log_manager->IsEnabled(Common::Log::LogType::FRAMEDUMP, log_level))
|
|
{
|
|
constexpr size_t MAX_MSGLEN = 1024;
|
|
char message[MAX_MSGLEN];
|
|
CharArrayFromFormatV(message, MAX_MSGLEN, fmt, vl);
|
|
|
|
GENERIC_LOG_FMT(Common::Log::LogType::FRAMEDUMP, log_level, "{}", message);
|
|
}
|
|
});
|
|
|
|
// TODO: We never call avformat_network_deinit.
|
|
avformat_network_init();
|
|
|
|
first_run = false;
|
|
}
|
|
}
|
|
|
|
std::string GetDumpPath(const std::string& extension, std::time_t time, u32 index)
|
|
{
|
|
if (!g_Config.sDumpPath.empty())
|
|
return g_Config.sDumpPath;
|
|
|
|
const std::string path_prefix =
|
|
File::GetUserPath(D_DUMPFRAMES_IDX) + SConfig::GetInstance().GetGameID();
|
|
|
|
const std::string base_name =
|
|
fmt::format("{}_{:%Y-%m-%d_%H-%M-%S}_{}", path_prefix, fmt::localtime(time), index);
|
|
|
|
const std::string path = fmt::format("{}.{}", base_name, extension);
|
|
|
|
// Ask to delete file.
|
|
if (File::Exists(path))
|
|
{
|
|
if (Config::Get(Config::MAIN_MOVIE_DUMP_FRAMES_SILENT) ||
|
|
AskYesNoFmtT("Delete the existing file '{0}'?", path))
|
|
{
|
|
File::Delete(path);
|
|
}
|
|
else
|
|
{
|
|
// Stop and cancel dumping the video
|
|
return "";
|
|
}
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
std::string AVErrorString(int error)
|
|
{
|
|
std::array<char, AV_ERROR_MAX_STRING_SIZE> msg;
|
|
av_make_error_string(&msg[0], msg.size(), error);
|
|
return fmt::format("{:8x} {}", (u32)error, &msg[0]);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool FFMpegFrameDump::Start(int w, int h, u64 start_ticks)
|
|
{
|
|
if (IsStarted())
|
|
return true;
|
|
|
|
m_savestate_index = 0;
|
|
m_start_time = std::time(nullptr);
|
|
m_file_index = 0;
|
|
|
|
return PrepareEncoding(w, h, start_ticks, m_savestate_index);
|
|
}
|
|
|
|
bool FFMpegFrameDump::PrepareEncoding(int w, int h, u64 start_ticks, u32 savestate_index)
|
|
{
|
|
m_context = std::make_unique<FrameDumpContext>();
|
|
|
|
m_context->width = w;
|
|
m_context->height = h;
|
|
|
|
m_context->start_ticks = start_ticks;
|
|
m_context->savestate_index = savestate_index;
|
|
|
|
InitAVCodec();
|
|
const bool success = CreateVideoFile();
|
|
if (!success)
|
|
{
|
|
CloseVideoFile();
|
|
OSD::AddMessage("FrameDump Start failed");
|
|
}
|
|
return success;
|
|
}
|
|
|
|
bool FFMpegFrameDump::CreateVideoFile()
|
|
{
|
|
const std::string& format = g_Config.sDumpFormat;
|
|
|
|
const std::string dump_path = GetDumpPath(format, m_start_time, m_file_index);
|
|
|
|
if (dump_path.empty())
|
|
return false;
|
|
|
|
File::CreateFullPath(dump_path);
|
|
|
|
auto* const output_format = av_guess_format(format.c_str(), dump_path.c_str(), nullptr);
|
|
if (!output_format)
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Invalid format {}", format);
|
|
return false;
|
|
}
|
|
|
|
if (avformat_alloc_output_context2(&m_context->format, output_format, nullptr,
|
|
dump_path.c_str()) < 0)
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Could not allocate output context");
|
|
return false;
|
|
}
|
|
|
|
const std::string& codec_name = g_Config.bUseFFV1 ? "ffv1" : g_Config.sDumpCodec;
|
|
|
|
AVCodecID codec_id = output_format->video_codec;
|
|
|
|
if (!codec_name.empty())
|
|
{
|
|
const AVCodecDescriptor* const codec_desc = avcodec_descriptor_get_by_name(codec_name.c_str());
|
|
if (codec_desc)
|
|
codec_id = codec_desc->id;
|
|
else
|
|
WARN_LOG_FMT(FRAMEDUMP, "Invalid codec {}", codec_name);
|
|
}
|
|
|
|
const AVCodec* codec = nullptr;
|
|
|
|
if (!g_Config.sDumpEncoder.empty())
|
|
{
|
|
codec = avcodec_find_encoder_by_name(g_Config.sDumpEncoder.c_str());
|
|
if (!codec)
|
|
WARN_LOG_FMT(FRAMEDUMP, "Invalid encoder {}", g_Config.sDumpEncoder);
|
|
}
|
|
if (!codec)
|
|
codec = avcodec_find_encoder(codec_id);
|
|
|
|
m_context->codec = avcodec_alloc_context3(codec);
|
|
if (!codec || !m_context->codec)
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Could not find encoder or allocate codec context");
|
|
return false;
|
|
}
|
|
|
|
// Force XVID FourCC for better compatibility when using H.263
|
|
if (codec->id == AV_CODEC_ID_MPEG4)
|
|
m_context->codec->codec_tag = MKTAG('X', 'V', 'I', 'D');
|
|
|
|
const auto time_base = GetTimeBaseForCurrentRefreshRate();
|
|
|
|
INFO_LOG_FMT(FRAMEDUMP, "Creating video file: {} x {} @ {}/{} fps", m_context->width,
|
|
m_context->height, time_base.den, time_base.num);
|
|
|
|
m_context->codec->codec_type = AVMEDIA_TYPE_VIDEO;
|
|
m_context->codec->bit_rate = static_cast<int64_t>(g_Config.iBitrateKbps) * 1000;
|
|
m_context->codec->width = m_context->width;
|
|
m_context->codec->height = m_context->height;
|
|
m_context->codec->time_base = time_base;
|
|
m_context->codec->gop_size = 1;
|
|
m_context->codec->level = 1;
|
|
|
|
AVPixelFormat pix_fmt = AV_PIX_FMT_NONE;
|
|
|
|
const std::string& pixel_format_string = g_Config.sDumpPixelFormat;
|
|
if (!pixel_format_string.empty())
|
|
{
|
|
pix_fmt = av_get_pix_fmt(pixel_format_string.c_str());
|
|
if (pix_fmt == AV_PIX_FMT_NONE)
|
|
WARN_LOG_FMT(FRAMEDUMP, "Invalid pixel format {}", pixel_format_string);
|
|
}
|
|
|
|
if (pix_fmt == AV_PIX_FMT_NONE)
|
|
{
|
|
if (m_context->codec->codec_id == AV_CODEC_ID_FFV1)
|
|
pix_fmt = AV_PIX_FMT_BGR0;
|
|
else if (m_context->codec->codec_id == AV_CODEC_ID_UTVIDEO)
|
|
pix_fmt = AV_PIX_FMT_GBRP;
|
|
else
|
|
pix_fmt = AV_PIX_FMT_YUV420P;
|
|
}
|
|
|
|
m_context->codec->pix_fmt = pix_fmt;
|
|
|
|
if (m_context->codec->codec_id == AV_CODEC_ID_UTVIDEO)
|
|
av_opt_set_int(m_context->codec->priv_data, "pred", 3, 0); // median
|
|
|
|
if (output_format->flags & AVFMT_GLOBALHEADER)
|
|
m_context->codec->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
|
|
|
|
if (avcodec_open2(m_context->codec, codec, nullptr) < 0)
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Could not open codec");
|
|
return false;
|
|
}
|
|
|
|
m_context->src_frame = av_frame_alloc();
|
|
m_context->scaled_frame = av_frame_alloc();
|
|
|
|
m_context->scaled_frame->format = m_context->codec->pix_fmt;
|
|
m_context->scaled_frame->width = m_context->width;
|
|
m_context->scaled_frame->height = m_context->height;
|
|
|
|
if (av_frame_get_buffer(m_context->scaled_frame, 1))
|
|
return false;
|
|
|
|
m_context->stream = avformat_new_stream(m_context->format, codec);
|
|
if (!m_context->stream ||
|
|
avcodec_parameters_from_context(m_context->stream->codecpar, m_context->codec) < 0)
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Could not create stream");
|
|
return false;
|
|
}
|
|
|
|
m_context->stream->time_base = m_context->codec->time_base;
|
|
|
|
NOTICE_LOG_FMT(FRAMEDUMP, "Opening file {} for dumping", dump_path);
|
|
if (avio_open(&m_context->format->pb, dump_path.c_str(), AVIO_FLAG_WRITE) < 0 ||
|
|
avformat_write_header(m_context->format, nullptr))
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Could not open {}", dump_path);
|
|
return false;
|
|
}
|
|
|
|
if (av_cmp_q(m_context->stream->time_base, time_base) != 0)
|
|
{
|
|
WARN_LOG_FMT(FRAMEDUMP, "Stream time base differs at {}/{}", m_context->stream->time_base.den,
|
|
m_context->stream->time_base.num);
|
|
}
|
|
|
|
OSD::AddMessage(fmt::format("Dumping Frames to \"{}\" ({}x{})", dump_path, m_context->width,
|
|
m_context->height));
|
|
return true;
|
|
}
|
|
|
|
bool FFMpegFrameDump::IsFirstFrameInCurrentFile() const
|
|
{
|
|
return m_context->last_pts == AV_NOPTS_VALUE;
|
|
}
|
|
|
|
void FFMpegFrameDump::AddFrame(const FrameData& frame)
|
|
{
|
|
// Are we even dumping?
|
|
if (!IsStarted())
|
|
return;
|
|
|
|
CheckForConfigChange(frame);
|
|
|
|
// Handle failure after a config change.
|
|
if (!IsStarted())
|
|
return;
|
|
|
|
// Calculate presentation timestamp from ticks since start.
|
|
const s64 pts = av_rescale_q(frame.state.ticks - m_context->start_ticks,
|
|
AVRational{1, int(SystemTimers::GetTicksPerSecond())},
|
|
m_context->codec->time_base);
|
|
|
|
if (!IsFirstFrameInCurrentFile())
|
|
{
|
|
if (pts <= m_context->last_pts)
|
|
{
|
|
WARN_LOG_FMT(FRAMEDUMP, "PTS delta < 1. Current frame will not be dumped.");
|
|
return;
|
|
}
|
|
else if (pts > m_context->last_pts + 1 && !m_context->gave_vfr_warning)
|
|
{
|
|
WARN_LOG_FMT(FRAMEDUMP, "PTS delta > 1. Resulting file will have variable frame rate. "
|
|
"Subsequent occurrences will not be reported.");
|
|
m_context->gave_vfr_warning = true;
|
|
}
|
|
}
|
|
|
|
constexpr AVPixelFormat pix_fmt = AV_PIX_FMT_RGBA;
|
|
|
|
m_context->src_frame->data[0] = const_cast<u8*>(frame.data);
|
|
m_context->src_frame->linesize[0] = frame.stride;
|
|
m_context->src_frame->format = pix_fmt;
|
|
m_context->src_frame->width = m_context->width;
|
|
m_context->src_frame->height = m_context->height;
|
|
|
|
// Convert image from RGBA to desired pixel format.
|
|
m_context->sws = sws_getCachedContext(
|
|
m_context->sws, frame.width, frame.height, pix_fmt, m_context->width, m_context->height,
|
|
m_context->codec->pix_fmt, SWS_BICUBIC, nullptr, nullptr, nullptr);
|
|
if (m_context->sws)
|
|
{
|
|
sws_scale(m_context->sws, m_context->src_frame->data, m_context->src_frame->linesize, 0,
|
|
frame.height, m_context->scaled_frame->data, m_context->scaled_frame->linesize);
|
|
}
|
|
|
|
m_context->last_pts = pts;
|
|
m_context->scaled_frame->pts = pts;
|
|
|
|
if (const int error = avcodec_send_frame(m_context->codec, m_context->scaled_frame))
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Error while encoding video: {}", AVErrorString(error));
|
|
return;
|
|
}
|
|
|
|
ProcessPackets();
|
|
}
|
|
|
|
void FFMpegFrameDump::ProcessPackets()
|
|
{
|
|
auto pkt = std::unique_ptr<AVPacket, std::function<void(AVPacket*)>>(
|
|
av_packet_alloc(), [](AVPacket* packet) { av_packet_free(&packet); });
|
|
|
|
if (!pkt)
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Could not allocate packet");
|
|
return;
|
|
}
|
|
|
|
while (true)
|
|
{
|
|
const int receive_error = avcodec_receive_packet(m_context->codec, pkt.get());
|
|
|
|
if (receive_error == AVERROR(EAGAIN) || receive_error == AVERROR_EOF)
|
|
{
|
|
// We have processed all available packets.
|
|
break;
|
|
}
|
|
|
|
if (receive_error)
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Error receiving packet: {}", AVErrorString(receive_error));
|
|
break;
|
|
}
|
|
|
|
av_packet_rescale_ts(pkt.get(), m_context->codec->time_base, m_context->stream->time_base);
|
|
pkt->stream_index = m_context->stream->index;
|
|
|
|
if (const int write_error = av_interleaved_write_frame(m_context->format, pkt.get()))
|
|
{
|
|
ERROR_LOG_FMT(FRAMEDUMP, "Error writing packet: {}", AVErrorString(write_error));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void FFMpegFrameDump::Stop()
|
|
{
|
|
if (!IsStarted())
|
|
return;
|
|
|
|
// Signal end of stream to encoder.
|
|
if (const int flush_error = avcodec_send_frame(m_context->codec, nullptr))
|
|
WARN_LOG_FMT(FRAMEDUMP, "Error sending flush packet: {}", AVErrorString(flush_error));
|
|
|
|
ProcessPackets();
|
|
av_write_trailer(m_context->format);
|
|
CloseVideoFile();
|
|
|
|
NOTICE_LOG_FMT(FRAMEDUMP, "Stopping frame dump");
|
|
OSD::AddMessage("Stopped dumping frames");
|
|
}
|
|
|
|
bool FFMpegFrameDump::IsStarted() const
|
|
{
|
|
return m_context != nullptr;
|
|
}
|
|
|
|
void FFMpegFrameDump::CloseVideoFile()
|
|
{
|
|
av_frame_free(&m_context->src_frame);
|
|
av_frame_free(&m_context->scaled_frame);
|
|
|
|
avcodec_free_context(&m_context->codec);
|
|
|
|
if (m_context->format)
|
|
avio_closep(&m_context->format->pb);
|
|
|
|
avformat_free_context(m_context->format);
|
|
|
|
if (m_context->sws)
|
|
sws_freeContext(m_context->sws);
|
|
|
|
m_context.reset();
|
|
}
|
|
|
|
void FFMpegFrameDump::DoState(PointerWrap& p)
|
|
{
|
|
if (p.IsReadMode())
|
|
++m_savestate_index;
|
|
}
|
|
|
|
void FFMpegFrameDump::CheckForConfigChange(const FrameData& frame)
|
|
{
|
|
bool restart_dump = false;
|
|
|
|
// We check here to see if the requested width and height have changed since the last frame which
|
|
// was dumped, then create a new file accordingly. However, is it possible for the height
|
|
// (possibly width as well, but no examples known) to have a value of zero. This can occur as the
|
|
// VI is able to be set to a zero value for height/width to disable output. If this is the case,
|
|
// simply keep the last known resolution of the video for the added frame.
|
|
if ((frame.width != m_context->width || frame.height != m_context->height) &&
|
|
(frame.width > 0 && frame.height > 0))
|
|
{
|
|
INFO_LOG_FMT(FRAMEDUMP, "Starting new dump on resolution change.");
|
|
restart_dump = true;
|
|
}
|
|
else if (!IsFirstFrameInCurrentFile() &&
|
|
frame.state.savestate_index != m_context->savestate_index)
|
|
{
|
|
INFO_LOG_FMT(FRAMEDUMP, "Starting new dump on savestate load.");
|
|
restart_dump = true;
|
|
}
|
|
else if (frame.state.refresh_rate_den != m_context->codec->time_base.num ||
|
|
frame.state.refresh_rate_num != m_context->codec->time_base.den)
|
|
{
|
|
INFO_LOG_FMT(FRAMEDUMP, "Starting new dump on refresh rate change {}/{} vs {}/{}.",
|
|
m_context->codec->time_base.den, m_context->codec->time_base.num,
|
|
frame.state.refresh_rate_num, frame.state.refresh_rate_den);
|
|
restart_dump = true;
|
|
}
|
|
|
|
if (restart_dump)
|
|
{
|
|
Stop();
|
|
++m_file_index;
|
|
PrepareEncoding(frame.width, frame.height, frame.state.ticks, frame.state.savestate_index);
|
|
}
|
|
}
|
|
|
|
FrameState FFMpegFrameDump::FetchState(u64 ticks, int frame_number) const
|
|
{
|
|
FrameState state;
|
|
state.ticks = ticks;
|
|
state.frame_number = frame_number;
|
|
state.savestate_index = m_savestate_index;
|
|
|
|
const auto time_base = GetTimeBaseForCurrentRefreshRate();
|
|
state.refresh_rate_num = time_base.den;
|
|
state.refresh_rate_den = time_base.num;
|
|
return state;
|
|
}
|
|
|
|
FFMpegFrameDump::FFMpegFrameDump() = default;
|
|
|
|
FFMpegFrameDump::~FFMpegFrameDump()
|
|
{
|
|
Stop();
|
|
}
|