diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d019ae6..60da28d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,6 @@ name: Build on: push: - branches: [master] tags: ['v*'] pull_request: branches: [master] @@ -21,6 +20,7 @@ jobs: run: cmake --build build --config Release - name: Upload artifacts + if: startsWith(github.ref, 'refs/tags/v') uses: actions/upload-artifact@v4 with: name: Krec2MP4 diff --git a/CMakeLists.txt b/CMakeLists.txt index 2df3fa1..3494c40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ target_link_libraries(Krec2MP4Lib PUBLIC # --- CLI executable --- add_executable(Krec2MP4 src/main.cpp + res/app.rc ) target_link_libraries(Krec2MP4 PRIVATE @@ -65,6 +66,7 @@ set_target_properties(AudioCapturePlugin PROPERTIES if(WIN32) add_executable(Krec2MP4_GUI WIN32 src/gui_main.cpp + res/app.rc ) target_link_libraries(Krec2MP4_GUI PRIVATE diff --git a/res/app.ico b/res/app.ico new file mode 100644 index 0000000..5910f45 Binary files /dev/null and b/res/app.ico differ diff --git a/res/app.rc b/res/app.rc new file mode 100644 index 0000000..e4cde82 --- /dev/null +++ b/res/app.rc @@ -0,0 +1,3 @@ +#include "../src/gui_resources.h" + +IDI_APPICON ICON "app.ico" diff --git a/src/converter.cpp b/src/converter.cpp index d3f7ae7..5ec90ac 100644 --- a/src/converter.cpp +++ b/src/converter.cpp @@ -146,9 +146,10 @@ bool check_ffmpeg(const std::string& ffmpeg_path) { } std::string make_output_path(const std::string& input_path, const std::string& output_path) { - if (!output_path.empty()) return output_path; - fs::path p(input_path); - p.replace_extension(".mp4"); + fs::path p = output_path.empty() ? fs::path(input_path) : fs::path(output_path); + if (p.extension() != ".mp4") { + p.replace_extension(".mp4"); + } return p.string(); } @@ -383,6 +384,9 @@ bool convert_one(const std::string& krec_path, const std::string& output_path, converter_log(LOG_INFO, "Running emulation (%d input frames)...", krec.total_input_frames); m64p_error ret = emu.execute(); + // Flush last PBO-buffered frame before closing encoder + frame_capture_flush(); + int frames_captured = frame_capture_count(); converter_log(LOG_INFO, "Emulation finished. Captured %d frames.", frames_captured); diff --git a/src/ffmpeg_encoder.cpp b/src/ffmpeg_encoder.cpp index a8857eb..b3dc31d 100644 --- a/src/ffmpeg_encoder.cpp +++ b/src/ffmpeg_encoder.cpp @@ -3,14 +3,49 @@ #include #include +// Quality presets for 0-51 range encoders (X264_X265, AMF, NVENC) +static const QualityPreset g_presets_51[] = { + { "Highest", 0 }, + { "High", 18 }, + { "Medium", 23 }, + { "Low", 28 }, + { "Lowest", 36 }, +}; + +// Quality presets for 0-255 range encoders (AMF_AV1, NVENC_AV1) +static const QualityPreset g_presets_255[] = { + { "Highest", 0 }, + { "High", 90 }, + { "Medium", 115 }, + { "Low", 140 }, + { "Lowest", 180 }, +}; + +static const QualityFamily g_quality_families[] = { + { EncoderFamily::X264_X265, "CRF", g_presets_51, 5, 2 }, + { EncoderFamily::AMF, "QP", g_presets_51, 5, 2 }, + { EncoderFamily::AMF_AV1, "QP", g_presets_255, 5, 2 }, + { EncoderFamily::NVENC, "CQ", g_presets_51, 5, 2 }, + { EncoderFamily::NVENC_AV1, "CQ", g_presets_255, 5, 2 }, +}; + +const QualityFamily& get_quality_family(EncoderFamily family) { + for (const auto& qf : g_quality_families) { + if (qf.family == family) return qf; + } + return g_quality_families[0]; // fallback to X264_X265 +} + // All known encoders static const EncoderInfo g_all_encoders[] = { - { L"H.264 (CPU)", "libx264", false }, - { L"H.265 (CPU)", "libx265", false }, - { L"H.264 (AMD GPU)", "h264_amf", true }, - { L"H.265 (AMD GPU)", "hevc_amf", true }, - { L"H.264 (NVIDIA GPU)", "h264_nvenc", true }, - { L"H.265 (NVIDIA GPU)", "hevc_nvenc", true }, + { L"H.264 (CPU)", "libx264", false, EncoderFamily::X264_X265 }, + { L"H.265 (CPU)", "libx265", false, EncoderFamily::X264_X265 }, + { L"H.264 (AMD GPU)", "h264_amf", true, EncoderFamily::AMF }, + { L"H.265 (AMD GPU)", "hevc_amf", true, EncoderFamily::AMF }, + { L"AV1 (AMD GPU)", "av1_amf", true, EncoderFamily::AMF_AV1 }, + { L"H.264 (NVIDIA GPU)", "h264_nvenc", true, EncoderFamily::NVENC }, + { L"H.265 (NVIDIA GPU)", "hevc_nvenc", true, EncoderFamily::NVENC }, + { L"AV1 (NVIDIA GPU)", "av1_nvenc", true, EncoderFamily::NVENC_AV1 }, }; #ifdef _WIN32 @@ -90,6 +125,11 @@ static std::string build_encoder_flags(const std::string& encoder, int crf) { snprintf(buf, sizeof(buf), "-c:v hevc_amf -quality quality -rc cqp -qp_i %d -qp_p %d -pix_fmt yuv420p", crf, crf); return buf; } + if (encoder == "av1_amf") { + char buf[128]; + snprintf(buf, sizeof(buf), "-c:v av1_amf -quality quality -rc cqp -qp_i %d -qp_p %d -pix_fmt yuv420p", crf, crf); + return buf; + } if (encoder == "h264_nvenc") { char buf[128]; snprintf(buf, sizeof(buf), "-c:v h264_nvenc -preset p7 -rc vbr -cq %d -pix_fmt yuv420p", crf); @@ -100,6 +140,11 @@ static std::string build_encoder_flags(const std::string& encoder, int crf) { snprintf(buf, sizeof(buf), "-c:v hevc_nvenc -preset p7 -rc vbr -cq %d -pix_fmt yuv420p", crf); return buf; } + if (encoder == "av1_nvenc") { + char buf[128]; + snprintf(buf, sizeof(buf), "-c:v av1_nvenc -preset p7 -rc vbr -cq %d -pix_fmt yuv420p", crf); + return buf; + } // Fallback: treat as libx264 char buf[128]; snprintf(buf, sizeof(buf), "-c:v libx264 -preset medium -crf %d -pix_fmt yuv420p", crf); diff --git a/src/ffmpeg_encoder.h b/src/ffmpeg_encoder.h index c13cf95..db1d742 100644 --- a/src/ffmpeg_encoder.h +++ b/src/ffmpeg_encoder.h @@ -3,10 +3,34 @@ #include #include +enum class EncoderFamily { + X264_X265, // libx264, libx265 — CRF 0-51 + AMF, // h264_amf, hevc_amf — QP 0-51 + AMF_AV1, // av1_amf — QP 0-255 + NVENC, // h264_nvenc, hevc_nvenc — CQ 0-51 + NVENC_AV1, // av1_nvenc — CQ 0-255 +}; + +struct QualityPreset { + const char* name; // e.g. "Medium" + int value; // e.g. 23 or 115 +}; + +struct QualityFamily { + EncoderFamily family; + const char* param_name; // "CRF", "QP", or "CQ" + const QualityPreset* presets; + int num_presets; + int default_index; // index of default preset +}; + +const QualityFamily& get_quality_family(EncoderFamily family); + struct EncoderInfo { const wchar_t* label; const char* codec; bool hw; // true = needs hardware probe + EncoderFamily family; }; // Returns the subset of known encoders available on this system. diff --git a/src/frame_capture.cpp b/src/frame_capture.cpp index 30a0eb2..553c3c4 100644 --- a/src/frame_capture.cpp +++ b/src/frame_capture.cpp @@ -3,6 +3,37 @@ #include #include #include +#include +#include +#include + +#include +#include + +// GL function pointers for PBO operations +typedef void (APIENTRYP PFNGLGENBUFFERSPROC)(GLsizei n, GLuint* buffers); +typedef void (APIENTRYP PFNGLDELETEBUFFERSPROC)(GLsizei n, const GLuint* buffers); +typedef void (APIENTRYP PFNGLBINDBUFFERPROC)(GLenum target, GLuint buffer); +typedef void (APIENTRYP PFNGLBUFFERDATAPROC)(GLenum target, GLsizeiptr size, const void* data, GLenum usage); +typedef void* (APIENTRYP PFNGLMAPBUFFERPROC)(GLenum target, GLenum access); +typedef GLboolean (APIENTRYP PFNGLUNMAPBUFFERPROC)(GLenum target); + +static PFNGLGENBUFFERSPROC glGenBuffers_fn = nullptr; +static PFNGLDELETEBUFFERSPROC glDeleteBuffers_fn = nullptr; +static PFNGLBINDBUFFERPROC glBindBuffer_fn = nullptr; +static PFNGLBUFFERDATAPROC glBufferData_fn = nullptr; +static PFNGLMAPBUFFERPROC glMapBuffer_fn = nullptr; +static PFNGLUNMAPBUFFERPROC glUnmapBuffer_fn = nullptr; + +#ifndef GL_PIXEL_PACK_BUFFER +#define GL_PIXEL_PACK_BUFFER 0x88EB +#endif +#ifndef GL_STREAM_READ +#define GL_STREAM_READ 0x88E1 +#endif +#ifndef GL_READ_ONLY +#define GL_READ_ONLY 0x88B8 +#endif static Emulator* s_emu = nullptr; static FFmpegEncoder* s_encoder = nullptr; @@ -11,11 +42,150 @@ static bool s_encoder_opened = false; static int s_captured_frames = 0; static int s_total_frames = 0; static bool s_speed_limiter_disabled = false; -static std::vector s_pixel_buffer; -static std::vector s_flipped_buffer; static ProgressCallback s_progress_callback; static std::atomic* s_cancel_flag = nullptr; +// PBO double-buffering state +static GLuint s_pbo[2] = {0, 0}; +static int s_pbo_index = 0; +static bool s_pbo_initialized = false; +static bool s_pbo_has_data = false; +static int s_pbo_width = 0; +static int s_pbo_height = 0; + +// Encoding worker thread — flips + writes frames off the emulation thread +static std::thread s_encode_thread; +static std::mutex s_encode_mutex; +static std::condition_variable s_encode_cv; +static std::condition_variable s_encode_done_cv; +static std::vector s_staging_buffer; // raw pixels from PBO (bottom-up) +static std::vector s_flipped_buffer; // flipped pixels (top-down) +static bool s_encode_has_work = false; +static bool s_encode_shutdown = false; +static int s_encode_width = 0; +static int s_encode_height = 0; + +static void encode_worker() { + while (true) { + std::unique_lock lock(s_encode_mutex); + s_encode_cv.wait(lock, [] { return s_encode_has_work || s_encode_shutdown; }); + + if (s_encode_shutdown && !s_encode_has_work) break; + + int width = s_encode_width; + int height = s_encode_height; + int stride = width * 3; + size_t frame_size = (size_t)stride * height; + + if (s_flipped_buffer.size() < frame_size) + s_flipped_buffer.resize(frame_size); + + // Flip vertically + for (int y = 0; y < height; y++) { + memcpy(s_flipped_buffer.data() + y * stride, + s_staging_buffer.data() + (height - 1 - y) * stride, stride); + } + + s_encode_has_work = false; + lock.unlock(); + s_encode_done_cv.notify_one(); + + // Write to FFmpeg (outside lock so emulation thread can continue) + s_encoder->write_frame(s_flipped_buffer.data(), width, height); + s_captured_frames++; + + if (s_progress_callback) { + s_progress_callback(s_captured_frames, s_total_frames); + } + } +} + +static void start_encode_thread() { + s_encode_shutdown = false; + s_encode_has_work = false; + s_encode_thread = std::thread(encode_worker); +} + +static void stop_encode_thread() { + { + std::lock_guard lock(s_encode_mutex); + s_encode_shutdown = true; + } + s_encode_cv.notify_one(); + if (s_encode_thread.joinable()) + s_encode_thread.join(); +} + +// Wait for the encode thread to finish its current frame +static void wait_for_encode() { + std::unique_lock lock(s_encode_mutex); + s_encode_done_cv.wait(lock, [] { return !s_encode_has_work; }); +} + +static bool init_pbo_functions() { + glGenBuffers_fn = (PFNGLGENBUFFERSPROC)SDL_GL_GetProcAddress("glGenBuffers"); + glDeleteBuffers_fn = (PFNGLDELETEBUFFERSPROC)SDL_GL_GetProcAddress("glDeleteBuffers"); + glBindBuffer_fn = (PFNGLBINDBUFFERPROC)SDL_GL_GetProcAddress("glBindBuffer"); + glBufferData_fn = (PFNGLBUFFERDATAPROC)SDL_GL_GetProcAddress("glBufferData"); + glMapBuffer_fn = (PFNGLMAPBUFFERPROC)SDL_GL_GetProcAddress("glMapBuffer"); + glUnmapBuffer_fn = (PFNGLUNMAPBUFFERPROC)SDL_GL_GetProcAddress("glUnmapBuffer"); + + return glGenBuffers_fn && glDeleteBuffers_fn && glBindBuffer_fn && + glBufferData_fn && glMapBuffer_fn && glUnmapBuffer_fn; +} + +static void init_pbos(int width, int height) { + size_t size = (size_t)width * height * 3; + glGenBuffers_fn(2, s_pbo); + for (int i = 0; i < 2; i++) { + glBindBuffer_fn(GL_PIXEL_PACK_BUFFER, s_pbo[i]); + glBufferData_fn(GL_PIXEL_PACK_BUFFER, size, nullptr, GL_STREAM_READ); + } + glBindBuffer_fn(GL_PIXEL_PACK_BUFFER, 0); + s_pbo_width = width; + s_pbo_height = height; + s_pbo_index = 0; + s_pbo_has_data = false; + s_pbo_initialized = true; + + s_staging_buffer.resize(size); + start_encode_thread(); +} + +static void cleanup_pbos() { + if (s_pbo_initialized) { + stop_encode_thread(); + glDeleteBuffers_fn(2, s_pbo); + s_pbo[0] = s_pbo[1] = 0; + s_pbo_initialized = false; + s_pbo_has_data = false; + } +} + +// Map PBO, copy to staging buffer, hand off to encode thread +static void process_pbo_async(int pbo_idx, int width, int height) { + // Wait for encode thread to finish previous frame before overwriting staging buffer + wait_for_encode(); + + glBindBuffer_fn(GL_PIXEL_PACK_BUFFER, s_pbo[pbo_idx]); + void* ptr = glMapBuffer_fn(GL_PIXEL_PACK_BUFFER, GL_READ_ONLY); + if (ptr) { + size_t frame_size = (size_t)width * height * 3; + memcpy(s_staging_buffer.data(), ptr, frame_size); + glUnmapBuffer_fn(GL_PIXEL_PACK_BUFFER); + + // Signal encode thread + { + std::lock_guard lock(s_encode_mutex); + s_encode_width = width; + s_encode_height = height; + s_encode_has_work = true; + } + s_encode_cv.notify_one(); + } + glBindBuffer_fn(GL_PIXEL_PACK_BUFFER, 0); +} + void frame_capture_init(Emulator* emu, FFmpegEncoder* encoder, const FFmpegConfig& ff_config, int total_frames) { s_emu = emu; @@ -25,10 +195,11 @@ void frame_capture_init(Emulator* emu, FFmpegEncoder* encoder, const FFmpegConfi s_captured_frames = 0; s_total_frames = total_frames; s_speed_limiter_disabled = false; - s_pixel_buffer.clear(); - s_flipped_buffer.clear(); + // Keep buffers allocated across batch runs to avoid reallocation s_progress_callback = nullptr; s_cancel_flag = nullptr; + s_pbo_initialized = false; + s_pbo_has_data = false; } void frame_capture_set_progress_callback(ProgressCallback cb) { @@ -43,20 +214,30 @@ int frame_capture_count() { return s_captured_frames; } +void frame_capture_flush() { + if (s_pbo_initialized && s_pbo_has_data) { + int prev = 1 - s_pbo_index; + process_pbo_async(prev, s_pbo_width, s_pbo_height); + } + // Wait for final encode to complete before cleanup + wait_for_encode(); + cleanup_pbos(); +} + void frame_capture_callback(unsigned int frame_index) { // Check cancel flag if (s_cancel_flag && s_cancel_flag->load()) { if (s_emu) { + cleanup_pbos(); s_emu->stop(); } return; } - // Set speed factor to 500% on first frame for faster conversion - // Keep the speed limiter active (just increase the target speed) + // Disable speed limiter on first frame for maximum conversion speed if (!s_speed_limiter_disabled && s_emu) { - int speed = 500; // 5x speed - s_emu->core_do_command(M64CMD_CORE_STATE_SET, M64CORE_SPEED_FACTOR, &speed); + int limiter = 0; // 0 = off + s_emu->core_do_command(M64CMD_CORE_STATE_SET, M64CORE_SPEED_LIMITER, &limiter); s_speed_limiter_disabled = true; } @@ -73,12 +254,12 @@ void frame_capture_callback(unsigned int frame_index) { if (!s_emu || !s_encoder) return; - // Get screen dimensions first + // Get screen dimensions int width = 0, height = 0; s_emu->read_screen(nullptr, &width, &height); if (width <= 0 || height <= 0) return; - // Open encoder lazily on first frame using actual render dimensions + // Open encoder lazily on first frame if (!s_encoder_opened) { if (width != s_ff_config.width || height != s_ff_config.height) { fprintf(stderr, "Frame capture: actual render size %dx%d differs from requested %dx%d, adapting.\n", @@ -94,28 +275,40 @@ void frame_capture_callback(unsigned int frame_index) { s_encoder_opened = true; } - // Allocate buffer and capture - size_t frame_size = (size_t)width * height * 3; - if (s_pixel_buffer.size() < frame_size) { - s_pixel_buffer.resize(frame_size); - s_flipped_buffer.resize(frame_size); + // Initialize PBOs + encode thread on first frame + if (!s_pbo_initialized) { + if (!init_pbo_functions()) { + fprintf(stderr, "Warning: PBO functions not available, falling back to sync readback\n"); + size_t frame_size = (size_t)width * height * 3; + std::vector pixel_buffer(frame_size); + s_emu->read_screen(pixel_buffer.data(), &width, &height); + if (s_flipped_buffer.size() < frame_size) s_flipped_buffer.resize(frame_size); + int stride = width * 3; + for (int y = 0; y < height; y++) { + memcpy(s_flipped_buffer.data() + y * stride, + pixel_buffer.data() + (height - 1 - y) * stride, stride); + } + s_encoder->write_frame(s_flipped_buffer.data(), width, height); + s_captured_frames++; + if (s_progress_callback) s_progress_callback(s_captured_frames, s_total_frames); + return; + } + init_pbos(width, height); } - s_emu->read_screen(s_pixel_buffer.data(), &width, &height); + // --- PBO double-buffered async readback + threaded encode --- - // Flip vertically (OpenGL returns bottom-up, FFmpeg expects top-down) - int stride = width * 3; - for (int y = 0; y < height; y++) { - memcpy(s_flipped_buffer.data() + y * stride, - s_pixel_buffer.data() + (height - 1 - y) * stride, - stride); + // 1. If previous frame is ready, hand it to the encode thread + if (s_pbo_has_data) { + int prev = 1 - s_pbo_index; + process_pbo_async(prev, s_pbo_width, s_pbo_height); } - s_encoder->write_frame(s_flipped_buffer.data(), width, height); - s_captured_frames++; + // 2. Start async readback of current frame into current PBO + glBindBuffer_fn(GL_PIXEL_PACK_BUFFER, s_pbo[s_pbo_index]); + glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, nullptr); + glBindBuffer_fn(GL_PIXEL_PACK_BUFFER, 0); - // Report progress - if (s_progress_callback) { - s_progress_callback(s_captured_frames, s_total_frames); - } + s_pbo_has_data = true; + s_pbo_index = 1 - s_pbo_index; } diff --git a/src/frame_capture.h b/src/frame_capture.h index f2110a9..ba68c91 100644 --- a/src/frame_capture.h +++ b/src/frame_capture.h @@ -21,5 +21,8 @@ void frame_capture_set_cancel_flag(std::atomic* flag); // The VI frame callback registered with the core. void frame_capture_callback(unsigned int frame_index); +// Flush the last PBO-buffered frame. Call after emulation stops, before closing encoder. +void frame_capture_flush(); + // Get the number of frames captured so far. int frame_capture_count(); diff --git a/src/gui_main.cpp b/src/gui_main.cpp index 8af6125..88cf7f5 100644 --- a/src/gui_main.cpp +++ b/src/gui_main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -40,8 +41,7 @@ static HWND g_input_path = nullptr; static HWND g_batch_check = nullptr; static HWND g_output_path = nullptr; static HWND g_resolution_combo = nullptr; -static HWND g_crf_slider = nullptr; -static HWND g_crf_value = nullptr; +static HWND g_quality_combo = nullptr; static HWND g_fps_edit = nullptr; static HWND g_msaa_slider = nullptr; static HWND g_msaa_value = nullptr; @@ -51,6 +51,7 @@ static HWND g_encoder_combo = nullptr; static HWND g_verbose_check = nullptr; static HWND g_convert_btn = nullptr; static HWND g_cancel_btn = nullptr; +static HWND g_open_folder_btn = nullptr; static HWND g_progress_bar = nullptr; static HWND g_progress_text = nullptr; static HWND g_log_edit = nullptr; @@ -59,6 +60,9 @@ static HWND g_log_edit = nullptr; static std::thread g_worker_thread; static std::atomic g_cancel_flag{false}; static bool g_converting = false; +static LARGE_INTEGER g_start_time = {}; // batch start (for total elapsed) +static LARGE_INTEGER g_file_start_time = {}; // per-file start (for fps/ETA) +static LARGE_INTEGER g_perf_freq = {}; // Resolution presets struct ResPreset { @@ -75,8 +79,8 @@ static const ResPreset g_res_presets[] = { static const int g_num_res_presets = sizeof(g_res_presets) / sizeof(g_res_presets[0]); // MSAA / Aniso presets: slider position -> value -static const int g_msaa_values[] = { 0, 2, 4, 8, 16 }; -static const wchar_t* g_msaa_labels[] = { L"Off", L"2x", L"4x", L"8x", L"16x" }; +static const int g_msaa_values[] = { 0, 2, 4, 8 }; +static const wchar_t* g_msaa_labels[] = { L"Off", L"2x", L"4x", L"8x" }; static const int g_aniso_values[] = { 0, 2, 4, 8, 16 }; static const wchar_t* g_aniso_labels[] = { L"Off", L"2x", L"4x", L"8x", L"16x" }; @@ -130,6 +134,26 @@ static std::wstring GetEditDir(HWND edit) { return {}; } +static EncoderFamily GetSelectedEncoderFamily() { + int sel = (int)SendMessageW(g_encoder_combo, CB_GETCURSEL, 0, 0); + if (sel >= 0 && sel < (int)g_encoders.size()) + return g_encoders[sel].family; + return EncoderFamily::X264_X265; +} + +static void PopulateQualityCombo(EncoderFamily family) { + SendMessageW(g_quality_combo, CB_RESETCONTENT, 0, 0); + const QualityFamily& qf = get_quality_family(family); + for (int i = 0; i < qf.num_presets; i++) { + char label[64]; + snprintf(label, sizeof(label), "%s (%s %d)", + qf.presets[i].name, qf.param_name, qf.presets[i].value); + SendMessageW(g_quality_combo, CB_ADDSTRING, 0, + (LPARAM)Utf8ToWide(label).c_str()); + } + SendMessageW(g_quality_combo, CB_SETCURSEL, qf.default_index, 0); +} + static std::wstring BrowseFile(HWND owner, const wchar_t* title, const wchar_t* filter, bool save, const wchar_t* defExt = nullptr, const wchar_t* initialDir = nullptr) { @@ -230,10 +254,15 @@ static void SaveSettings() { Utf8ToWide(g_encoders[enc_sel].codec).c_str(), file); } - // CRF - int crf = (int)SendMessageW(g_crf_slider, TBM_GETPOS, 0, 0); - swprintf(num, 16, L"%d", crf); - WritePrivateProfileStringW(sec, L"CRF", num, file); + // Quality preset + { + int qsel = (int)SendMessageW(g_quality_combo, CB_GETCURSEL, 0, 0); + const QualityFamily& qf = get_quality_family(GetSelectedEncoderFamily()); + if (qsel >= 0 && qsel < qf.num_presets) { + WritePrivateProfileStringW(sec, L"Quality", + Utf8ToWide(qf.presets[qsel].name).c_str(), file); + } + } // FPS GetWindowTextW(g_fps_edit, buf, MAX_PATH); @@ -291,13 +320,18 @@ static void LoadSettings() { } } - // CRF - int crf = GetPrivateProfileIntW(sec, L"CRF", 23, file); - if (crf >= 0 && crf <= 51) { - SendMessageW(g_crf_slider, TBM_SETPOS, TRUE, crf); - wchar_t num[8]; - swprintf(num, 8, L"%d", crf); - SetWindowTextW(g_crf_value, num); + // Quality preset (repopulate combo for restored encoder, then match saved name) + PopulateQualityCombo(GetSelectedEncoderFamily()); + GetPrivateProfileStringW(sec, L"Quality", L"Medium", buf, MAX_PATH, file); + { + std::string saved_name = WideToUtf8(buf); + const QualityFamily& qf = get_quality_family(GetSelectedEncoderFamily()); + for (int i = 0; i < qf.num_presets; i++) { + if (saved_name == qf.presets[i].name) { + SendMessageW(g_quality_combo, CB_SETCURSEL, i, 0); + break; + } + } } // FPS @@ -306,7 +340,7 @@ static void LoadSettings() { // MSAA int msaa = GetPrivateProfileIntW(sec, L"MSAA", 0, file); - if (msaa >= 0 && msaa <= 4) { + if (msaa >= 0 && msaa <= 3) { SendMessageW(g_msaa_slider, TBM_SETPOS, TRUE, msaa); SetWindowTextW(g_msaa_value, g_msaa_labels[msaa]); } @@ -417,14 +451,11 @@ static void CreateControls(HWND hwnd) { y += ROW_H + GAP; CreateLabel(hwnd, L"Quality:", MARGIN, y + 2, LBL_W, ROW_H); - g_crf_slider = CreateWindowExW(0, TRACKBAR_CLASSW, L"", - WS_CHILD | WS_VISIBLE | WS_TABSTOP | TBS_HORZ | TBS_AUTOTICKS, - EDIT_X, y, 260, ROW_H + 6, hwnd, (HMENU)(INT_PTR)IDC_CRF_SLIDER, nullptr, nullptr); - SendMessageW(g_crf_slider, TBM_SETRANGE, TRUE, MAKELONG(0, 51)); - SendMessageW(g_crf_slider, TBM_SETPOS, TRUE, 23); - SendMessageW(g_crf_slider, TBM_SETTICFREQ, 5, 0); - g_crf_value = CreateEdit(hwnd, IDC_CRF_VALUE, EDIT_X + 266, y + 2, 40, ROW_H - 2); - SetWindowTextW(g_crf_value, L"23"); + g_quality_combo = CreateWindowExW(WS_EX_CLIENTEDGE, L"COMBOBOX", L"", + WS_CHILD | WS_VISIBLE | WS_TABSTOP | CBS_DROPDOWNLIST, + EDIT_X, y, 200, 200, hwnd, (HMENU)(INT_PTR)IDC_QUALITY, nullptr, nullptr); + SendMessageW(g_quality_combo, WM_SETFONT, (WPARAM)g_font, TRUE); + PopulateQualityCombo(GetSelectedEncoderFamily()); y += ROW_H + GAP + 4; CreateLabel(hwnd, L"FPS Override:", MARGIN, y + 2, LBL_W, ROW_H); @@ -438,12 +469,12 @@ static void CreateControls(HWND hwnd) { } y += ROW_H + GAP; - // Anti-aliasing (MSAA) slider: positions 0-4 -> Off, 2x, 4x, 8x, 16x + // Anti-aliasing (MSAA) slider: positions 0-3 -> Off, 2x, 4x, 8x CreateLabel(hwnd, L"Anti-Alias:", MARGIN, y + 2, LBL_W, ROW_H); g_msaa_slider = CreateWindowExW(0, TRACKBAR_CLASSW, L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | TBS_HORZ | TBS_AUTOTICKS, EDIT_X, y, 200, ROW_H + 6, hwnd, (HMENU)(INT_PTR)IDC_MSAA_SLIDER, nullptr, nullptr); - SendMessageW(g_msaa_slider, TBM_SETRANGE, TRUE, MAKELONG(0, 4)); + SendMessageW(g_msaa_slider, TBM_SETRANGE, TRUE, MAKELONG(0, 3)); SendMessageW(g_msaa_slider, TBM_SETPOS, TRUE, 0); SendMessageW(g_msaa_slider, TBM_SETTICFREQ, 1, 0); g_msaa_value = CreateWindowExW(0, L"STATIC", L"Off", @@ -472,10 +503,13 @@ static void CreateControls(HWND hwnd) { y += ROW_H + GAP + 2; g_convert_btn = CreateBtn(hwnd, L"Convert", IDC_CONVERT_BTN, - MARGIN + 180, y, 120, 32); + MARGIN + 120, y, 120, 32); g_cancel_btn = CreateBtn(hwnd, L"Cancel", IDC_CANCEL_BTN, - MARGIN + 310, y, 100, 32); + MARGIN + 250, y, 100, 32); + g_open_folder_btn = CreateBtn(hwnd, L"Open Folder", IDC_OPEN_FOLDER_BTN, + MARGIN + 360, y, 110, 32); EnableWindow(g_cancel_btn, FALSE); + EnableWindow(g_open_folder_btn, FALSE); y += 32 + GAP + 4; // --- Progress --- @@ -549,6 +583,11 @@ static void WorkerThread(AppConfig config) { for (size_t i = 0; i < krec_files.size(); i++) { if (g_cancel_flag.load()) break; + // Report batch progress + if (krec_files.size() > 1) { + PostMessageW(g_hwnd, WM_APP_BATCH, (WPARAM)(i + 1), (LPARAM)krec_files.size()); + } + std::string output; if (config.batch) { std::string out_dir = config.output_path.empty() @@ -601,8 +640,13 @@ static AppConfig ReadConfig() { cfg.res_height = g_res_presets[sel].h; } - // CRF - cfg.crf = (int)SendMessageW(g_crf_slider, TBM_GETPOS, 0, 0); + // Quality (read value from selected preset) + { + int qsel = (int)SendMessageW(g_quality_combo, CB_GETCURSEL, 0, 0); + const QualityFamily& qf = get_quality_family(GetSelectedEncoderFamily()); + if (qsel >= 0 && qsel < qf.num_presets) + cfg.crf = qf.presets[qsel].value; + } // FPS std::string fps_str = GetEditText(g_fps_edit); @@ -616,7 +660,7 @@ static AppConfig ReadConfig() { // Anti-aliasing (MSAA) int msaa_pos = (int)SendMessageW(g_msaa_slider, TBM_GETPOS, 0, 0); - if (msaa_pos >= 0 && msaa_pos <= 4) cfg.msaa = g_msaa_values[msaa_pos]; + if (msaa_pos >= 0 && msaa_pos <= 3) cfg.msaa = g_msaa_values[msaa_pos]; // Anisotropic filtering int aniso_pos = (int)SendMessageW(g_aniso_slider, TBM_GETPOS, 0, 0); @@ -643,6 +687,14 @@ static void StartConversion() { MessageBoxW(g_hwnd, L"In batch mode, input must be a directory.", L"Validation Error", MB_ICONWARNING); return; } + if (cfg.batch && cfg.output_path.empty()) { + MessageBoxW(g_hwnd, L"In batch mode, an output directory is required.", L"Validation Error", MB_ICONWARNING); + return; + } + if (cfg.batch && !fs::is_directory(cfg.output_path)) { + MessageBoxW(g_hwnd, L"In batch mode, output must be an existing directory.", L"Validation Error", MB_ICONWARNING); + return; + } if (!cfg.batch && !fs::is_regular_file(cfg.input_path)) { MessageBoxW(g_hwnd, L"Input file does not exist.", L"Validation Error", MB_ICONWARNING); return; @@ -656,8 +708,12 @@ static void StartConversion() { // Toggle buttons EnableWindow(g_convert_btn, FALSE); EnableWindow(g_cancel_btn, TRUE); + EnableWindow(g_open_folder_btn, FALSE); g_converting = true; g_cancel_flag.store(false); + QueryPerformanceFrequency(&g_perf_freq); + QueryPerformanceCounter(&g_start_time); + g_file_start_time = g_start_time; // Launch worker if (g_worker_thread.joinable()) g_worker_thread.join(); @@ -675,14 +731,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara case WM_HSCROLL: { HWND slider = (HWND)lParam; - if (slider == g_crf_slider) { - int pos = (int)SendMessageW(g_crf_slider, TBM_GETPOS, 0, 0); - wchar_t buf[8]; - swprintf(buf, 8, L"%d", pos); - SetWindowTextW(g_crf_value, buf); - } else if (slider == g_msaa_slider) { + if (slider == g_msaa_slider) { int pos = (int)SendMessageW(g_msaa_slider, TBM_GETPOS, 0, 0); - if (pos >= 0 && pos <= 4) + if (pos >= 0 && pos <= 3) SetWindowTextW(g_msaa_value, g_msaa_labels[pos]); } else if (slider == g_aniso_slider) { int pos = (int)SendMessageW(g_aniso_slider, TBM_GETPOS, 0, 0); @@ -696,14 +747,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara int id = LOWORD(wParam); int code = HIWORD(wParam); - // CRF edit -> sync slider - if (id == IDC_CRF_VALUE && code == EN_CHANGE) { - wchar_t buf[8] = {}; - GetWindowTextW(g_crf_value, buf, 8); - int val = _wtoi(buf); - if (val >= 0 && val <= 51) { - SendMessageW(g_crf_slider, TBM_SETPOS, TRUE, val); - } + // Encoder changed -> repopulate quality combo + if (id == IDC_ENCODER && code == CBN_SELCHANGE) { + PopulateQualityCombo(GetSelectedEncoderFamily()); return 0; } @@ -756,7 +802,32 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara EnableWindow(g_cancel_btn, FALSE); } break; + case IDC_OPEN_FOLDER_BTN: { + // Determine output directory + std::string out = GetEditText(g_output_path); + if (out.empty()) out = GetEditText(g_input_path); + if (!out.empty()) { + fs::path p(out); + fs::path dir = fs::is_directory(p) ? p : p.parent_path(); + if (fs::is_directory(dir)) { + ShellExecuteW(hwnd, L"open", dir.wstring().c_str(), + nullptr, nullptr, SW_SHOWNORMAL); + } + } + break; } + } + return 0; + } + + case WM_APP_BATCH: { + int current_file = (int)wParam; + int total_files = (int)lParam; + // Reset per-file timer so fps/ETA are accurate for each file + QueryPerformanceCounter(&g_file_start_time); + wchar_t title[256]; + swprintf(title, 256, L"Krec2MP4 \u2014 File %d / %d", current_file, total_files); + SetWindowTextW(hwnd, title); return 0; } @@ -790,8 +861,26 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara int pct = (current * 100) / total; SendMessageW(g_progress_bar, PBM_SETRANGE32, 0, total); SendMessageW(g_progress_bar, PBM_SETPOS, current, 0); - wchar_t buf[128]; - swprintf(buf, 128, L"Frame %d / %d (%d%%)", current, total, pct); + + // Calculate encoding speed and time estimates + LARGE_INTEGER now; + QueryPerformanceCounter(&now); + double elapsed = (double)(now.QuadPart - g_start_time.QuadPart) / g_perf_freq.QuadPart; + double file_elapsed = (double)(now.QuadPart - g_file_start_time.QuadPart) / g_perf_freq.QuadPart; + double enc_fps = (file_elapsed > 0.0) ? current / file_elapsed : 0.0; + double speed_mult = enc_fps / 60.0; + + int remaining = total - current; + double eta = (enc_fps > 0.0) ? remaining / enc_fps : 0.0; + + int el_m = (int)elapsed / 60; + int el_s = (int)elapsed % 60; + int eta_m = (int)eta / 60; + int eta_s = (int)eta % 60; + + wchar_t buf[256]; + swprintf(buf, 256, L"Frame %d / %d (%d%%) \u2014 %.0f fps (%.1fx) \u2014 %d:%02d elapsed, %d:%02d remaining", + current, total, pct, enc_fps, speed_mult, el_m, el_s, eta_m, eta_s); SetWindowTextW(g_progress_text, buf); } return 0; @@ -805,6 +894,7 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara g_converting = false; EnableWindow(g_convert_btn, TRUE); EnableWindow(g_cancel_btn, FALSE); + SetWindowTextW(hwnd, L"Krec2MP4 - N64 Replay to Video Converter"); // Stop marquee mode if active LONG pstyle = GetWindowLongW(g_progress_bar, GWL_STYLE); @@ -813,11 +903,20 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara SetWindowLongW(g_progress_bar, GWL_STYLE, pstyle & ~PBS_MARQUEE); } + // Calculate total elapsed time + LARGE_INTEGER now; + QueryPerformanceCounter(&now); + double elapsed = (double)(now.QuadPart - g_start_time.QuadPart) / g_perf_freq.QuadPart; + int el_m = (int)elapsed / 60; + int el_s = (int)elapsed % 60; + wchar_t buf[256]; if (g_cancel_flag.load()) { - swprintf(buf, 256, L"Cancelled. Success: %d, Failed: %d", success, failed); + swprintf(buf, 256, L"Cancelled. Success: %d, Failed: %d (%d:%02d)", + success, failed, el_m, el_s); } else { - swprintf(buf, 256, L"Done! Success: %d, Failed: %d", success, failed); + swprintf(buf, 256, L"Done! Success: %d, Failed: %d (%d:%02d)", + success, failed, el_m, el_s); } SetWindowTextW(g_progress_text, buf); @@ -825,6 +924,13 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lPara SendMessageW(g_progress_bar, PBM_SETRANGE32, 0, 100); SendMessageW(g_progress_bar, PBM_SETPOS, 100, 0); } + + // Enable Open Folder if there's an output path + std::string out = GetEditText(g_output_path); + if (out.empty()) out = GetEditText(g_input_path); + if (!out.empty()) { + EnableWindow(g_open_folder_btn, TRUE); + } return 0; } @@ -878,7 +984,9 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { wc.style = CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc = WndProc; wc.hInstance = hInstance; - wc.hIcon = LoadIcon(nullptr, IDI_APPLICATION); + wc.hIcon = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_APPICON)); + wc.hIconSm = (HICON)LoadImage(hInstance, MAKEINTRESOURCE(IDI_APPICON), + IMAGE_ICON, 16, 16, LR_DEFAULTCOLOR); wc.hCursor = LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1); wc.lpszClassName = L"Krec2MP4_GUI"; diff --git a/src/gui_resources.h b/src/gui_resources.h index 8be690e..f67ccbf 100644 --- a/src/gui_resources.h +++ b/src/gui_resources.h @@ -1,5 +1,8 @@ #pragma once +// Icon resource ID (must match res/app.rc) +#define IDI_APPICON 1 + // Control IDs #define IDC_ROM_PATH 101 #define IDC_ROM_BROWSE 102 @@ -10,8 +13,7 @@ #define IDC_OUTPUT_BROWSE 107 #define IDC_RESOLUTION 201 -#define IDC_CRF_SLIDER 202 -#define IDC_CRF_VALUE 203 +#define IDC_QUALITY 202 #define IDC_FPS_EDIT 204 #define IDC_MSAA_SLIDER 205 #define IDC_MSAA_VALUE 206 @@ -22,6 +24,7 @@ #define IDC_VERBOSE_CHECK 401 #define IDC_CONVERT_BTN 402 #define IDC_CANCEL_BTN 403 +#define IDC_OPEN_FOLDER_BTN 404 #define IDC_PROGRESS_BAR 501 #define IDC_PROGRESS_TEXT 502 @@ -31,3 +34,4 @@ #define WM_APP_LOG (WM_APP + 1) // wParam=level, lParam=_strdup'd string #define WM_APP_PROGRESS (WM_APP + 2) // wParam=current_frame, lParam=total_frames #define WM_APP_DONE (WM_APP + 3) // wParam=success_count, lParam=fail_count +#define WM_APP_BATCH (WM_APP + 4) // wParam=current_file (1-based), lParam=total_files diff --git a/src/vidext.cpp b/src/vidext.cpp index d8d9ae5..83d331a 100644 --- a/src/vidext.cpp +++ b/src/vidext.cpp @@ -143,8 +143,8 @@ static m64p_error VidExt_GLSetAttr(m64p_GLattr attr, int value) { case M64P_GL_BLUE_SIZE: s_gl_blue_size = value; break; case M64P_GL_ALPHA_SIZE: s_gl_alpha_size = value; break; case M64P_GL_SWAP_CONTROL: s_gl_swap_interval = 0; break; // always 0 for headless - case M64P_GL_MULTISAMPLEBUFFERS: s_gl_multisample_buffers = value; break; - case M64P_GL_MULTISAMPLESAMPLES: s_gl_multisample_samples = value; break; + case M64P_GL_MULTISAMPLEBUFFERS: s_gl_multisample_buffers = 0; break; // force 0: GLideN64 handles MSAA in its own FBOs; multisampled default FB breaks glReadPixels + case M64P_GL_MULTISAMPLESAMPLES: s_gl_multisample_samples = 0; break; case M64P_GL_CONTEXT_MAJOR_VERSION: s_gl_major = value; break; case M64P_GL_CONTEXT_MINOR_VERSION: s_gl_minor = value; break; case M64P_GL_CONTEXT_PROFILE_MASK: s_gl_profile = value; break;