diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a87de58 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + +# Compile-only matrix — same workflow for Linux and macOS (one codebase). +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update -qq + sudo apt-get install -y \ + build-essential cmake \ + libglfw3-dev libglew-dev libglm-dev \ + libcurl4-openssl-dev \ + libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ + nlohmann-json3-dev \ + libsqlite3-dev \ + libgl1-mesa-dev \ + zenity + + - name: Install dependencies (macOS) + if: runner.os == 'macOS' + run: | + brew install cmake glfw glew glm curl ffmpeg nlohmann-json sqlite pkg-config + + - name: Configure + env: + # Homebrew keg-only curl/sqlite on Apple Silicon runners + CMAKE_PREFIX_PATH: /opt/homebrew:/usr/local + PKG_CONFIG_PATH: /opt/homebrew/lib/pkgconfig:/opt/homebrew/opt/curl/lib/pkgconfig:/opt/homebrew/opt/sqlite/lib/pkgconfig:/usr/local/lib/pkgconfig + run: cmake -B build -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build -j$(nproc 2>/dev/null || sysctl -n hw.ncpu) diff --git a/CLAUDE.md b/CLAUDE.md index 48263ea..42e94d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,16 +25,27 @@ There are no tests. The only validation is a successful build and runtime smoke ## Dependencies -System packages required (Ubuntu/Debian): +One codebase for Linux and macOS (same `CMakeLists.txt`). + +Ubuntu/Debian: ```bash sudo apt install cmake libglfw3-dev libglew-dev libglm-dev \ libcurl4-openssl-dev libavcodec-dev libavformat-dev libavutil-dev \ libswscale-dev nlohmann-json3-dev libsqlite3-dev zenity ``` -ImGui and stb are fetched automatically by CMake (FetchContent) on first configure. +macOS (Homebrew): +```bash +brew install cmake glfw glew glm curl ffmpeg nlohmann-json sqlite pkg-config +``` + +Optional macOS `.app`: `-DRAMMB_MACOSX_BUNDLE=ON`. + +ImGui, stb, and portable-file-dialogs are fetched by CMake (FetchContent) on first configure. + +`` requires GCC 13+ / recent Apple Clang. Ubuntu 22.04 ships GCC 11 — use Ubuntu 24.04 or install `gcc-13`. -`` requires GCC 13+. Ubuntu 22.04 ships GCC 11 — use Ubuntu 24.04 or install `gcc-13`. +OpenGL: **4.5** on Linux, **4.1 + forward compat** on macOS (Apple deprecated OpenGL; still used for the shared renderer). ## Architecture diff --git a/CMakeLists.txt b/CMakeLists.txt index 1508799..e59f1d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,26 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# One loader for all platforms (Phase 1: GLEW; GLAD remains a future option). +set(RAMMB_GL_LOADER "glew" CACHE STRING "OpenGL loader: glew (glad optional later)") +set_property(CACHE RAMMB_GL_LOADER PROPERTY STRINGS glew) + +# Optional macOS .app (default OFF — CLI next to shaders/ on both OSes). +option(RAMMB_MACOSX_BUNDLE "Build a macOS .app bundle" OFF) + +# OpenGL context version: macOS caps at 4.1; Linux keeps 4.5. +if(APPLE) + set(RAMMB_GL_MAJOR 4) + set(RAMMB_GL_MINOR 1) + set(RAMMB_GL_FORWARD_COMPAT 1) + # Homebrew prefixes (Apple Silicon + Intel) + list(PREPEND CMAKE_PREFIX_PATH /opt/homebrew /usr/local) +else() + set(RAMMB_GL_MAJOR 4) + set(RAMMB_GL_MINOR 5) + set(RAMMB_GL_FORWARD_COMPAT 0) +endif() + # ── Dependencies via FetchContent ──────────────────────────────────────────── include(FetchContent) @@ -22,19 +42,30 @@ FetchContent_Declare(stb GIT_SHALLOW TRUE) FetchContent_MakeAvailable(stb) -# ── System dependencies (install via apt) ──────────────────────────────────── +# Portable native file dialogs (zenity/kdialog on Linux, osascript on macOS). +# Header-only: populate sources but skip upstream CMakeLists (requires CMake < 3.5). +FetchContent_Declare(portable_file_dialogs + GIT_REPOSITORY https://github.com/samhocevar/portable-file-dialogs.git + GIT_TAG c12ea8c9a727f5320a2b4570aee863bbede2a204 + GIT_SHALLOW TRUE) +FetchContent_GetProperties(portable_file_dialogs) +if(NOT portable_file_dialogs_POPULATED) + FetchContent_Populate(portable_file_dialogs) +endif() + +# ── System dependencies (apt on Linux, Homebrew on macOS) ──────────────────── find_package(OpenGL REQUIRED) find_package(GLEW REQUIRED) find_package(glfw3 REQUIRED) find_package(CURL REQUIRED) find_package(SQLite3 REQUIRED) -# FFmpeg (libav*) +# FFmpeg (libav*) — IMPORTED_TARGET carries Homebrew -L paths on macOS find_package(PkgConfig REQUIRED) -pkg_check_modules(AVCODEC REQUIRED libavcodec) -pkg_check_modules(AVFORMAT REQUIRED libavformat) -pkg_check_modules(AVUTIL REQUIRED libavutil) -pkg_check_modules(SWSCALE REQUIRED libswscale) +pkg_check_modules(AVCODEC REQUIRED IMPORTED_TARGET libavcodec) +pkg_check_modules(AVFORMAT REQUIRED IMPORTED_TARGET libavformat) +pkg_check_modules(AVUTIL REQUIRED IMPORTED_TARGET libavutil) +pkg_check_modules(SWSCALE REQUIRED IMPORTED_TARGET libswscale) # GLM (header-only, find manually if find_package fails) find_package(glm QUIET) @@ -67,6 +98,7 @@ target_link_libraries(imgui_lib PUBLIC glfw OpenGL::GL) set(SOURCES src/main.cpp src/app.cpp + src/platform.cpp src/animation.cpp src/renderer/shader.cpp src/renderer/renderer.cpp @@ -88,7 +120,8 @@ target_include_directories(rammb-neovis PRIVATE src ${stb_SOURCE_DIR} ${imgui_SOURCE_DIR} - ${imgui_SOURCE_DIR}/backends) + ${imgui_SOURCE_DIR}/backends + ${portable_file_dialogs_SOURCE_DIR}) if(TARGET glm::glm) target_link_libraries(rammb-neovis PRIVATE glm::glm) @@ -102,11 +135,17 @@ elseif(DEFINED NLOHMANN_JSON_INCLUDE_DIR) target_include_directories(rammb-neovis PRIVATE ${NLOHMANN_JSON_INCLUDE_DIR}) endif() +# Includes + library dirs from pkg-config (needed for Homebrew prefixes on macOS). target_include_directories(rammb-neovis PRIVATE ${AVCODEC_INCLUDE_DIRS} ${AVFORMAT_INCLUDE_DIRS} ${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS}) +target_link_directories(rammb-neovis PRIVATE + ${AVCODEC_LIBRARY_DIRS} + ${AVFORMAT_LIBRARY_DIRS} + ${AVUTIL_LIBRARY_DIRS} + ${SWSCALE_LIBRARY_DIRS}) target_link_libraries(rammb-neovis PRIVATE OpenGL::GL @@ -115,18 +154,54 @@ target_link_libraries(rammb-neovis PRIVATE CURL::libcurl SQLite::SQLite3 imgui_lib - ${AVCODEC_LIBRARIES} - ${AVFORMAT_LIBRARIES} - ${AVUTIL_LIBRARIES} - ${SWSCALE_LIBRARIES}) + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL + PkgConfig::SWSCALE) + +# Preferred HW H.264 encoder name (runtime still falls back to libx264 / generic). +if(APPLE) + set(RAMMB_HW_H264_ENCODER "h264_videotoolbox") +else() + set(RAMMB_HW_H264_ENCODER "h264_nvenc") +endif() -# Copy shaders next to the binary after build +# Platform compile defs (context version + Apple deprecation silence) +target_compile_definitions(rammb-neovis PRIVATE + RAMMB_GL_MAJOR=${RAMMB_GL_MAJOR} + RAMMB_GL_MINOR=${RAMMB_GL_MINOR} + RAMMB_GL_FORWARD_COMPAT=${RAMMB_GL_FORWARD_COMPAT} + RAMMB_HW_H264_ENCODER=\"${RAMMB_HW_H264_ENCODER}\") + +if(APPLE) + target_compile_definitions(rammb-neovis PRIVATE GL_SILENCE_DEPRECATION) +endif() + +if(APPLE AND RAMMB_MACOSX_BUNDLE) + set_target_properties(rammb-neovis PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "RAMMB-NEOVIS" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.crymsondynamo.rammb-neovis" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}") +endif() + +# Copy shaders next to the binary after build (CLI and AppImage layout) add_custom_command(TARGET rammb-neovis POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/shaders $/shaders COMMENT "Copying shaders") +# Also stage shaders into .app Resources when bundling +if(APPLE AND RAMMB_MACOSX_BUNDLE) + add_custom_command(TARGET rammb-neovis POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + ${CMAKE_SOURCE_DIR}/shaders + $/Resources/shaders + COMMENT "Copying shaders into .app Resources") +endif() + # ── Compiler flags ─────────────────────────────────────────────────────────── if(MSVC) target_compile_options(rammb-neovis PRIVATE /W4 /WX-) @@ -138,19 +213,22 @@ endif() include(GNUInstallDirs) install(TARGETS rammb-neovis - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) # /usr/bin/rammb-neovis + BUNDLE DESTINATION . + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) install(DIRECTORY ${CMAKE_SOURCE_DIR}/shaders/ - DESTINATION ${CMAKE_INSTALL_DATADIR}/rammb-neovis/shaders) # /usr/share/rammb-neovis/shaders/ + DESTINATION ${CMAKE_INSTALL_DATADIR}/rammb-neovis/shaders) -# .desktop entry so it shows up in app launchers -configure_file( - ${CMAKE_SOURCE_DIR}/packaging/rammb-neovis.desktop.in - ${CMAKE_CURRENT_BINARY_DIR}/rammb-neovis.desktop @ONLY) -install(FILES ${CMAKE_CURRENT_BINARY_DIR}/rammb-neovis.desktop - DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) +# .desktop entry is Linux desktop-environment specific +if(UNIX AND NOT APPLE) + configure_file( + ${CMAKE_SOURCE_DIR}/packaging/rammb-neovis.desktop.in + ${CMAKE_CURRENT_BINARY_DIR}/rammb-neovis.desktop @ONLY) + install(FILES ${CMAKE_CURRENT_BINARY_DIR}/rammb-neovis.desktop + DESTINATION ${CMAKE_INSTALL_DATADIR}/applications) +endif() -# ── CPack — generates .deb (and .tar.gz as a bonus) ────────────────────────── +# ── CPack — .deb on Linux; DragNDrop when building a macOS .app ────────────── set(CPACK_PACKAGE_NAME "rammb-neovis") set(CPACK_PACKAGE_VENDOR "CrymsonDynamo") set(CPACK_PACKAGE_DESCRIPTION_SUMMARY @@ -162,26 +240,31 @@ set(CPACK_PACKAGE_VERSION_PATCH "0") set(CPACK_PACKAGE_CONTACT "https://github.com/CrymsonDynamo/RAMMB-NEOVIS") set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/CrymsonDynamo/RAMMB-NEOVIS") -# .deb specific -set(CPACK_GENERATOR "DEB") -set(CPACK_DEBIAN_PACKAGE_MAINTAINER "CrymsonDynamo") -set(CPACK_DEBIAN_PACKAGE_SECTION "science") -set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional") -set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "amd64") -set(CPACK_DEBIAN_PACKAGE_DEPENDS - "libglfw3 (>= 3.3), libglew2.2 | libglew2.1, libcurl4, \ +if(UNIX AND NOT APPLE) + set(CPACK_GENERATOR "DEB") + set(CPACK_DEBIAN_PACKAGE_MAINTAINER "CrymsonDynamo") + set(CPACK_DEBIAN_PACKAGE_SECTION "science") + set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional") + set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "amd64") + set(CPACK_DEBIAN_PACKAGE_DEPENDS + "libglfw3 (>= 3.3), libglew2.2 | libglew2.1, libcurl4, \ libavcodec61 | libavcodec60 | libavcodec-extra60 | libavcodec59 | libavcodec-extra59, \ libavformat61 | libavformat60 | libavformat59, \ libavutil59 | libavutil58 | libavutil57, \ libswscale8 | libswscale7 | libswscale6, \ libgl1, zenity") -set(CPACK_DEBIAN_PACKAGE_DESCRIPTION - "RAMMB-NEOVIS — native C++/OpenGL satellite imagery viewer\n" - " Browse and animate GOES, Himawari and JPSS imagery from the RAMMB Slider\n" - " archive. Supports MP4/GIF/PNG export, regional tile selection, scene tabs,\n" - " and date range queries.") - -set(CPACK_PACKAGE_FILE_NAME - "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-linux-amd64") - -include(CPack) + set(CPACK_DEBIAN_PACKAGE_DESCRIPTION + "RAMMB-NEOVIS — native C++/OpenGL satellite imagery viewer\n" + " Browse and animate GOES, Himawari and JPSS imagery from the RAMMB Slider\n" + " archive. Supports MP4/GIF/PNG export, regional tile selection, scene tabs,\n" + " and date range queries.") + set(CPACK_PACKAGE_FILE_NAME + "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-linux-amd64") + include(CPack) +elseif(APPLE AND RAMMB_MACOSX_BUNDLE) + set(CPACK_GENERATOR "DragNDrop") + set(CPACK_DMG_VOLUME_NAME "RAMMB-NEOVIS") + set(CPACK_PACKAGE_FILE_NAME + "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-macos") + include(CPack) +endif() diff --git a/README.md b/README.md index ae6bad7..1073291 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@ A native C++/OpenGL replacement for the [RAMMB Slider](https://rammb-slider.cira.colostate.edu/) satellite imagery browser. Faster, offline-capable, and built for power users who want smooth animation, export, and regional download control. ![RAMMB-NEOVIS screenshot placeholder](image.png) + +**One codebase, multiple OS targets.** Linux and macOS share the same sources and CMake entry point — platform differences (OpenGL context version, dialogs, encode backends, packaging) are isolated behind CMake/`#ifdef` helpers, not a Mac fork. + --- ## Features @@ -12,7 +15,7 @@ A native C++/OpenGL replacement for the [RAMMB Slider](https://rammb-slider.cira - **Tile region selector** — Interactive grid overlay lets you download only the geographic region you care about - **Pause/resume downloads** — Stop background tile downloads without losing loaded frames - **Scene tabs** — Save and switch between multiple satellite/sector/product configurations, browser-tab style -- **Export** — MP4 (H.264 or NVENC), animated GIF, or PNG sequence with a draggable crop box +- **Export** — MP4 (hardware H.264 when available, else libx264), animated GIF, or PNG sequence with a draggable crop box - Output resolution auto-matches the crop aspect ratio (Low / Medium / High / Custom presets) - **Notifications** — Toast popups for load status, tile failures, and export completion - **Settings** — VSync, memory cache limit, auto-reload timer, per-scene naming @@ -36,12 +39,18 @@ Products include GeoColor, individual ABI bands, derived products (sandwich RGB, ### Requirements -- Linux (Ubuntu 22.04 / 24.04 tested; other distros should work) -- C++20 compiler (GCC 12+ or Clang 16+) -- CMake 3.24+ -- GPU with OpenGL 4.5 support +| | Linux | macOS | +|---|--------|--------| +| OS | Ubuntu 22.04 / 24.04 tested (other distros OK) | Recent macOS with Homebrew | +| Compiler | GCC 13+ or Clang 16+ (`std::format`) | Apple Clang (recent Xcode) | +| CMake | 3.24+ | 3.24+ | +| GPU / GL | OpenGL **4.5** core | OpenGL **4.1** core (system max) | + +> **macOS OpenGL note:** Apple deprecated OpenGL (still present and GPU-accelerated via Apple’s GL→Metal path). This project uses OpenGL 4.1 + forward-compat on Mac for a shared renderer with Linux. A Metal backend is optional future work in the same repo — not a separate product. -### Install dependencies (Ubuntu/Debian) +### Install dependencies + +**Ubuntu / Debian** ```bash sudo apt install \ @@ -51,20 +60,53 @@ sudo apt install \ libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \ nlohmann-json3-dev \ libsqlite3-dev \ - zenity # optional: native file browser dialogs + zenity # used by portable file dialogs on Linux ``` +**macOS (Homebrew)** + +```bash +brew install cmake glfw glew glm curl ffmpeg nlohmann-json sqlite pkg-config +``` + +ImGui, stb, and portable-file-dialogs are fetched automatically by CMake on first configure (needs network). + ### Clone and build +Same commands on both platforms (adjust `-j` as you like): + ```bash git clone https://github.com/CrymsonDynamo/RAMMB-NEOVIS.git cd RAMMB-NEOVIS cmake -B build -DCMAKE_BUILD_TYPE=Release -cmake --build build -j$(nproc) +cmake --build build -j$(nproc) # macOS: $(sysctl -n hw.ncpu) ./build/rammb-neovis ``` -ImGui and stb_image are fetched automatically by CMake at configure time (requires internet access on first build). +Shaders are copied next to the binary after build. + +### Optional macOS `.app` bundle + +Default build is a CLI binary next to `shaders/` (same mental model as Linux). To produce a `.app`: + +```bash +cmake -B build -DCMAKE_BUILD_TYPE=Release -DRAMMB_MACOSX_BUNDLE=ON +cmake --build build -j$(sysctl -n hw.ncpu) +open build/rammb-neovis.app +``` + +Shaders land in `Contents/Resources/shaders`. You can also `cpack -G DragNDrop` from `build/` when the bundle option is on. + +--- + +## Hardware acceleration + +| Concern | Linux | macOS | +|---------|-------|-------| +| Display | OpenGL 4.5 | OpenGL 4.1 + forward compat | +| MP4 encode (prefer HW) | `h264_nvenc` → `libx264` | `h264_videotoolbox` → `libx264` | + +The export panel checkbox is **Prefer hardware encoder** — not NVENC-specific. If the HW encoder is missing or fails to open, export falls through to software H.264. --- @@ -78,6 +120,9 @@ ImGui and stb_image are fetched automatically by CMake at configure time (requir | ← / → | Step one frame | | Home | Reset pan and zoom | | R | Reload current source | +| Ctrl+S / Cmd+S | Save scene | +| Ctrl+Shift+S / Cmd+Shift+S | Save scene as | +| Ctrl+O / Cmd+O | Open scene | | Esc | Quit | --- @@ -89,7 +134,7 @@ ImGui and stb_image are fetched automatically by CMake at configure time (requir 3. Pick a format (MP4 / GIF / PNG sequence), resolution preset, and output path 4. Click **Export** — progress is shown in the panel; the export runs in a background thread -For MP4, an NVENC checkbox is available if you have an NVIDIA GPU. Falls back to software x264 automatically. +For MP4, enable **Prefer hardware encoder** to try NVENC (Linux) or VideoToolbox (macOS). Falls back to libx264 automatically. --- @@ -110,6 +155,7 @@ The bar at the top of the window works like browser tabs — each scene stores i ``` src/ app.cpp / app.hpp — Main application loop, GLFW callbacks + platform.cpp / .hpp — OS/GL helpers (context, dialogs, paths, mods) animation.cpp / .hpp — Frame timing and playback modes tile_manager.cpp / .hpp — Tile download, caching, and selection mask thread_pool.hpp — Work queue with pause/resume @@ -125,7 +171,7 @@ src/ tools_panel.cpp — Right-side tile region selector overlay notifications.hpp — Toast notification system shaders/ - tile.vert / tile.frag — GLSL shaders for tile rendering + tile.vert / tile.frag — GLSL 410 shaders for tile rendering ``` --- diff --git a/shaders/tile.frag b/shaders/tile.frag index 395603c..ec723a4 100644 --- a/shaders/tile.frag +++ b/shaders/tile.frag @@ -1,4 +1,4 @@ -#version 450 core +#version 410 core in vec2 v_uv; out vec4 frag_color; diff --git a/shaders/tile.vert b/shaders/tile.vert index eda8ecc..50aaf18 100644 --- a/shaders/tile.vert +++ b/shaders/tile.vert @@ -1,4 +1,4 @@ -#version 450 core +#version 410 core layout(location = 0) in vec2 a_pos; layout(location = 1) in vec2 a_uv; diff --git a/src/app.cpp b/src/app.cpp index e20b7ca..a1711f1 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1,4 +1,5 @@ #include "app.hpp" +#include "platform.hpp" #include "ui/sidebar.hpp" #include "ui/scene_bar.hpp" #include "ui/timeline.hpp" @@ -16,7 +17,6 @@ #include #include #include -#include using json = nlohmann::json; @@ -26,10 +26,9 @@ static constexpr const char* BASE_DATA = "https://slider.cira.colostate.edu/data // Inverse of Renderer::screen_to_world, using the current viewport. // Used by the export crop overlay. glm::vec2 App::world_to_screen(glm::vec2 world) const { - // From renderer internals: nx = ((sx - vp_x)/vp_w)*2-1, ny = -((sy-vp_y)/vp_h)*2+1 - // world.x = pan.x + nx*half_w → nx = (world.x - pan.x) / half_w + // Logical (window) coordinates — same space as ImGui / input / s2w/w2s. int win_w = 1, win_h = 1; - glfwGetFramebufferSize(m_window, &win_w, &win_h); + glfwGetWindowSize(m_window, &win_w, &win_h); float vp_w = float(win_w) - m_sidebar_w; float aspect = vp_w / float(win_h); float half_h = 0.5f / m_renderer.zoom(); @@ -39,7 +38,6 @@ glm::vec2 App::world_to_screen(glm::vec2 world) const { float nx = (world.x - pan.x) / half_w; float ny = -(world.y - pan.y) / half_h; - // vp_x is sidebar_w, vp_y is 0 (top of viewport, OpenGL flips handled) float sx = m_sidebar_w + (nx + 1.0f) * 0.5f * vp_w; float sy = (ny + 1.0f) * 0.5f * float(win_h); return { sx, sy }; @@ -70,10 +68,7 @@ App::~App() { bool App::init(int width, int height) { if (!glfwInit()) { std::cerr << "[App] GLFW init failed\n"; return false; } - glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); - glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 5); - glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); - glfwWindowHint(GLFW_SAMPLES, 4); + platform_gl_hints(); m_window = glfwCreateWindow(width, height, "RAMMB-NEOVIS", nullptr, nullptr); if (!m_window) { std::cerr << "[App] Window failed\n"; glfwTerminate(); return false; } @@ -88,29 +83,35 @@ bool App::init(int width, int height) { glfwMakeContextCurrent(m_window); glfwSwapInterval(1); - if (glewInit() != GLEW_OK) { std::cerr << "[App] GLEW failed\n"; return false; } + if (!platform_glew_init()) return false; std::cout << "OpenGL : " << glGetString(GL_VERSION) << "\n"; std::cout << "GPU : " << glGetString(GL_RENDERER) << "\n"; - // Resolve shader directory: prefer next to the executable, fall back to - // the installed data path (/usr/share/rammb-neovis/shaders). + // Resolve shader directory: prefer next to the executable, then .app + // Resources, installed data path, then cwd-relative shaders/. auto find_shader_dir = []() -> std::string { - // 1. Next to the executable (dev build or portable AppImage) - char buf[4096] = {}; - ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); - if (n > 0) { - std::filesystem::path exe_dir = std::filesystem::path(buf).parent_path(); - if (std::filesystem::exists(exe_dir / "shaders" / "tile.vert")) - return (exe_dir / "shaders").string(); + std::string exe_dir = executable_directory(); + if (!exe_dir.empty()) { + std::filesystem::path base(exe_dir); + // 1. Next to the executable (dev build, AppImage, or Mac CLI) + if (std::filesystem::exists(base / "shaders" / "tile.vert")) + return (base / "shaders").string(); + // 2. macOS .app: Contents/MacOS/../Resources/shaders + auto resources = base.parent_path() / "Resources" / "shaders" / "tile.vert"; + if (std::filesystem::exists(resources)) + return resources.parent_path().string(); } - // 2. Installed data dir + // 3. Installed data dir (Linux .deb; harmless miss on macOS) if (std::filesystem::exists("/usr/share/rammb-neovis/shaders/tile.vert")) return "/usr/share/rammb-neovis/shaders"; - // 3. Relative fallback (cwd) + // 4. Relative fallback (cwd) return "shaders"; }; if (!m_renderer.init(find_shader_dir().c_str())) return false; - m_renderer.resize(width, height); + // GL viewport is in framebuffer pixels (may differ from window size on Retina). + int fb_w = width, fb_h = height; + glfwGetFramebufferSize(m_window, &fb_w, &fb_h); + m_renderer.resize(fb_w, fb_h); // ── ImGui ───────────────────────────────────────────────────────────────── IMGUI_CHECKVERSION(); @@ -138,7 +139,7 @@ bool App::init(int width, int height) { ImGui::GetIO().IniFilename = nullptr; ImGui_ImplGlfw_InitForOpenGL(m_window, true); - ImGui_ImplOpenGL3_Init("#version 450"); + ImGui_ImplOpenGL3_Init(platform_imgui_glsl_version()); m_running = true; reload_source(); @@ -147,28 +148,11 @@ bool App::init(int width, int height) { // ── Scene file I/O ──────────────────────────────────────────────────────────── -static std::string popen_line(const std::string& cmd) { - FILE* f = popen(cmd.c_str(), "r"); - if (!f) return ""; - char buf[4096] = {}; - if (fgets(buf, sizeof(buf), f)) { - size_t n = strlen(buf); - if (n > 0 && buf[n - 1] == '\n') buf[n - 1] = '\0'; - } - pclose(f); - return buf; -} - std::string App::dialog_save_path() { std::string def = m_scene_bar.current_file.empty() ? "untitled.rnvs" : m_scene_bar.current_file; - // Escape double-quotes in the path just in case - std::string cmd = - "zenity --file-selection --save --confirm-overwrite " - "--file-filter=\"*.rnvs\" " - "--title=\"Save Scene\" " - "--filename=\"" + def + "\" 2>/dev/null"; - std::string path = popen_line(cmd); + std::string path = platform_save_file( + "Save Scene", def.c_str(), "Scene files", "*.rnvs"); // Ensure .rnvs extension if (!path.empty() && (path.size() < 5 || path.substr(path.size() - 5) != ".rnvs")) @@ -177,10 +161,8 @@ std::string App::dialog_save_path() { } std::string App::dialog_open_path() { - return popen_line( - "zenity --file-selection " - "--file-filter=\"*.rnvs\" " - "--title=\"Open Scene\" 2>/dev/null"); + return platform_open_file( + "Open Scene", "", "Scene files", "*.rnvs"); } void App::update_window_title() { @@ -408,8 +390,9 @@ void App::update(float dt) { } // ── Update TileManager with current viewport ────────────────────────────── + // Logical window coords — must match scroll/pan and ImGui layout (HiDPI-safe). int win_w = 1, win_h = 1; - glfwGetFramebufferSize(m_window, &win_w, &win_h); + glfwGetWindowSize(m_window, &win_w, &win_h); static constexpr float BOTTOM_H = 80.0f + 32.0f; // timeline + status bar float vp_w = float(win_w) - m_sidebar_w; float render_h = std::max(1.0f, float(win_h) - BOTTOM_H - m_bar_h); @@ -439,8 +422,13 @@ void App::render() { ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame(); + // Layout + input use logical window coordinates (ImGui space). + // GL viewport/scissor use framebuffer pixels via content scale. int win_w = 1, win_h = 1; - glfwGetFramebufferSize(m_window, &win_w, &win_h); + int fb_w = 1, fb_h = 1; + glfwGetWindowSize(m_window, &win_w, &win_h); + glfwGetFramebufferSize(m_window, &fb_w, &fb_h); + ContentScale scale = window_content_scale(m_window); // ── Scene tabs + settings bar (top of window, full width) ──────────────── float bar_h = scene_bar_draw(m_scene_bar, m_state, float(win_w), float(win_h)); @@ -609,16 +597,19 @@ void App::render() { } } - // ── 3D tile render ──────────────────────────────────────────────────────── - int render_h_i = int(render_h); - glScissor(int(m_sidebar_w), int(bottom_h), int(vp_w), render_h_i); + // ── 3D tile render (framebuffer pixels) ─────────────────────────────────── + int gl_x = int(vp_left * scale.x); + int gl_y = int(bottom_h * scale.y); + int gl_w = std::max(1, int(vp_w * scale.x)); + int gl_h = std::max(1, int(render_h * scale.y)); + glScissor(gl_x, gl_y, gl_w, gl_h); glEnable(GL_SCISSOR_TEST); - m_renderer.resize_viewport(int(vp_left), int(bottom_h), int(vp_w), render_h_i); + m_renderer.resize_viewport(gl_x, gl_y, gl_w, gl_h); m_renderer.begin_frame(); m_tiles.draw(m_state.current_frame, m_renderer); m_overlays.draw(m_state.overlays, m_renderer); glDisable(GL_SCISSOR_TEST); - m_renderer.resize_viewport(0, 0, win_w, win_h); + m_renderer.resize_viewport(0, 0, fb_w, fb_h); // ── Notification toasts (bottom-right) ─────────────────────────────────── notifs_draw(float(win_w), float(win_h), bottom_h); @@ -852,17 +843,17 @@ void App::cb_key(GLFWwindow* w, int key, int /*sc*/, int action, int mods) { if (action != GLFW_PRESS) return; auto* app = static_cast(glfwGetWindowUserPointer(w)); - // Ctrl+S / Ctrl+Shift+S / Ctrl+O work even when ImGui has keyboard focus - bool ctrl = (mods & GLFW_MOD_CONTROL) != 0; - bool shift = (mods & GLFW_MOD_SHIFT) != 0; - if (ctrl && key == GLFW_KEY_S) { + // Ctrl (Linux) / Cmd (macOS) + S / Shift+S / O — even when ImGui has focus + bool primary = platform_primary_mod(mods); + bool shift = (mods & GLFW_MOD_SHIFT) != 0; + if (primary && key == GLFW_KEY_S) { if (shift || app->m_scene_bar.current_file.empty()) app->m_scene_bar.save_as_requested = true; else app->m_scene_bar.save_requested = true; return; } - if (ctrl && key == GLFW_KEY_O) { + if (primary && key == GLFW_KEY_O) { app->m_scene_bar.open_requested = true; return; } diff --git a/src/app.hpp b/src/app.hpp index 1b2a1a1..4acbb4b 100644 --- a/src/app.hpp +++ b/src/app.hpp @@ -44,13 +44,13 @@ class App { // Apply state.frame_timestamps → AnimationController + TileManager void apply_frames(); - // Scene file I/O (use zenity for file dialogs) + // Scene file I/O (native dialogs via platform helpers) void save_scene(); void save_scene_as(); void open_scene(); void update_window_title(); - // Returns path chosen by zenity, or "" if cancelled. + // Returns path chosen by native dialog, or "" if cancelled. std::string dialog_save_path(); std::string dialog_open_path(); diff --git a/src/export/exporter.cpp b/src/export/exporter.cpp index b72a3e2..dad9830 100644 --- a/src/export/exporter.cpp +++ b/src/export/exporter.cpp @@ -14,9 +14,125 @@ extern "C" { #include #include #include +#include +#include +#include +#include namespace fs = std::filesystem; +#ifndef RAMMB_HW_H264_ENCODER +#ifdef __APPLE__ +#define RAMMB_HW_H264_ENCODER "h264_videotoolbox" +#else +#define RAMMB_HW_H264_ENCODER "h264_nvenc" +#endif +#endif + +// Platform preference chain for MP4 H.264 (one exporter, pluggable backends). +// Linux: h264_nvenc → libx264 → AV_CODEC_ID_H264 +// macOS: h264_videotoolbox → libx264 → AV_CODEC_ID_H264 +static std::vector h264_encoder_chain(bool prefer_hw) { + std::vector chain; + if (prefer_hw) + chain.push_back(RAMMB_HW_H264_ENCODER); + chain.push_back("libx264"); + return chain; +} + +static void apply_encoder_options(AVCodecContext* cc, const AVCodec* codec, + const ExportSettings& settings) { + const char* name = codec->name ? codec->name : ""; + if (std::strstr(name, "nvenc")) { + av_opt_set(cc->priv_data, "preset", "p4", 0); + av_opt_set(cc->priv_data, "rc", "vbr", 0); + av_opt_set_int(cc->priv_data, "cq", settings.crf, 0); + } else if (std::strstr(name, "videotoolbox")) { + // VT has no CRF; map quality slider → bitrate (rough bpp from CRF). + float q = std::clamp((51.0f - float(settings.crf)) / 51.0f, 0.08f, 1.0f); + int64_t br = int64_t(double(cc->width) * double(cc->height) + * double(settings.fps) * 0.20 * double(q)); + cc->bit_rate = std::max(br, 500000); + if (cc->priv_data) { + av_opt_set(cc->priv_data, "realtime", "false", 0); + av_opt_set(cc->priv_data, "allow_sw", "true", 0); + } + } else { + // libx264 / generic software + if (cc->priv_data) { + av_opt_set(cc->priv_data, "preset", "fast", 0); + av_opt_set(cc->priv_data, "crf", std::to_string(settings.crf).c_str(), 0); + av_opt_set(cc->priv_data, "profile", "main", 0); + } + } +} + +// Find + open first working encoder in the preference chain. +static bool open_h264_encoder(const ExportSettings& settings, int W, int H, + float fps, AVCodecContext** out_cc, + const AVCodec** out_codec, std::string& err) { + auto chain = h264_encoder_chain(settings.prefer_hw_encoder); + const int TB_DEN = 1000; + const int fps_n = std::max(1, int(std::lround(fps))); + + for (const char* name : chain) { + const AVCodec* codec = avcodec_find_encoder_by_name(name); + if (!codec) + continue; + + AVCodecContext* cc = avcodec_alloc_context3(codec); + if (!cc) + continue; + cc->width = W; + cc->height = H; + cc->time_base = { 1, TB_DEN }; + cc->framerate = { fps_n, 1 }; + cc->pix_fmt = AV_PIX_FMT_YUV420P; + cc->gop_size = 10; + cc->max_b_frames = 0; + // MP4 muxer needs global headers; must be set before open. + cc->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; + apply_encoder_options(cc, codec, settings); + + if (avcodec_open2(cc, codec, nullptr) < 0) { + avcodec_free_context(&cc); + continue; // try next candidate (e.g. NVENC present but no GPU) + } + *out_cc = cc; + *out_codec = codec; + return true; + } + + // Last resort: any H.264 encoder FFmpeg knows about + const AVCodec* codec = avcodec_find_encoder(AV_CODEC_ID_H264); + if (!codec) { + err = "No H.264 encoder found"; + return false; + } + AVCodecContext* cc = avcodec_alloc_context3(codec); + if (!cc) { + err = "avcodec_alloc_context3 failed"; + return false; + } + cc->width = W; + cc->height = H; + cc->time_base = { 1, TB_DEN }; + cc->framerate = { fps_n, 1 }; + cc->pix_fmt = AV_PIX_FMT_YUV420P; + cc->gop_size = 10; + cc->max_b_frames = 0; + cc->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; + apply_encoder_options(cc, codec, settings); + if (avcodec_open2(cc, codec, nullptr) < 0) { + avcodec_free_context(&cc); + err = "avcodec_open2 failed for all H.264 encoders"; + return false; + } + *out_cc = cc; + *out_codec = codec; + return true; +} + // ── Exporter control ────────────────────────────────────────────────────────── bool Exporter::start(const ExportSettings& settings, @@ -87,59 +203,38 @@ bool Exporter::write_mp4(const ExportSettings& settings, int H = (settings.out_height / 2) * 2; std::string path = settings.output_path; - // Pick encoder: prefer NVENC, fall back to libx264 - const AVCodec* codec = nullptr; - if (settings.use_nvenc) codec = avcodec_find_encoder_by_name("h264_nvenc"); - if (!codec) codec = avcodec_find_encoder_by_name("libx264"); - if (!codec) codec = avcodec_find_encoder(AV_CODEC_ID_H264); - if (!codec) { prog.error_msg = "No H.264 encoder found"; return false; } - - AVFormatContext* fmt_ctx = nullptr; - avformat_alloc_output_context2(&fmt_ctx, nullptr, nullptr, path.c_str()); - if (!fmt_ctx) { prog.error_msg = "avformat_alloc failed"; return false; } - - AVStream* stream = avformat_new_stream(fmt_ctx, nullptr); - if (!stream) { avformat_free_context(fmt_ctx); prog.error_msg = "avformat_new_stream failed"; return false; } - // Use millisecond time base — supports any FPS cleanly. // pts increments by pts_per_frame each frame so duration is exact. - const int TB_DEN = 1000; + const int TB_DEN = 1000; const int64_t pts_per_frame = int64_t(TB_DEN / settings.fps); // ms per frame - const int fps_n = std::max(1, int(std::lround(settings.fps))); - AVCodecContext* cc = avcodec_alloc_context3(codec); - cc->width = W; - cc->height = H; - cc->time_base = { 1, TB_DEN }; // 1 ms precision - cc->framerate = { fps_n, 1 }; - cc->pix_fmt = AV_PIX_FMT_YUV420P; - cc->gop_size = 10; - cc->max_b_frames = 0; + AVCodecContext* cc = nullptr; + const AVCodec* codec = nullptr; + if (!open_h264_encoder(settings, W, H, settings.fps, &cc, &codec, prog.error_msg)) + return false; + std::cout << "[export] H.264 encoder: " << codec->name << "\n"; - bool is_nvenc = std::string(codec->name).find("nvenc") != std::string::npos; - if (is_nvenc) { - av_opt_set(cc->priv_data, "preset", "p4", 0); - av_opt_set(cc->priv_data, "rc", "vbr", 0); - av_opt_set_int(cc->priv_data, "cq", settings.crf, 0); - } else { - av_opt_set(cc->priv_data, "preset", "fast", 0); - av_opt_set(cc->priv_data, "crf", std::to_string(settings.crf).c_str(), 0); - av_opt_set(cc->priv_data, "profile", "main", 0); // broad player compatibility + AVFormatContext* fmt_ctx = nullptr; + avformat_alloc_output_context2(&fmt_ctx, nullptr, nullptr, path.c_str()); + if (!fmt_ctx) { + avcodec_free_context(&cc); + prog.error_msg = "avformat_alloc failed"; + return false; } - if (fmt_ctx->oformat->flags & AVFMT_GLOBALHEADER) - cc->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; + AVStream* stream = avformat_new_stream(fmt_ctx, nullptr); + if (!stream) { + avcodec_free_context(&cc); + avformat_free_context(fmt_ctx); + prog.error_msg = "avformat_new_stream failed"; + return false; + } // faststart: move moov atom to front of file so players can seek without buffering av_opt_set(fmt_ctx->priv_data, "movflags", "faststart", 0); stream->time_base = { 1, TB_DEN }; - if (avcodec_open2(cc, codec, nullptr) < 0) { - avcodec_free_context(&cc); avformat_free_context(fmt_ctx); - prog.error_msg = "avcodec_open2 failed"; - return false; - } avcodec_parameters_from_context(stream->codecpar, cc); if (!(fmt_ctx->oformat->flags & AVFMT_NOFILE)) { diff --git a/src/export/exporter.hpp b/src/export/exporter.hpp index 0cb39a0..d4e7d0f 100644 --- a/src/export/exporter.hpp +++ b/src/export/exporter.hpp @@ -31,8 +31,8 @@ struct ExportSettings { // Video/GIF float fps = 10.0f; - int crf = 18; // quality for x264/x265 (lower=better) - bool use_nvenc= true; // prefer NVENC h264 if available + int crf = 18; // quality for x264 (lower=better); guides HW bitrate too + bool prefer_hw_encoder = true; // NVENC (Linux) / VideoToolbox (macOS) → libx264 // PNG sequence: prefix, e.g. "/path/to/frame_" → frame_000.png std::string png_prefix = "frame_"; diff --git a/src/platform.cpp b/src/platform.cpp new file mode 100644 index 0000000..25c2985 --- /dev/null +++ b/src/platform.cpp @@ -0,0 +1,141 @@ +#include "platform.hpp" + +#include +#include +#include + +#include +#include +#include + +#ifdef __APPLE__ +#include +#else +#include +#endif + +#ifndef RAMMB_GL_MAJOR +#ifdef __APPLE__ +#define RAMMB_GL_MAJOR 4 +#define RAMMB_GL_MINOR 1 +#define RAMMB_GL_FORWARD_COMPAT 1 +#else +#define RAMMB_GL_MAJOR 4 +#define RAMMB_GL_MINOR 5 +#define RAMMB_GL_FORWARD_COMPAT 0 +#endif +#endif + +void platform_gl_hints() { + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, RAMMB_GL_MAJOR); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, RAMMB_GL_MINOR); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); +#if RAMMB_GL_FORWARD_COMPAT + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); +#endif + glfwWindowHint(GLFW_SAMPLES, 4); +} + +bool platform_glew_init() { +#ifdef __APPLE__ + // Required on macOS before glewInit(); harmless elsewhere if ever set always. + glewExperimental = GL_TRUE; +#endif + GLenum err = glewInit(); + // GLEW may report GL_INVALID_ENUM on core profiles after a successful init. + glGetError(); + if (err != GLEW_OK) { + std::cerr << "[platform] GLEW failed: " << glewGetErrorString(err) << "\n"; + return false; + } + return true; +} + +const char* platform_imgui_glsl_version() { + // Universal GLSL 410 — Linux 4.5 contexts accept it; macOS is capped at 4.1. + return "#version 410"; +} + +std::string executable_directory() { +#ifdef __APPLE__ + char initial[1024]; + uint32_t size = sizeof(initial); + std::string path; + if (_NSGetExecutablePath(initial, &size) == 0) { + path = initial; + } else { + std::vector buf(size); + if (_NSGetExecutablePath(buf.data(), &size) != 0) + return {}; + path.assign(buf.data()); + } + std::error_code ec; + auto resolved = std::filesystem::canonical(path, ec); + if (ec) + resolved = std::filesystem::path(path); + return resolved.parent_path().string(); +#else + char buf[4096] = {}; + ssize_t n = readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (n <= 0) + return {}; + buf[n] = '\0'; + return std::filesystem::path(buf).parent_path().string(); +#endif +} + +ContentScale window_content_scale(GLFWwindow* window) { + ContentScale s; + if (!window) + return s; + // Prefer framebuffer/window ratio — matches the pixels GL actually renders to. + int win_w = 1, win_h = 1, fb_w = 1, fb_h = 1; + glfwGetWindowSize(window, &win_w, &win_h); + glfwGetFramebufferSize(window, &fb_w, &fb_h); + if (win_w > 0 && win_h > 0) { + s.x = float(fb_w) / float(win_w); + s.y = float(fb_h) / float(win_h); + } + return s; +} + +bool platform_primary_mod(int glfw_mods) { + return (glfw_mods & (GLFW_MOD_CONTROL | GLFW_MOD_SUPER)) != 0; +} + +std::string platform_open_file(const char* title, + const char* default_path, + const char* filter_desc, + const char* filter_glob) { + if (!pfd::settings::available()) + return {}; + auto sel = pfd::open_file( + title ? title : "Open", + default_path ? default_path : "", + { filter_desc ? filter_desc : "All Files", + filter_glob ? filter_glob : "*" }); + auto result = sel.result(); + return result.empty() ? std::string{} : result.front(); +} + +std::string platform_save_file(const char* title, + const char* default_path, + const char* filter_desc, + const char* filter_glob) { + if (!pfd::settings::available()) + return {}; + return pfd::save_file( + title ? title : "Save", + default_path ? default_path : "", + { filter_desc ? filter_desc : "All Files", + filter_glob ? filter_glob : "*" }).result(); +} + +std::string platform_pick_folder(const char* title, + const char* default_path) { + if (!pfd::settings::available()) + return {}; + return pfd::select_folder( + title ? title : "Select Folder", + default_path ? default_path : "").result(); +} diff --git a/src/platform.hpp b/src/platform.hpp new file mode 100644 index 0000000..2264944 --- /dev/null +++ b/src/platform.hpp @@ -0,0 +1,41 @@ +#pragma once +#include + +struct GLFWwindow; + +// Thin platform helpers — keep OS/GL differences out of app hot paths. + +// GLFW context version hints (4.1+forward on Apple, 4.5 elsewhere). +void platform_gl_hints(); + +// Safe GLEW init for all platforms (sets glewExperimental on Apple). +bool platform_glew_init(); + +// GLSL version string for ImGui OpenGL3 backend (matches shared shader policy). +const char* platform_imgui_glsl_version(); + +// Directory containing the running executable (no trailing slash). +std::string executable_directory(); + +// Window content scale (framebuffer / window). Typically 1 on Linux, 2 on Retina. +struct ContentScale { + float x{1.0f}; + float y{1.0f}; +}; +ContentScale window_content_scale(GLFWwindow* window); + +// True if Control or Super/Cmd is held — shared accelerators on Linux and macOS. +bool platform_primary_mod(int glfw_mods); + +// Native file dialogs (portable-file-dialogs: zenity/kdialog on Linux, osascript on Mac). +// Returns selected path, or "" if cancelled / unavailable. +std::string platform_open_file(const char* title, + const char* default_path = "", + const char* filter_desc = "All Files", + const char* filter_glob = "*"); +std::string platform_save_file(const char* title, + const char* default_path = "", + const char* filter_desc = "All Files", + const char* filter_glob = "*"); +std::string platform_pick_folder(const char* title, + const char* default_path = ""); diff --git a/src/ui/export_panel.cpp b/src/ui/export_panel.cpp index 16aa9e6..6cdfc6a 100644 --- a/src/ui/export_panel.cpp +++ b/src/ui/export_panel.cpp @@ -1,28 +1,19 @@ #include "export_panel.hpp" +#include "platform.hpp" #include #include #include -#include #include #include #include namespace fs = std::filesystem; -// Opens a native file/folder picker via zenity (Linux). Returns selected path or "". +// Shared native file/folder picker (zenity/kdialog on Linux, osascript on macOS). static std::string run_file_picker(bool directory_only, const char* title) { - std::string cmd = directory_only - ? std::format("zenity --file-selection --directory --title=\"{}\" 2>/dev/null", title) - : std::format("zenity --file-selection --save --confirm-overwrite --title=\"{}\" 2>/dev/null", title); - FILE* f = popen(cmd.c_str(), "r"); - if (!f) return {}; - char buf[1024] = {}; - bool got = (fgets(buf, sizeof(buf), f) != nullptr); - pclose(f); - if (!got) return {}; - std::string result(buf); - if (!result.empty() && result.back() == '\n') result.pop_back(); - return result; + if (directory_only) + return platform_pick_folder(title); + return platform_save_file(title, "", "All Files", "*"); } static const ImVec4 COL_ACCENT = { 0.20f, 0.60f, 1.00f, 1.0f }; @@ -362,8 +353,15 @@ bool export_panel_draw(ExportState& state, ImGui::SliderInt("##crf", &state.settings.crf, 0, 51, "%d"); ImGui::TextColored({ 0.55f,0.55f,0.55f,1.0f }, " 0=lossless 18=great 28=small"); - bool& nvenc = state.settings.use_nvenc; - ImGui::Checkbox("Use NVENC (RTX 5070 Ti)", &nvenc); + bool& hw = state.settings.prefer_hw_encoder; + ImGui::Checkbox("Prefer hardware encoder", &hw); + ImGui::TextColored({ 0.55f,0.55f,0.55f,1.0f }, +#ifdef __APPLE__ + " VideoToolbox → libx264 if unavailable" +#else + " NVENC → libx264 if unavailable" +#endif + ); } ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); }