From d1d8df5674f87d317bb0fc98cd3129421571d3e8 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Tue, 10 Mar 2026 16:56:14 -0700 Subject: [PATCH 01/18] fix(utility): resolve Windows path handling bugs breaking EXR sequence loading Three issues prevented image sequences (EXR, etc.) from loading on Windows: 1. Broken backslash regex in scan_posix_path() - the character class [\] matched ']' instead of '\', leaving backslashes in scanned file paths. 2. pad_size() returned 0 for non-zero-padded frame numbers (e.g. "1000"), producing invalid %00d / {:00d} format specifiers that failed extension matching in is_file_supported(). 3. posix_path_to_uri() did not normalise backslashes to forward slashes on Windows, leaking them into file: URIs. Co-Authored-By: Claude Opus 4.6 --- src/utility/src/helpers.cpp | 7 ++++++- src/utility/src/sequence.cpp | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/utility/src/helpers.cpp b/src/utility/src/helpers.cpp index d52b8e3df..1985353b2 100644 --- a/src/utility/src/helpers.cpp +++ b/src/utility/src/helpers.cpp @@ -6,6 +6,7 @@ #include #include #endif +#include #include #include #include @@ -573,6 +574,10 @@ caf::uri xstudio::utility::posix_path_to_uri(const std::string &path, const bool p = reverse_remap_file_path(path); +#ifdef _WIN32 + // Normalise Windows backslashes to forward slashes for a valid file URI. + std::replace(p.begin(), p.end(), '\\', '/'); +#endif // spdlog::warn("posix_path_to_uri: {} -> {}", path, p); @@ -646,7 +651,7 @@ xstudio::utility::scan_posix_path(const std::string &path, const int depth) { auto more = scan_posix_path(_path, depth - 1); items.insert(items.end(), more.begin(), more.end()); } else if (fs::is_regular_file(entry)) - files.push_back(std::regex_replace(_path, std::regex("[\]"), "/")); + files.push_back(std::regex_replace(_path, std::regex("\\\\"), "/")); #else const std::string _path = entry.path(); const std::string _filename = entry.path().filename(); diff --git a/src/utility/src/sequence.cpp b/src/utility/src/sequence.cpp index afa6ef48d..1b828344d 100644 --- a/src/utility/src/sequence.cpp +++ b/src/utility/src/sequence.cpp @@ -337,8 +337,11 @@ std::vector default_collapse_sequences(const std::vector &entri int pad_size(const std::string &frame) { // -01 == pad 3 - // 0 pad means unknown padding - return (std::to_string(std::atoi(frame.c_str())).size() == frame.size() ? 0 : frame.size()); + // If the string representation matches the numeric length, the frame has no + // explicit zero-padding. Return the natural width so the format specifier + // can still round-trip (e.g. frame "1000" → pad 4 → %04d → {:04d}). + // Returning 0 would produce %00d / {:00d} which is invalid. + return static_cast(frame.size()); } std::string pad_spec(const int pad) { From a81ef86e2e934d302e770ce5e8096de875c0ad44 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Fri, 13 Mar 2026 11:48:54 -0700 Subject: [PATCH 02/18] feat(ui): add review mode flag and viewport drag-drop support Add --review / -v CLI flag to launch xstudio in "Present" layout (viewport only, no playlists or timeline). This overrides saved user layout preferences on startup. Also add drag-drop handling to the viewport panel so files can be dropped directly onto the viewer in any layout. Previously drops were only handled by the media list panel, which doesn't exist in the Present layout. Bump version to 1.1.1. Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 2 +- src/launch/xstudio/src/xstudio.cpp | 13 +++++++ .../views/viewport/XsViewportPanel.qml | 34 ++++++++++++++++++- ui/qml/xstudio/windows/XsSessionWindow.qml | 5 +++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1206e9cad..9e34e054f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.28 FATAL_ERROR) cmake_policy(VERSION 3.28) -set(XSTUDIO_GLOBAL_VERSION "1.1.0" CACHE STRING "Version string") +set(XSTUDIO_GLOBAL_VERSION "1.1.1" CACHE STRING "Version string") set(XSTUDIO_GLOBAL_NAME xStudio) # set(CMAKE_OSX_DEPLOYMENT_TARGET "14.5" CACHE STRING "Minimum OS X deployment version" FORCE) diff --git a/src/launch/xstudio/src/xstudio.cpp b/src/launch/xstudio/src/xstudio.cpp index 8fce8e8cc..a37b5ad75 100644 --- a/src/launch/xstudio/src/xstudio.cpp +++ b/src/launch/xstudio/src/xstudio.cpp @@ -51,6 +51,7 @@ CAF_PUSH_WARNINGS #include #include #include +#include #include #include #include @@ -195,6 +196,7 @@ void execute_xstudio_ui( const bool disble_vsync, const float ui_scale_factor, const bool silence_qt_warnings, + const bool review_mode, int argc, char **argv) { @@ -238,6 +240,8 @@ void execute_xstudio_ui( ui::qml::setup_xstudio_qml_emgine( static_cast(&engine), CafActorSystem::system()); + engine.rootContext()->setContextProperty("reviewModeEnabled", review_mode); + const QUrl url(QStringLiteral("qrc:/main.qml")); QObject::connect( @@ -557,6 +561,12 @@ struct CLIArguments { "Open a quick-view for each supplied media item", {'l', "quick-view"}}; + args::Flag review_mode = { + parser, + "review", + "Launch in review mode (viewport only, no playlists or timeline)", + {'v', "review"}}; + args::Flag silence_qt_warnings = { parser, "silence-qt-warnings", @@ -655,6 +665,7 @@ struct Launcher { actions["debug"] = cli_args.debug.Matched(); actions["user_prefs_off"] = cli_args.user_prefs_off.Matched(); actions["quick_view"] = cli_args.quick_view.Matched(); + actions["review_mode"] = cli_args.review_mode.Matched(); actions["disable_vsync"] = cli_args.disable_vsync.Matched(); actions["compare"] = static_cast(args::get(cli_args.compare)); actions["silence_qt_warnings"] = cli_args.silence_qt_warnings.Matched(); @@ -895,6 +906,7 @@ struct Launcher { "headless": false, "new_instance": false, "quick_view": false, + "review_mode": false, "session_name": "", "open_session": false, "debug": false, @@ -1080,6 +1092,7 @@ int main(int argc, char **argv) { l.actions["disable_vsync"], l.prefs.get("/ui/qml/global_ui_scale_factor").value("value", 1.0f), l.actions["silence_qt_warnings"], + l.actions["review_mode"], argc, argv); diff --git a/ui/qml/xstudio/views/viewport/XsViewportPanel.qml b/ui/qml/xstudio/views/viewport/XsViewportPanel.qml index 829edff9e..95eb5d37b 100644 --- a/ui/qml/xstudio/views/viewport/XsViewportPanel.qml +++ b/ui/qml/xstudio/views/viewport/XsViewportPanel.qml @@ -1,10 +1,12 @@ import QtQuick import QtQuick.Layouts +import QuickFuture 1.0 import xStudio 1.0 import xstudio.qml.viewport 1.0 import xstudio.qml.models 1.0 import xstudio.qml.helpers 1.0 +import xstudio.qml.session 1.0 import "./widgets" @@ -147,6 +149,36 @@ Rectangle{ } + XsDragDropHandler { + + id: viewportDragDropHandler + targetWidget: viewportWidget + dragSourceName: "Viewport" + + onDropped: (mousePosition, source, data) => { + + if (source !== "External URIS" && source !== "External JSON") + return + + var dropData = (source === "External URIS") + ? {"text/uri-list": data} + : data + + // Use the current playlist if one exists, otherwise create one + var idx = theSessionData.currentMediaContainerIndex + if (!idx || !idx.valid) { + idx = theSessionData.createPlaylist(theSessionData.getNextName("Playlist {}")) + } + + Future.promise( + theSessionData.handleDropFuture( + Qt.CopyAction, + dropData, + idx) + ).then(function(quuids){}) + } + } + XsLabel { text: "Media Not Found" color: XsStyleSheet.hintColor @@ -182,7 +214,7 @@ Rectangle{ placement: "top" } - XsViewport { + XsViewport { id: viewport Layout.fillWidth: true Layout.fillHeight: true diff --git a/ui/qml/xstudio/windows/XsSessionWindow.qml b/ui/qml/xstudio/windows/XsSessionWindow.qml index 3000171aa..b91d6eb08 100644 --- a/ui/qml/xstudio/windows/XsSessionWindow.qml +++ b/ui/qml/xstudio/windows/XsSessionWindow.qml @@ -57,6 +57,11 @@ ApplicationWindow { appWindow.height = ui_layouts_model.get(ui_layouts_model.root_index, "height") numLayouts = ui_layouts_model.rowCount(root_index) visible = true + + // --review / -v flag: force Present layout on startup + if (typeof reviewModeEnabled !== "undefined" && reviewModeEnabled) { + setLayoutName("Present") + } } property var numLayouts: 0 From ea62511718be103d9d5138fa569c953ddbb96fd0 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Fri, 13 Mar 2026 11:49:14 -0700 Subject: [PATCH 03/18] feat(ui): show Gamma and Saturation controls in viewport toolbar Remove Gamma and Saturation from the default hidden toolbar items so they are visible by default in the viewport toolbar. Co-Authored-By: Claude Opus 4.6 --- share/preference/ui_viewport.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/share/preference/ui_viewport.json b/share/preference/ui_viewport.json index 7b80ed9cf..c60f5e760 100644 --- a/share/preference/ui_viewport.json +++ b/share/preference/ui_viewport.json @@ -65,7 +65,7 @@ "path": "/ui/viewport/hidden_toolbar_items", "default_value": false, "description": "Hides items from the viewport toolbar", - "value": ["Gamma", "Saturation", "Rate", "Mirror"], + "value": ["Rate", "Mirror"], "datatype": "json", "context": ["QML_UI"] } From d4f20eff3affd7e32abc74c37e3cce6a2d96f393 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sat, 14 Mar 2026 07:48:03 -0700 Subject: [PATCH 04/18] feat: EXR performance, hotkey editor, viewport fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXR Performance: - Dispatch up to 4 concurrent precache requests in do_precache() - Add configurable EXR decompression thread count preference (default 16) - Fix off-by-one in EXR resolution reporting (max-min → max-min+1) - Crop data window to display window by default (0% overscan) - Add standalone EXR benchmark tool Hotkey Editor: - Replace read-only hotkey viewer with full interactive editor - Click-to-capture key rebinding with conflict detection and warnings - Persistent hotkey overrides saved to %LOCALAPPDATA%/xstudio/ - Search filtering, per-key and reset-all functionality - Scrollbar for overflow content Viewport Fixes: - Fix SSBO shader debug colors (red/blue → black) for out-of-bounds pixels - Fix FBO texture wrap mode (GL_CLAMP_TO_EDGE → GL_CLAMP_TO_BORDER) - Fix overscan display: crop EXR data window to display window by default Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 + CLAUDE.md | 72 ++ CMakeLists.txt | 7 +- bench/exr_benchmark/CMakeLists.txt | 22 + bench/exr_benchmark/exr_benchmark.cpp | 858 ++++++++++++++++++ .../media_reader/frame_request_queue.hpp | 3 +- include/xstudio/ui/qml/hotkey_ui.hpp | 21 + .../plugin_media_reader_openexr.json | 18 +- src/media_reader/src/frame_request_queue.cpp | 9 +- src/media_reader/src/media_reader_actor.cpp | 39 +- .../media_reader/openexr/src/openexr.cpp | 39 +- .../media_reader/openexr/src/openexr.hpp | 1 + src/ui/opengl/src/shader_program_base.cpp | 4 +- src/ui/qml/viewport/src/hotkey_ui.cpp | 254 +++++- .../src/offscreen_viewport.cpp | 6 +- ui/qml/xstudio/qml.qrc | 1 + .../dialogs/hotkeys/XsHotkeysDialog.qml | 142 ++- .../delegates/XsHotkeyEditorDelegate.qml | 296 ++++++ 18 files changed, 1728 insertions(+), 68 deletions(-) create mode 100644 CLAUDE.md create mode 100644 bench/exr_benchmark/CMakeLists.txt create mode 100644 bench/exr_benchmark/exr_benchmark.cpp create mode 100644 ui/qml/xstudio/widgets/dialogs/hotkeys/delegates/XsHotkeyEditorDelegate.qml diff --git a/.gitignore b/.gitignore index f55c0a2d1..002f0f4cd 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ python/src/xstudio/version.py /build/ xstudio_install/ **/qml/*_qml_export.h + +# Dolt database files (added by bd init) +.dolt/ +*.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..f10019967 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# xSTUDIO - Project Guide + +## What is xSTUDIO? +Professional media playback and review application for VFX/film post-production. +- C++17/20, Qt6 QML frontend, CAF (C++ Actor Framework) for concurrency +- OpenEXR, FFmpeg media readers as plugins +- OpenGL GPU-accelerated viewport rendering +- Plugin architecture for media readers, colour operations, etc. + +## Build System + +**IMPORTANT: Always build as STANDALONE.** Building non-standalone causes linking/dependency issues on Windows. + +### Windows Build (Primary) +```bash +# Configure (standalone) +cmake --preset WinRelease + +# Build +cmake --build build --config Release --target xstudio + +# The build output goes to: build/bin/Release/ +``` + +### Portable Deployment (CRITICAL) +**The user runs xSTUDIO from `portable/bin/`, NOT from `build/bin/Release/`.** +After every build, you MUST copy updated binaries into the portable directory: +```bash +# Copy the main exe +cp build/bin/Release/xstudio.exe portable/bin/ + +# Copy any updated DLLs (plugin .dll files from build/bin/Release/) +cp build/bin/Release/*.dll portable/bin/ +``` +Without this step, the user will be running the OLD binary and won't see any changes. + +### Key Build Details +- Generator: Visual Studio 17 2022 +- vcpkg for package management (toolchain at ../vcpkg/) +- Qt6 at C:/Qt/6.5.3/msvc2019_64/ +- Presets: WinRelease, WinRelWithDebInfo, WinDebug + +## Architecture +- **Actor Model**: CAF-based distributed actors with message passing +- **Plugin System**: Dynamic loading for media readers, colour ops +- **Registries**: keyboard_events, media_reader_registry, plugin_manager_registry, etc. +- **Thread Pools**: OpenEXR internal (16 threads), 5 ReaderHelper actors, 4 detail readers + +## Key Directories +``` +src/plugin/media_reader/openexr/ - EXR reader plugin +src/media_reader/ - Media reader coordination +src/media_cache/ - Frame caching +src/ui/base/src/keyboard.cpp - Hotkey system +src/ui/viewport/src/keypress_monitor.cpp - Key event distribution +src/ui/qml/viewport/src/hotkey_ui.cpp - Hotkey QML model +src/playhead/ - Playback control +``` + +## Test EXR Sequences +- `L:\tdm\shots\fw\9119\comp\images\FW9119_comp_v001\exr` (81 frames, 1000-1080) + +## Working Style + +**CRITICAL: Claude acts as ORCHESTRATOR, not implementor.** +- Spin up specialized agents (via Agent tool) for all development work: coding, debugging, building, benchmarking +- This prevents context window compaction and keeps the main conversation lean +- The orchestrator reads results from agents, coordinates next steps, and communicates with the user +- Only do trivial edits (like updating CLAUDE.md) directly — everything else gets delegated + +## Issue Tracking +Uses **bd** (beads) for issue tracking. See AGENTS.md for workflow. diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e34e054f..687a3954f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.28 FATAL_ERROR) cmake_policy(VERSION 3.28) -set(XSTUDIO_GLOBAL_VERSION "1.1.1" CACHE STRING "Version string") +set(XSTUDIO_GLOBAL_VERSION "1.1.2" CACHE STRING "Version string") set(XSTUDIO_GLOBAL_NAME xStudio) # set(CMAKE_OSX_DEPLOYMENT_TARGET "14.5" CACHE STRING "Minimum OS X deployment version" FORCE) @@ -240,6 +240,11 @@ endif() add_subdirectory(src) +option(BUILD_EXR_BENCHMARK "Build standalone EXR loading benchmark" OFF) +if(BUILD_EXR_BENCHMARK) + add_subdirectory(bench/exr_benchmark) +endif() + if(INSTALL_XSTUDIO) # build quickpromise diff --git a/bench/exr_benchmark/CMakeLists.txt b/bench/exr_benchmark/CMakeLists.txt new file mode 100644 index 000000000..44778ffc9 --- /dev/null +++ b/bench/exr_benchmark/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.28) +project(exr_benchmark LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(OpenEXR CONFIG REQUIRED) +find_package(Imath CONFIG REQUIRED) + +add_executable(exr_benchmark + exr_benchmark.cpp +) + +target_link_libraries(exr_benchmark PRIVATE + OpenEXR::OpenEXR + Imath::Imath +) + +if(MSVC) + target_compile_options(exr_benchmark PRIVATE /MP /W3) + target_compile_definitions(exr_benchmark PRIVATE NOMINMAX _CRT_SECURE_NO_WARNINGS) +endif() diff --git a/bench/exr_benchmark/exr_benchmark.cpp b/bench/exr_benchmark/exr_benchmark.cpp new file mode 100644 index 000000000..f5ae72a36 --- /dev/null +++ b/bench/exr_benchmark/exr_benchmark.cpp @@ -0,0 +1,858 @@ +// EXR Sequence Loading Benchmark for xSTUDIO +// Standalone binary - no CAF/xSTUDIO dependencies +// Mirrors the reading approach in src/plugin/media_reader/openexr/src/openexr.cpp + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#pragma comment(lib, "psapi.lib") +#endif + +namespace fs = std::filesystem; + +// --------------------------------------------------------------------------- +// Timer +// --------------------------------------------------------------------------- +struct Timer { + using clock = std::chrono::high_resolution_clock; + clock::time_point start_; + Timer() : start_(clock::now()) {} + double elapsed_ms() const { + return std::chrono::duration(clock::now() - start_).count(); + } +}; + +// --------------------------------------------------------------------------- +// Memory info (Windows) +// --------------------------------------------------------------------------- +struct MemInfo { + size_t working_set_mb = 0; + size_t peak_working_set_mb = 0; +}; + +MemInfo get_mem_info() { + MemInfo m; +#ifdef _WIN32 + PROCESS_MEMORY_COUNTERS pmc; + if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) { + m.working_set_mb = pmc.WorkingSetSize / (1024 * 1024); + m.peak_working_set_mb = pmc.PeakWorkingSetSize / (1024 * 1024); + } +#endif + return m; +} + +// --------------------------------------------------------------------------- +// Aligned buffer (mirrors xSTUDIO's Buffer with 1024-byte alignment) +// --------------------------------------------------------------------------- +struct AlignedBuffer { + void* data = nullptr; + size_t size = 0; + + void allocate(size_t sz) { + free(); + data = operator new[](sz, std::align_val_t(1024)); + size = sz; + } + void free() { + if (data) { + operator delete[](data, std::align_val_t(1024)); + data = nullptr; + size = 0; + } + } + ~AlignedBuffer() { free(); } + AlignedBuffer() = default; + AlignedBuffer(AlignedBuffer&& o) noexcept : data(o.data), size(o.size) { + o.data = nullptr; o.size = 0; + } + AlignedBuffer& operator=(AlignedBuffer&& o) noexcept { + free(); data = o.data; size = o.size; o.data = nullptr; o.size = 0; return *this; + } + AlignedBuffer(const AlignedBuffer&) = delete; + AlignedBuffer& operator=(const AlignedBuffer&) = delete; +}; + +// --------------------------------------------------------------------------- +// Frame result +// --------------------------------------------------------------------------- +struct FrameResult { + int frame_number = 0; + double read_ms = 0.0; + size_t file_size = 0; + size_t decoded_size = 0; + int width = 0; + int height = 0; + int num_channels = 0; + bool success = false; + std::string error; +}; + +// --------------------------------------------------------------------------- +// EXR Reader - mirrors xSTUDIO's OpenEXRMediaReader::image() +// --------------------------------------------------------------------------- +constexpr int EXR_READ_BLOCK_HEIGHT = 256; + +FrameResult read_exr_frame_imf(const fs::path& filepath) { + FrameResult result; + result.frame_number = 0; // caller sets this + + try { + result.file_size = fs::file_size(filepath); + } catch (...) { + result.file_size = 0; + } + + Timer t; + try { + Imf::MultiPartInputFile input(filepath.string().c_str()); + int part_idx = 0; // use first part + + const Imf::Header& header = input.header(part_idx); + Imf::InputPart in(input, part_idx); + + Imath::Box2i data_window = header.dataWindow(); + Imath::Box2i display_window = header.displayWindow(); + + // Find RGBA channels (same logic as xSTUDIO) + const auto& channels = header.channels(); + struct ChanInfo { std::string name; Imf::PixelType type; }; + std::vector rgba_chans; + + // Look for standard RGBA channel names + auto try_add = [&](const char* name) { + auto it = channels.findChannel(name); + if (it) { + rgba_chans.push_back({name, it->type}); + return true; + } + return false; + }; + + // Try common naming conventions + if (!try_add("R")) try_add("r"); + if (!try_add("G")) try_add("g"); + if (!try_add("B")) try_add("b"); + if (!try_add("A")) try_add("a"); + + // Fallback: if no RGBA found, take first 4 channels + if (rgba_chans.empty()) { + for (auto it = channels.begin(); it != channels.end() && rgba_chans.size() < 4; ++it) { + rgba_chans.push_back({it.name(), it.channel().type}); + } + } + + if (rgba_chans.empty()) { + result.error = "No channels found"; + result.read_ms = t.elapsed_ms(); + return result; + } + + // Compute buffer size (same as xSTUDIO openexr.cpp:283-302) + const size_t width = data_window.size().x + 1; + const size_t height = data_window.size().y + 1; + size_t bytes_per_pixel = 0; + for (const auto& ch : rgba_chans) { + bytes_per_pixel += (ch.type == Imf::PixelType::HALF) ? 2 : 4; + } + const size_t buf_size = width * height * bytes_per_pixel; + const size_t line_stride = width * bytes_per_pixel; + + AlignedBuffer buf; + buf.allocate(buf_size); + + // Set up framebuffer - same approach as xSTUDIO non-cropped path (lines 413-447) + char* base = static_cast(buf.data) + - data_window.min.x * bytes_per_pixel + - data_window.min.y * line_stride; + + Imf::FrameBuffer fb; + char* chan_ptr = base; + for (const auto& ch : rgba_chans) { + fb.insert( + ch.name.c_str(), + Imf::Slice(ch.type, chan_ptr, bytes_per_pixel, line_stride, 1, 1, 0)); + chan_ptr += (ch.type == Imf::PixelType::HALF) ? 2 : 4; + } + + in.setFrameBuffer(fb); + in.readPixels(data_window.min.y, data_window.max.y); + + result.width = static_cast(width); + result.height = static_cast(height); + result.num_channels = static_cast(rgba_chans.size()); + result.decoded_size = buf_size; + result.success = true; + + } catch (const std::exception& e) { + result.error = e.what(); + } + + result.read_ms = t.elapsed_ms(); + return result; +} + +// --------------------------------------------------------------------------- +// Collect sequence files +// --------------------------------------------------------------------------- +std::vector collect_exr_sequence(const fs::path& dir) { + std::vector files; + for (const auto& entry : fs::directory_iterator(dir)) { + if (entry.is_regular_file()) { + auto ext = entry.path().extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + if (ext == ".exr") { + files.push_back(entry.path()); + } + } + } + std::sort(files.begin(), files.end()); + return files; +} + +// --------------------------------------------------------------------------- +// Statistics +// --------------------------------------------------------------------------- +struct Stats { + double min_ms = 0, max_ms = 0, mean_ms = 0, median_ms = 0, p95_ms = 0, p99_ms = 0; + double total_ms = 0; + double fps = 0; + double throughput_mbps = 0; // decoded MB/s + double io_mbps = 0; // compressed MB/s + size_t total_decoded = 0; + size_t total_file_size = 0; + int frame_count = 0; +}; + +Stats compute_stats(const std::vector& results) { + Stats s; + if (results.empty()) return s; + + std::vector times; + for (const auto& r : results) { + if (r.success) { + times.push_back(r.read_ms); + s.total_decoded += r.decoded_size; + s.total_file_size += r.file_size; + } + } + s.frame_count = static_cast(times.size()); + if (times.empty()) return s; + + std::sort(times.begin(), times.end()); + s.min_ms = times.front(); + s.max_ms = times.back(); + s.median_ms = times[times.size() / 2]; + s.p95_ms = times[static_cast(times.size() * 0.95)]; + s.p99_ms = times[std::min(static_cast(times.size() * 0.99), times.size() - 1)]; + s.mean_ms = std::accumulate(times.begin(), times.end(), 0.0) / times.size(); + s.total_ms = std::accumulate(times.begin(), times.end(), 0.0); + + double total_s = s.total_ms / 1000.0; + s.fps = (total_s > 0) ? s.frame_count / total_s : 0; + s.throughput_mbps = (total_s > 0) ? (s.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + s.io_mbps = (total_s > 0) ? (s.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + + return s; +} + +// --------------------------------------------------------------------------- +// Benchmark: Sequential read +// --------------------------------------------------------------------------- +std::vector bench_sequential(const std::vector& files) { + std::vector results; + results.reserve(files.size()); + for (size_t i = 0; i < files.size(); ++i) { + auto r = read_exr_frame_imf(files[i]); + r.frame_number = static_cast(i); + results.push_back(std::move(r)); + } + return results; +} + +// --------------------------------------------------------------------------- +// Benchmark: Thread pool read +// --------------------------------------------------------------------------- +std::vector bench_threaded(const std::vector& files, int num_threads) { + std::vector results(files.size()); + std::atomic next_idx{0}; + + auto worker = [&]() { + while (true) { + size_t idx = next_idx.fetch_add(1); + if (idx >= files.size()) break; + auto r = read_exr_frame_imf(files[idx]); + r.frame_number = static_cast(idx); + results[idx] = std::move(r); + } + }; + + std::vector threads; + for (int i = 0; i < num_threads; ++i) { + threads.emplace_back(worker); + } + for (auto& t : threads) { + t.join(); + } + return results; +} + +// --------------------------------------------------------------------------- +// Benchmark: Pre-allocated buffer pool +// --------------------------------------------------------------------------- +std::vector bench_threaded_pooled(const std::vector& files, int num_threads) { + // Pre-read one frame to get buffer size + auto probe = read_exr_frame_imf(files[0]); + if (!probe.success) return {probe}; + size_t frame_buf_size = probe.decoded_size; + + // Pre-allocate buffers for all threads + struct ThreadBuf { + AlignedBuffer buf; + }; + std::vector thread_bufs(num_threads); + for (auto& tb : thread_bufs) { + tb.buf.allocate(frame_buf_size); + } + + std::vector results(files.size()); + std::atomic next_idx{0}; + + auto worker = [&](int thread_id) { + while (true) { + size_t idx = next_idx.fetch_add(1); + if (idx >= files.size()) break; + + FrameResult result; + result.frame_number = static_cast(idx); + try { + result.file_size = fs::file_size(files[idx]); + } catch (...) {} + + Timer t; + try { + Imf::MultiPartInputFile input(files[idx].string().c_str()); + const Imf::Header& header = input.header(0); + Imf::InputPart in(input, 0); + + Imath::Box2i data_window = header.dataWindow(); + const auto& channels = header.channels(); + + struct ChanInfo { std::string name; Imf::PixelType type; }; + std::vector rgba_chans; + auto try_add = [&](const char* name) { + auto it = channels.findChannel(name); + if (it) { rgba_chans.push_back({name, it->type}); return true; } + return false; + }; + if (!try_add("R")) try_add("r"); + if (!try_add("G")) try_add("g"); + if (!try_add("B")) try_add("b"); + if (!try_add("A")) try_add("a"); + if (rgba_chans.empty()) { + for (auto it = channels.begin(); it != channels.end() && rgba_chans.size() < 4; ++it) + rgba_chans.push_back({it.name(), it.channel().type}); + } + + size_t w = data_window.size().x + 1; + size_t h = data_window.size().y + 1; + size_t bpp = 0; + for (const auto& ch : rgba_chans) bpp += (ch.type == Imf::PixelType::HALF) ? 2 : 4; + size_t line_stride = w * bpp; + + // Use pre-allocated buffer - ensure we don't underflow + // The base pointer trick places buffer origin at (0,0) so OpenEXR + // can index by data_window coordinates directly + size_t needed = w * h * bpp; + if (needed > thread_bufs[thread_id].buf.size) { + thread_bufs[thread_id].buf.allocate(needed); + } + // Compute offset so that data_window.min maps to start of buffer + char* buf_start = static_cast(thread_bufs[thread_id].buf.data); + char* base = buf_start - data_window.min.x * static_cast(bpp) + - data_window.min.y * static_cast(line_stride); + + Imf::FrameBuffer fb; + char* chan_ptr = base; + for (const auto& ch : rgba_chans) { + fb.insert(ch.name.c_str(), + Imf::Slice(ch.type, chan_ptr, bpp, line_stride, 1, 1, 0)); + chan_ptr += (ch.type == Imf::PixelType::HALF) ? 2 : 4; + } + + in.setFrameBuffer(fb); + in.readPixels(data_window.min.y, data_window.max.y); + + result.width = static_cast(w); + result.height = static_cast(h); + result.num_channels = static_cast(rgba_chans.size()); + result.decoded_size = w * h * bpp; + result.success = true; + } catch (const std::exception& e) { + result.error = e.what(); + } + result.read_ms = t.elapsed_ms(); + results[idx] = std::move(result); + } + }; + + std::vector threads; + for (int i = 0; i < num_threads; ++i) { + threads.emplace_back(worker, i); + } + for (auto& th : threads) { + th.join(); + } + return results; +} + +// --------------------------------------------------------------------------- +// Print helpers +// --------------------------------------------------------------------------- +void print_separator() { + std::cout << std::string(120, '-') << "\n"; +} + +void print_header() { + printf("%-30s %7s %7s %9s %7s %8s %8s %8s %8s %8s %8s\n", + "Strategy", "ExtThr", "ExrThr", "Total(s)", "FPS", "Dec MB/s", "IO MB/s", "Min(ms)", "Med(ms)", "P95(ms)", "PeakMem"); + print_separator(); +} + +void print_result(const std::string& name, int ext_threads, int exr_threads, const Stats& s) { + auto mem = get_mem_info(); + printf("%-30s %7d %7d %9.3f %7.1f %8.1f %8.1f %8.1f %8.1f %8.1f %6lluMB\n", + name.c_str(), ext_threads, exr_threads, + s.total_ms / 1000.0, s.fps, s.throughput_mbps, s.io_mbps, + s.min_ms, s.median_ms, s.p95_ms, + (unsigned long long)mem.peak_working_set_mb); +} + +void print_frame_details(const std::vector& results) { + int errors = 0; + for (const auto& r : results) { + if (!r.success) { + errors++; + fprintf(stderr, " Frame %4d: ERROR - %s\n", r.frame_number, r.error.c_str()); + } + } + if (errors > 0) { + fprintf(stderr, " %d errors out of %zu frames\n", errors, results.size()); + } +} + +// --------------------------------------------------------------------------- +// Benchmark: Simulate xSTUDIO precache pipeline +// Models the effect of Fix 2 (parallel precache: 1 vs N in-flight) +// In xSTUDIO, the precache pipeline serializes frame reads per playhead. +// Fix 2 changes this from 1 in-flight to max_in_flight concurrent reads. +// --------------------------------------------------------------------------- +std::vector bench_precache_sim(const std::vector& files, + int max_in_flight, int exr_threads) { + // Set EXR thread count before any reads + Imf::setGlobalThreadCount(exr_threads); + + if (max_in_flight <= 1) { + // Serialized: read one frame at a time (old xSTUDIO behavior) + return bench_sequential(files); + } + + // Simulate the precache pipeline: dispatch up to max_in_flight frames at a time + // This models xSTUDIO's 5 ReaderHelper actors with max_in_flight cap per playhead + std::vector results(files.size()); + std::atomic next_idx{0}; + + auto worker = [&]() { + while (true) { + size_t idx = next_idx.fetch_add(1); + if (idx >= files.size()) break; + auto r = read_exr_frame_imf(files[idx]); + r.frame_number = static_cast(idx); + results[idx] = std::move(r); + } + }; + + // Use max_in_flight threads (simulating concurrent reader actors) + std::vector threads; + for (int i = 0; i < max_in_flight; ++i) { + threads.emplace_back(worker); + } + for (auto& t : threads) { + t.join(); + } + return results; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +int main(int argc, char* argv[]) { + // Force line-buffered stdout for real-time output + setvbuf(stdout, nullptr, _IONBF, 0); + setvbuf(stderr, nullptr, _IONBF, 0); + std::string exr_dir; + int iterations = 1; + bool verbose = false; + bool fix_bench = false; + + // Parse args + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--dir" && i + 1 < argc) { + exr_dir = argv[++i]; + } else if (arg == "--iterations" && i + 1 < argc) { + iterations = std::stoi(argv[++i]); + } else if (arg == "--verbose" || arg == "-v") { + verbose = true; + } else if (arg == "--fix-bench") { + fix_bench = true; + } else if (arg == "--help" || arg == "-h") { + std::cout << "Usage: exr_benchmark --dir [options]\n" + << "Options:\n" + << " --dir Path to directory containing EXR sequence\n" + << " --iterations Number of iterations per test (default: 1)\n" + << " --fix-bench Run per-fix comparison benchmarks\n" + << " --verbose, -v Show per-frame details\n" + << " --help, -h Show this help\n"; + return 0; + } else if (exr_dir.empty()) { + exr_dir = arg; + } + } + + if (exr_dir.empty()) { + // Default test path + exr_dir = "L:/tdm/shots/fw/9119/comp/images/FW9119_comp_v001/exr"; + } + + fs::path dir(exr_dir); + if (!fs::exists(dir) || !fs::is_directory(dir)) { + std::cerr << "Error: Directory does not exist: " << exr_dir << "\n"; + return 1; + } + + auto files = collect_exr_sequence(dir); + if (files.empty()) { + std::cerr << "Error: No EXR files found in: " << exr_dir << "\n"; + return 1; + } + + // Probe first frame for info + auto probe = read_exr_frame_imf(files[0]); + if (!probe.success) { + std::cerr << "Error: Failed to read first frame: " << probe.error << "\n"; + return 1; + } + + // Compute total file sizes + size_t total_file_size = 0; + for (const auto& f : files) { + try { total_file_size += fs::file_size(f); } catch (...) {} + } + + std::cout << "\n"; + std::cout << "EXR Sequence Loading Benchmark\n"; + std::cout << "==============================\n"; + printf("Directory: %s\n", exr_dir.c_str()); + printf("Frames: %zu (%s - %s)\n", + files.size(), files.front().filename().string().c_str(), files.back().filename().string().c_str()); + printf("Resolution: %dx%d, %d channels\n", + probe.width, probe.height, probe.num_channels); + printf("Frame size: %.1f MB decoded, %.1f MB avg compressed\n", + probe.decoded_size / (1024.0 * 1024.0), + (total_file_size / files.size()) / (1024.0 * 1024.0)); + printf("Total data: %.1f MB decoded, %.1f MB compressed\n", + (probe.decoded_size * files.size()) / (1024.0 * 1024.0), + total_file_size / (1024.0 * 1024.0)); + printf("HW threads: %u\n", std::thread::hardware_concurrency()); + printf("Iterations: %d\n", iterations); + std::cout << "\n"; + + // =================================================================== + // Per-fix benchmark mode + // =================================================================== + if (fix_bench) { + unsigned hw = std::thread::hardware_concurrency(); + int dynamic_exr = std::clamp(static_cast(hw) / 2, 1, 16); + + std::cout << "\n"; + std::cout << "Per-Fix Benchmark Comparison\n"; + std::cout << "============================\n"; + printf("HW threads: %u, Dynamic EXR threads (hw/2): %d\n\n", hw, dynamic_exr); + + // --------------------------------------------------------------- + // Fix 3: Dynamic EXR thread count (was hardcoded to 16) + // --------------------------------------------------------------- + std::cout << "=== Fix 3: Dynamic EXR Thread Count ===\n"; + std::cout << "Compares hardcoded exr_threads=16 vs dynamic hw/2\n\n"; + print_header(); + + // Old behavior: hardcoded 16 + { + Imf::setGlobalThreadCount(16); + Timer wall; + auto results = bench_sequential(files); + auto stats = compute_stats(results); + print_result("OLD: exr_threads=16", 1, 16, stats); + } + + // New behavior: dynamic hw/2 + { + Imf::setGlobalThreadCount(dynamic_exr); + Timer wall; + auto results = bench_sequential(files); + auto stats = compute_stats(results); + print_result("NEW: exr_threads=hw/2", 1, dynamic_exr, stats); + } + + // For comparison: optimal exr thread count (4 from baseline data) + { + Imf::setGlobalThreadCount(4); + Timer wall; + auto results = bench_sequential(files); + auto stats = compute_stats(results); + print_result("REF: exr_threads=4", 1, 4, stats); + } + + print_separator(); + std::cout << "\n"; + + // --------------------------------------------------------------- + // Fix 2: Parallel precache (1 in-flight vs 4 in-flight) + // --------------------------------------------------------------- + std::cout << "=== Fix 2: Parallel Precache Pipeline ===\n"; + std::cout << "Simulates xSTUDIO precache: 1 vs 4 concurrent reads per playhead\n"; + printf("Using exr_threads=%d (from Fix 3)\n\n", dynamic_exr); + print_header(); + + // Old behavior: 1 in-flight (serialized precache) + for (int exr_thr : {0, dynamic_exr}) { + Timer wall; + auto results = bench_precache_sim(files, 1, exr_thr); + double wall_ms = wall.elapsed_ms(); + auto stats = compute_stats(results); + stats.total_ms = wall_ms; + double total_s = wall_ms / 1000.0; + stats.fps = (total_s > 0) ? stats.frame_count / total_s : 0; + stats.throughput_mbps = (total_s > 0) ? (stats.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + stats.io_mbps = (total_s > 0) ? (stats.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + print_result("OLD: 1 in-flight", 1, exr_thr, stats); + } + + // New behavior: 4 in-flight (uses 4 threads with 0 EXR internal threads) + { + Timer wall; + auto results = bench_precache_sim(files, 4, 0); + double wall_ms = wall.elapsed_ms(); + auto stats = compute_stats(results); + stats.total_ms = wall_ms; + double total_s = wall_ms / 1000.0; + stats.fps = (total_s > 0) ? stats.frame_count / total_s : 0; + stats.throughput_mbps = (total_s > 0) ? (stats.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + stats.io_mbps = (total_s > 0) ? (stats.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + print_result("NEW: 4 in-flight", 4, 0, stats); + } + + // Aggressive: 8 in-flight + { + Timer wall; + auto results = bench_precache_sim(files, 8, 0); + double wall_ms = wall.elapsed_ms(); + auto stats = compute_stats(results); + stats.total_ms = wall_ms; + double total_s = wall_ms / 1000.0; + stats.fps = (total_s > 0) ? stats.frame_count / total_s : 0; + stats.throughput_mbps = (total_s > 0) ? (stats.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + stats.io_mbps = (total_s > 0) ? (stats.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + print_result("AGG: 8 in-flight", 8, 0, stats); + } + + print_separator(); + std::cout << "\n"; + + // --------------------------------------------------------------- + // Combined: All fixes together (optimal configuration) + // --------------------------------------------------------------- + std::cout << "=== Combined: All Fixes Applied ===\n"; + std::cout << "Fix 1: Precache stall recovery (architectural - not benchmarkable standalone)\n"; + std::cout << "Fix 2: 4 concurrent reads per playhead\n"; + printf("Fix 3: Dynamic EXR threads = %d\n\n", dynamic_exr); + print_header(); + + // Best config: pooled buffers, 4 in-flight, dynamic EXR threads + for (int in_flight : {1, 2, 4, 8}) { + Imf::setGlobalThreadCount(0); // 0 exr threads = best with ext threading + Timer wall; + auto results = bench_precache_sim(files, in_flight, 0); + double wall_ms = wall.elapsed_ms(); + auto stats = compute_stats(results); + stats.total_ms = wall_ms; + double total_s = wall_ms / 1000.0; + stats.fps = (total_s > 0) ? stats.frame_count / total_s : 0; + stats.throughput_mbps = (total_s > 0) ? (stats.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + stats.io_mbps = (total_s > 0) ? (stats.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + + char label[64]; + snprintf(label, sizeof(label), "Combined: %d in-flight", in_flight); + print_result(label, in_flight, 0, stats); + } + + print_separator(); + std::cout << "\n"; + + // --------------------------------------------------------------- + // Thread Sweep: find optimal EXR internal threads for each ext count + // --------------------------------------------------------------- + std::cout << "=== Thread Sweep: ext_threads x exr_threads ===\n"; + std::cout << "Finding optimal EXR internal thread count for each concurrency level\n\n"; + print_header(); + + for (int ext : {1, 2, 4, 8}) { + for (int exr : {0, 1, 2, 4, 8, 16}) { + Timer wall; + auto results = bench_precache_sim(files, ext, exr); + double wall_ms = wall.elapsed_ms(); + auto stats = compute_stats(results); + stats.total_ms = wall_ms; + double total_s = wall_ms / 1000.0; + stats.fps = (total_s > 0) ? stats.frame_count / total_s : 0; + stats.throughput_mbps = (total_s > 0) ? (stats.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + stats.io_mbps = (total_s > 0) ? (stats.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + + char label[64]; + snprintf(label, sizeof(label), "Sweep: ext=%d exr=%d", ext, exr); + print_result(label, ext, exr, stats); + } + print_separator(); + } + + auto final_mem = get_mem_info(); + printf("\nPeak memory: %llu MB\n", (unsigned long long)final_mem.peak_working_set_mb); + std::cout << "Done.\n"; + return 0; + } + + // =================================================================== + // Full benchmark mode (default) + // =================================================================== + + // Thread counts and EXR internal thread counts to test + std::vector ext_thread_counts = {1, 2, 4, 8}; + std::vector exr_thread_counts = {0, 1, 4, 8, 16}; + + // Remove thread counts that exceed hardware + unsigned hw = std::thread::hardware_concurrency(); + ext_thread_counts.erase( + std::remove_if(ext_thread_counts.begin(), ext_thread_counts.end(), + [hw](int n) { return static_cast(n) > hw; }), + ext_thread_counts.end()); + + for (int iter = 0; iter < iterations; ++iter) { + if (iterations > 1) { + printf("\n=== Iteration %d / %d ===\n\n", iter + 1, iterations); + } + + print_header(); + + // --- Test 1: Sequential, varying EXR internal threads --- + for (int exr_thr : exr_thread_counts) { + Imf::setGlobalThreadCount(exr_thr); + + Timer wall; + auto results = bench_sequential(files); + double wall_ms = wall.elapsed_ms(); + + auto stats = compute_stats(results); + // For sequential, wall time == sum of frame times (approximately) + print_result("Sequential", 1, exr_thr, stats); + + if (verbose) print_frame_details(results); + } + + print_separator(); + + // --- Test 2: Threaded (new alloc per frame), varying ext threads x EXR threads --- + for (int ext_thr : ext_thread_counts) { + if (ext_thr <= 1) continue; // already tested sequential + + for (int exr_thr : {0, 4, 8, 16}) { + Imf::setGlobalThreadCount(exr_thr); + + Timer wall; + auto results = bench_threaded(files, ext_thr); + double wall_ms = wall.elapsed_ms(); + + // For threaded, use wall time for throughput (frames run in parallel) + auto stats = compute_stats(results); + // Override total_ms with wall time for accurate FPS + double total_s = wall_ms / 1000.0; + stats.total_ms = wall_ms; + stats.fps = (total_s > 0) ? stats.frame_count / total_s : 0; + stats.throughput_mbps = (total_s > 0) ? (stats.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + stats.io_mbps = (total_s > 0) ? (stats.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + + print_result("Threaded (fresh alloc)", ext_thr, exr_thr, stats); + + if (verbose) print_frame_details(results); + } + } + + print_separator(); + + // --- Test 3: Threaded with pre-allocated buffer pool --- + for (int ext_thr : ext_thread_counts) { + if (ext_thr <= 1) continue; + + for (int exr_thr : {0, 4, 16}) { + Imf::setGlobalThreadCount(exr_thr); + + Timer wall; + auto results = bench_threaded_pooled(files, ext_thr); + double wall_ms = wall.elapsed_ms(); + + auto stats = compute_stats(results); + double total_s = wall_ms / 1000.0; + stats.total_ms = wall_ms; + stats.fps = (total_s > 0) ? stats.frame_count / total_s : 0; + stats.throughput_mbps = (total_s > 0) ? (stats.total_decoded / (1024.0 * 1024.0)) / total_s : 0; + stats.io_mbps = (total_s > 0) ? (stats.total_file_size / (1024.0 * 1024.0)) / total_s : 0; + + print_result("Threaded (pooled buf)", ext_thr, exr_thr, stats); + + if (verbose) print_frame_details(results); + } + } + + print_separator(); + } + + auto final_mem = get_mem_info(); + printf("\nPeak memory: %llu MB\n", (unsigned long long)final_mem.peak_working_set_mb); + std::cout << "Done.\n"; + + return 0; +} diff --git a/include/xstudio/media_reader/frame_request_queue.hpp b/include/xstudio/media_reader/frame_request_queue.hpp index adcf07b41..d73ee1b6d 100644 --- a/include/xstudio/media_reader/frame_request_queue.hpp +++ b/include/xstudio/media_reader/frame_request_queue.hpp @@ -87,7 +87,8 @@ namespace media_reader { * */ std::optional - pop_request(const std::map &exclude_playheads); + pop_request(const std::map &in_flight_counts, + const int max_in_flight = 4); /** * @brief Add a request to the queue diff --git a/include/xstudio/ui/qml/hotkey_ui.hpp b/include/xstudio/ui/qml/hotkey_ui.hpp index 84656a7e4..40ae1f181 100644 --- a/include/xstudio/ui/qml/hotkey_ui.hpp +++ b/include/xstudio/ui/qml/hotkey_ui.hpp @@ -91,6 +91,19 @@ namespace ui { Q_INVOKABLE QString hotkey_sequence_from_hotkey_name(const QString &hotkey_name); + Q_INVOKABLE bool rebindHotkey(int model_row, const QString &new_sequence); + + Q_INVOKABLE QString checkConflict(int model_row, const QString &new_sequence); + + Q_INVOKABLE void resetHotkey(int model_row); + + Q_INVOKABLE void resetAllHotkeys(); + + Q_INVOKABLE QString hotkeyNameAtRow(int model_row) const; + + Q_INVOKABLE QString hotkeySequenceAtRow(int model_row) const; + + Q_INVOKABLE QString hotkeyDescriptionAtRow(int model_row) const; signals: @@ -103,6 +116,11 @@ namespace ui { private: void update_hotkeys_model_data(const std::vector &new_hotkeys_data); void checkCategories(); + const Hotkey *hotkeyAtRow(int row) const; + Hotkey *hotkeyAtRow(int row); + void saveHotkeyOverrides(); + void loadHotkeyOverrides(); + static std::string hotkey_overrides_path(); caf::actor_system &system() { return self()->home_system(); } virtual void init(caf::actor_system &system) { super::init(system); } @@ -110,6 +128,9 @@ namespace ui { std::vector hotkeys_data_; QStringList categories_; QString current_category_; + std::map default_sequences_; + std::map pending_overrides_; + bool defaults_captured_ = false; }; class VIEWPORT_QML_EXPORT HotkeyUI : public QMLActor { diff --git a/share/preference/plugin_media_reader_openexr.json b/share/preference/plugin_media_reader_openexr.json index 835edb99a..1e15fd34c 100644 --- a/share/preference/plugin_media_reader_openexr.json +++ b/share/preference/plugin_media_reader_openexr.json @@ -4,9 +4,9 @@ "OpenEXR":{ "max_exr_overscan_percent": { "path": "/plugin/media_reader/OpenEXR/max_exr_overscan_percent", - "default_value": 100.0, + "default_value": 0.0, "description": "Set the maximum amount of EXR overscan that is loaded. Setting to zero means no overscan is loaded, or set to a very high number to always load all overscan pixels. After changing this value, you may need to clear the xstudio cache ('Advanced' sub-menu in MediaList) to re-load the frames", - "value": 100.0, + "value": 0.0, "minimum": 0.0, "maximum": 1000.0, "datatype": "double", @@ -23,8 +23,20 @@ "maximum": 10, "datatype": "int", "context": ["APPLICATION"] + }, + "exr_thread_count": { + "path": "/plugin/media_reader/OpenEXR/exr_thread_count", + "default_value": 16, + "description": "EXR decompression threads per reader (0=single-threaded, default=16)", + "value": 16, + "minimum": 0, + "maximum": 64, + "datatype": "int", + "context": ["APPLICATION"], + "category": "Playback", + "display_name": "EXR decompression threads (0=disabled)" } } } } -} \ No newline at end of file +} diff --git a/src/media_reader/src/frame_request_queue.cpp b/src/media_reader/src/frame_request_queue.cpp index 941495505..8b82692d2 100644 --- a/src/media_reader/src/frame_request_queue.cpp +++ b/src/media_reader/src/frame_request_queue.cpp @@ -59,12 +59,15 @@ void FrameRequestQueue::add_frame_requests( -> bool { return a->required_by_ < b->required_by_; }); } -std::optional -FrameRequestQueue::pop_request(const std::map &exclude_playheads) { +std::optional FrameRequestQueue::pop_request( + const std::map &in_flight_counts, + const int max_in_flight) { std::optional rt = {}; for (auto p = queue_.begin(); p != queue_.end(); p++) { - if (!exclude_playheads.count((*p)->requesting_playhead_uuid_)) { + auto it = in_flight_counts.find((*p)->requesting_playhead_uuid_); + // Allow up to max_in_flight concurrent reads per playhead + if (it == in_flight_counts.end() || it->second < max_in_flight) { rt = *(*p); queue_.erase(p); break; diff --git a/src/media_reader/src/media_reader_actor.cpp b/src/media_reader/src/media_reader_actor.cpp index 71fde07fb..2281fbf6f 100644 --- a/src/media_reader/src/media_reader_actor.cpp +++ b/src/media_reader/src/media_reader_actor.cpp @@ -787,25 +787,27 @@ void GlobalMediaReaderActor::on_exit() { system().registry().erase(media_reader_ void GlobalMediaReaderActor::do_precache() { - // We won't process a new request if there are already precache requests in - // flight for a given playhead. The reason is the async nature of CAF ... - // we could send 100s of requests to precache frames (sending messages is - // fast) before frames can actually be read, decoded and cached (because - // reading frames is slow) - we would then be in a situation where the CAF - // mailbox is full of requests to precache frames + // Allow up to max_in_flight concurrent precache reads per playhead. + // Previously this was limited to 1, serializing all reads. + // We loop here to dispatch multiple requests up to the limit. + static constexpr int max_in_flight = 4; + + // Try to dispatch as many requests as allowed + for (int dispatched = 0; dispatched < max_in_flight; ++dispatched) { + std::optional fr = playback_precache_request_queue_.pop_request( - playheads_with_precache_requests_in_flight_); + playheads_with_precache_requests_in_flight_, max_in_flight); // when putting new images in the cache, images older than this timepoint can // be discarded bool is_background_cache = false; if (not fr) { fr = background_precache_request_queue_.pop_request( - playheads_with_precache_requests_in_flight_); + playheads_with_precache_requests_in_flight_, max_in_flight); if (not fr) { - return; // global reader is saying pre-cache queue for this reader is empty + return; // no more requests to dispatch } is_background_cache = true; } @@ -893,19 +895,20 @@ void GlobalMediaReaderActor::do_precache() { is_background_cache); } } - } catch (std::exception &) { + } catch (std::exception &e) { // we have been unable to create a reader - the file is // unreadable for some reason. We do not want to report an // error because we are currently pre-cacheing. The error - // *will* get reported when we actaully want to show the - // image as an immediate frame request wlil be made as the + // *will* get reported when we actually want to show the + // image as an immediate frame request will be made as the // image isn't in the cache, and at that point error message // propagation will give the user feedback about the frame - // being unreadable - - // shouldn't it continue... ? - // mark_playhead_received_precache_result(playhead_uuid); - // continue_precacheing(); + // being unreadable. + // We MUST release the in-flight marker and continue, otherwise + // the precache pipeline stalls permanently for this playhead. + spdlog::warn("Precache get_reader failed: {}", e.what()); + mark_playhead_received_precache_result(playhead_uuid); + continue_precacheing(); } } }, @@ -914,6 +917,8 @@ void GlobalMediaReaderActor::do_precache() { spdlog::warn( "Failed preserve buffer {} {}", to_string(mptr->key()), to_string(err)); }); + + } // end for (dispatched) } void GlobalMediaReaderActor::keep_cache_hot( diff --git a/src/plugin/media_reader/openexr/src/openexr.cpp b/src/plugin/media_reader/openexr/src/openexr.cpp index 46f71c467..52d5b0abd 100644 --- a/src/plugin/media_reader/openexr/src/openexr.cpp +++ b/src/plugin/media_reader/openexr/src/openexr.cpp @@ -2,6 +2,8 @@ #include #include #include +#include +#include #include #include @@ -63,17 +65,22 @@ bool crop_data_window( const Imath::Box2i in_data_window = data_window; + // Compute the allowed overscan in pixels from the display window edges. + // At 0% overscan, the data window is clipped exactly to the display window. + const int overscan_x = (int)round(float(width) * overscan_percent / 100.0f); + const int overscan_y = (int)round(float(height) * overscan_percent / 100.0f); + data_window.min.x = - std::max(data_window.min.x, (int)round(-float(width) * overscan_percent / 100.0f)); + std::max(data_window.min.x, display_window.min.x - overscan_x); - data_window.max.x = std::min( - data_window.max.x, (int)round(float(width) * (overscan_percent / 100.0f + 1.0f))); + data_window.max.x = + std::min(data_window.max.x, display_window.max.x + overscan_x); data_window.min.y = - std::max(data_window.min.y, (int)round(-float(height) * overscan_percent / 100.0f)); + std::max(data_window.min.y, display_window.min.y - overscan_y); - data_window.max.y = std::min( - data_window.max.y, (int)round(float(height) * (overscan_percent / 100.0f + 1.0f))); + data_window.max.y = + std::min(data_window.max.y, display_window.max.y + overscan_y); return in_data_window != data_window; } @@ -180,9 +187,9 @@ static ui::viewport::GPUShaderPtr OpenEXRMediaReader::OpenEXRMediaReader(const utility::JsonStore &prefs) : MediaReader("OpenEXR", prefs) { - Imf::setGlobalThreadCount(16); - max_exr_overscan_percent_ = 5.0f; + max_exr_overscan_percent_ = 0.0f; readers_per_source_ = 1; + exr_thread_count_ = 16; update_preferences(prefs); } @@ -202,6 +209,18 @@ void OpenEXRMediaReader::update_preferences(const utility::JsonStore &prefs) { } catch (const std::exception &e) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, e.what()); } + try { + exr_thread_count_ = + preference_value(prefs, "/plugin/media_reader/OpenEXR/exr_thread_count"); + } catch (const std::exception &e) { + spdlog::warn("{} {}", __PRETTY_FUNCTION__, e.what()); + } + + // Apply OpenEXR decompression thread count. + // 0 = single-threaded: no internal threading. + // Non-zero: use the specified value directly (default=16). + Imf::setGlobalThreadCount(exr_thread_count_); + spdlog::info("OpenEXR global thread count set to {} (preference: {})", exr_thread_count_, exr_thread_count_); } ImageBufPtr OpenEXRMediaReader::image(const media::AVFrameID &mptr) { @@ -678,8 +697,8 @@ xstudio::media::MediaDetail OpenEXRMediaReader::detail(const caf::uri &uri) cons PartDetail pd; stream_ids_from_exr_part(part_header, pd.stream_ids); pd.resolution = { - part_header.displayWindow().max.x - part_header.displayWindow().min.x, - part_header.displayWindow().max.y - part_header.displayWindow().min.y}; + part_header.displayWindow().max.x - part_header.displayWindow().min.x + 1, + part_header.displayWindow().max.y - part_header.displayWindow().min.y + 1}; pd.pixel_aspect = part_header.pixelAspectRatio(); pd.part_number = prt; parts_detail.push_back(pd); diff --git a/src/plugin/media_reader/openexr/src/openexr.hpp b/src/plugin/media_reader/openexr/src/openexr.hpp index 85d3ee6f2..f5c5ccec4 100644 --- a/src/plugin/media_reader/openexr/src/openexr.hpp +++ b/src/plugin/media_reader/openexr/src/openexr.hpp @@ -53,6 +53,7 @@ namespace media_reader { float max_exr_overscan_percent_; int readers_per_source_; + int exr_thread_count_; }; } // namespace media_reader } // namespace xstudio diff --git a/src/ui/opengl/src/shader_program_base.cpp b/src/ui/opengl/src/shader_program_base.cpp index 4136ba558..4ebfa42c9 100644 --- a/src/ui/opengl/src/shader_program_base.cpp +++ b/src/ui/opengl/src/shader_program_base.cpp @@ -532,8 +532,8 @@ vec4 get_bicubic_filter(vec2 pos) void main(void) { - if (texPosition.x < image_bounds_min.x || texPosition.x > image_bounds_max.x) FragColor = vec4(1.0,0.0,0.0,1.0); - else if (texPosition.y < image_bounds_min.y || texPosition.y > image_bounds_max.y) FragColor = vec4(0.0,.0,1.0,1.0); + if (texPosition.x < image_bounds_min.x || texPosition.x > image_bounds_max.x) FragColor = vec4(0.0,0.0,0.0,1.0); + else if (texPosition.y < image_bounds_min.y || texPosition.y > image_bounds_max.y) FragColor = vec4(0.0,0.0,0.0,1.0); else { // For now, disabling bilinear filtering as it is too expensive and slowing refresh badly diff --git a/src/ui/qml/viewport/src/hotkey_ui.cpp b/src/ui/qml/viewport/src/hotkey_ui.cpp index 6edc4313b..1700ce39e 100644 --- a/src/ui/qml/viewport/src/hotkey_ui.cpp +++ b/src/ui/qml/viewport/src/hotkey_ui.cpp @@ -1,10 +1,17 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include +#include #include #include "xstudio/atoms.hpp" #include "xstudio/ui/qml/hotkey_ui.hpp" +#include "xstudio/utility/json_store.hpp" + +#ifdef _WIN32 +#include +#endif using namespace caf; using namespace xstudio; @@ -35,6 +42,14 @@ HotkeysUI::HotkeysUI(QObject *parent) : super(parent) { update_hotkeys_model_data(hotkeys); + // Capture defaults before applying overrides + for (const auto &hk : hotkeys_data_) { + default_sequences_[hk.hotkey_name()] = hk.hotkey_sequence(); + } + defaults_captured_ = true; + + loadHotkeyOverrides(); + } catch (const std::exception &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); } @@ -145,16 +160,243 @@ QVariant HotkeysUI::data(const QModelIndex &index, int role) const { bool HotkeysUI::setData(const QModelIndex &index, const QVariant &value, int role) { - /*role -= Qt::UserRole+1; - emit setAttributeFromFrontEnd( - attributes_data_[index.row()][Attribute::UuidRole].toUuid(), - role, - value - );*/ + if (role == hotkeySequence) { + return rebindHotkey(index.row(), value.toString()); + } return false; } +const Hotkey *HotkeysUI::hotkeyAtRow(int row) const { + int ct = 0; + const std::string curr_cat(StdFromQString(current_category_)); + for (const auto &hk : hotkeys_data_) { + if (hk.hotkey_origin() == curr_cat) { + if (ct == row) + return &hk; + ct++; + } + } + return nullptr; +} + +Hotkey *HotkeysUI::hotkeyAtRow(int row) { + int ct = 0; + const std::string curr_cat(StdFromQString(current_category_)); + for (auto &hk : hotkeys_data_) { + if (hk.hotkey_origin() == curr_cat) { + if (ct == row) + return &hk; + ct++; + } + } + return nullptr; +} + +QString HotkeysUI::hotkeyNameAtRow(int model_row) const { + const auto *hk = hotkeyAtRow(model_row); + return hk ? QStringFromStd(hk->hotkey_name()) : QString(); +} + +QString HotkeysUI::hotkeySequenceAtRow(int model_row) const { + const auto *hk = hotkeyAtRow(model_row); + return hk ? QStringFromStd(hk->hotkey_sequence()) : QString(); +} + +QString HotkeysUI::hotkeyDescriptionAtRow(int model_row) const { + const auto *hk = hotkeyAtRow(model_row); + return hk ? QStringFromStd(hk->hotkey_description()) : QString(); +} + +QString HotkeysUI::checkConflict(int model_row, const QString &new_sequence) { + int new_key = 0, new_mod = 0; + Hotkey::sequence_to_key_and_modifier(StdFromQString(new_sequence), new_key, new_mod); + if (new_key == 0) + return QString(); + + const auto *target = hotkeyAtRow(model_row); + if (!target) + return QString(); + + for (const auto &hk : hotkeys_data_) { + if (hk.uuid() == target->uuid()) + continue; + if (hk.modifiers() == new_mod) { + // Compare key codes - need to get the key code from the hotkey + int hk_key = 0, hk_mod = 0; + Hotkey::sequence_to_key_and_modifier(hk.hotkey_sequence(), hk_key, hk_mod); + if (hk_key == new_key) { + return QStringFromStd(hk.hotkey_name()); + } + } + } + return QString(); +} + +bool HotkeysUI::rebindHotkey(int model_row, const QString &new_sequence) { + auto *hk = hotkeyAtRow(model_row); + if (!hk) + return false; + + int new_key = 0, new_mod = 0; + Hotkey::sequence_to_key_and_modifier(StdFromQString(new_sequence), new_key, new_mod); + if (new_key == 0) + return false; + + // Capture defaults on first rebind + if (!defaults_captured_) { + for (const auto &h : hotkeys_data_) { + default_sequences_[h.hotkey_name()] = h.hotkey_sequence(); + } + defaults_captured_ = true; + } + + try { + auto keyboard_manager = system().registry().template get(keyboard_events); + + // Create a new Hotkey with the updated key/modifiers + Hotkey new_hk( + new_key, + new_mod, + hk->hotkey_name(), + hk->hotkey_origin(), + hk->hotkey_description(), + "", // window_name + false, // auto_repeat + caf::actor_addr(), // no watcher - existing watchers preserved by KeypressMonitor + hk->uuid()); + + // Send to KeypressMonitor which will call update() on existing hotkey + anon_mail(keypress_monitor::register_hotkey_atom_v, new_hk) + .send(keyboard_manager); + + // Record the override immediately so saveHotkeyOverrides() can see + // it - the async broadcast from KeypressMonitor hasn't updated + // hotkeys_data_ yet at this point. + pending_overrides_[hk->hotkey_name()] = StdFromQString(new_sequence); + + saveHotkeyOverrides(); + + return true; + } catch (const std::exception &err) { + spdlog::warn("rebindHotkey failed: {}", err.what()); + } + return false; +} + +void HotkeysUI::resetHotkey(int model_row) { + const auto *hk = hotkeyAtRow(model_row); + if (!hk) + return; + + auto it = default_sequences_.find(hk->hotkey_name()); + if (it != default_sequences_.end()) { + rebindHotkey(model_row, QStringFromStd(it->second)); + } +} + +void HotkeysUI::resetAllHotkeys() { + for (auto &[name, seq] : default_sequences_) { + // Find this hotkey in the current data and rebind + int ct = 0; + const std::string curr_cat(StdFromQString(current_category_)); + for (auto &hk : hotkeys_data_) { + if (hk.hotkey_origin() == curr_cat) { + if (hk.hotkey_name() == name) { + rebindHotkey(ct, QStringFromStd(seq)); + } + ct++; + } + } + } +} + +std::string HotkeysUI::hotkey_overrides_path() { + std::string dir; +#ifdef _WIN32 + char path[MAX_PATH]; + if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_LOCAL_APPDATA, NULL, 0, path))) { + dir = std::string(path) + "\\xstudio"; + } else { + dir = "."; + } +#else + const char *home = getenv("HOME"); + dir = home ? std::string(home) + "/.config/xstudio" : "."; +#endif + std::filesystem::create_directories(dir); + return dir + "/hotkey_overrides.json"; +} + +void HotkeysUI::saveHotkeyOverrides() { + try { + nlohmann::json overrides = nlohmann::json::object(); + + // Build overrides from pending_overrides_ which tracks rebinds + // immediately, rather than from hotkeys_data_ which is only updated + // asynchronously after the KeypressMonitor broadcast. + for (const auto &[name, seq] : pending_overrides_) { + auto it = default_sequences_.find(name); + if (it != default_sequences_.end() && it->second != seq) { + overrides[name] = seq; + } + } + + std::ofstream ofs(hotkey_overrides_path()); + if (ofs.is_open()) { + ofs << overrides.dump(2); + spdlog::info("Saved hotkey overrides to {}", hotkey_overrides_path()); + } + } catch (const std::exception &err) { + spdlog::warn("Failed to save hotkey overrides: {}", err.what()); + } +} + +void HotkeysUI::loadHotkeyOverrides() { + try { + std::ifstream ifs(hotkey_overrides_path()); + if (!ifs.is_open()) + return; + + nlohmann::json overrides; + ifs >> overrides; + + auto keyboard_manager = system().registry().template get(keyboard_events); + + for (auto it = overrides.begin(); it != overrides.end(); ++it) { + const std::string &name = it.key(); + const std::string &seq = it.value().get(); + + // Find this hotkey + for (auto &hk : hotkeys_data_) { + if (hk.hotkey_name() == name) { + int new_key = 0, new_mod = 0; + Hotkey::sequence_to_key_and_modifier(seq, new_key, new_mod); + if (new_key != 0) { + Hotkey new_hk( + new_key, new_mod, + hk.hotkey_name(), hk.hotkey_origin(), hk.hotkey_description(), + "", false, caf::actor_addr(), hk.uuid()); + anon_mail(keypress_monitor::register_hotkey_atom_v, new_hk) + .send(keyboard_manager); + } + break; + } + } + } + + // Populate pending_overrides_ so subsequent saves include loaded + // overrides that haven't been touched this session. + for (auto it = overrides.begin(); it != overrides.end(); ++it) { + pending_overrides_[it.key()] = it.value().get(); + } + + spdlog::info("Loaded {} hotkey overrides from {}", overrides.size(), hotkey_overrides_path()); + } catch (const std::exception &err) { + spdlog::debug("No hotkey overrides loaded: {}", err.what()); + } +} + QString HotkeysUI::hotkey_sequence(const QVariant &hotkey_uuid) { QString result; utility::Uuid hk_uuid; diff --git a/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp b/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp index 3688c819a..b9d4a013a 100644 --- a/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp +++ b/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp @@ -799,8 +799,10 @@ bool OffscreenViewport::setupTextureAndFrameBuffer( format_to_gl_tex_format[vid_out_format_]); } - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); + float borderColor[] = {0.0f, 0.0f, 0.0f, 1.0f}; + glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); diff --git a/ui/qml/xstudio/qml.qrc b/ui/qml/xstudio/qml.qrc index 3844ddc8e..6f6c214ea 100644 --- a/ui/qml/xstudio/qml.qrc +++ b/ui/qml/xstudio/qml.qrc @@ -184,6 +184,7 @@ widgets/controls/attr_controls/XsIntegerAttrControl.qml widgets/dialogs/hotkeys/delegates/XsHotkeyDetails.qml + widgets/dialogs/hotkeys/delegates/XsHotkeyEditorDelegate.qml widgets/dialogs/hotkeys/XsHotkeysDialog.qml widgets/dialogs/preferences/delegates/XsColourPreference.qml widgets/dialogs/preferences/delegates/XsCompareModePref.qml diff --git a/ui/qml/xstudio/widgets/dialogs/hotkeys/XsHotkeysDialog.qml b/ui/qml/xstudio/widgets/dialogs/hotkeys/XsHotkeysDialog.qml index c751becea..aabb34dc7 100644 --- a/ui/qml/xstudio/widgets/dialogs/hotkeys/XsHotkeysDialog.qml +++ b/ui/qml/xstudio/widgets/dialogs/hotkeys/XsHotkeysDialog.qml @@ -11,11 +11,12 @@ import "./delegates" XsWindow { id: dialog - width: 550 - minimumWidth: 550 - height: 250 - // minimumHeight: 250 - property var row_widths: [100, 100, 100] + width: 700 + minimumWidth: 600 + height: 500 + minimumHeight: 350 + + property var row_widths: [180, 140, 100] function setRowMinWidth(w, i) { if (w > row_widths[i]) { var r = row_widths @@ -24,16 +25,18 @@ XsWindow { } } - title: "xSTUDIO Hotkeys" + property string searchFilter: "" + + title: "xSTUDIO Hotkey Editor" ColumnLayout { anchors.fill: parent anchors.margins: 0 - spacing: 10 + spacing: 0 + // Tab bar for categories TabBar { - id: tabBar Layout.fillWidth: true @@ -42,11 +45,9 @@ XsWindow { } Repeater { - model: hotkeysModel.categories TabButton { - width: implicitWidth contentItem: XsText { @@ -57,40 +58,135 @@ XsWindow { } background: Rectangle { - border.color: hovered? palette.highlight : "transparent" + border.color: hovered ? palette.highlight : "transparent" color: tabBar.currentIndex == index ? XsStyleSheet.panelTitleBarColor : Qt.darker(XsStyleSheet.panelTitleBarColor, 1.5) } } - } onCurrentIndexChanged: { hotkeysModel.currentCategory = hotkeysModel.categories[currentIndex] } + } + + // Search bar + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: Qt.darker(palette.base, 1.2) + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 8 + + XsText { + text: "Search:" + Layout.alignment: Qt.AlignVCenter + } + + TextField { + id: searchField + Layout.fillWidth: true + Layout.preferredHeight: 26 + placeholderText: "Filter hotkeys..." + color: palette.text + font.pixelSize: XsStyleSheet.fontSize + background: Rectangle { + color: palette.base + border.color: searchField.activeFocus ? palette.highlight : XsStyleSheet.widgetBgNormalColor + radius: 2 + } + onTextChanged: { + dialog.searchFilter = text.toLowerCase() + } + } + } + } + // Column header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 28 + color: XsStyleSheet.panelTitleBarColor + + RowLayout { + anchors.fill: parent + spacing: 0 + + XsText { + Layout.preferredWidth: row_widths[0] + 20 + Layout.leftMargin: 10 + text: "Action" + font.bold: true + horizontalAlignment: Text.AlignLeft + } + + Rectangle { + Layout.fillHeight: true + Layout.preferredWidth: 1 + color: XsStyleSheet.widgetBgNormalColor + } + + XsText { + Layout.fillWidth: true + Layout.leftMargin: 10 + text: "Shortcut" + font.bold: true + horizontalAlignment: Text.AlignLeft + } + } } + // Hotkey list XsListView { + id: hotkeyListView Layout.fillWidth: true Layout.fillHeight: true model: hotkeysModel - delegate: XsHotkeyDetails {} + clip: true + + ScrollBar.vertical: XsScrollBar { + policy: ScrollBar.AsNeeded + } + delegate: XsHotkeyEditorDelegate { + searchFilter: dialog.searchFilter + } } - XsSimpleButton { + // Bottom toolbar + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 44 + color: palette.base + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + XsSimpleButton { + text: qsTr("Reset All") + width: XsStyleSheet.primaryButtonStdWidth * 2 + onClicked: { + hotkeysModel.resetAllHotkeys() + } + } + + Item { Layout.fillWidth: true } - Layout.alignment: Qt.AlignRight|Qt.AlignVCenter - Layout.rightMargin: 10 - Layout.bottomMargin: 10 - text: qsTr("Close") - width: XsStyleSheet.primaryButtonStdWidth*2 - onClicked: { - dialog.close() + XsSimpleButton { + text: qsTr("Close") + width: XsStyleSheet.primaryButtonStdWidth * 2 + onClicked: { + dialog.close() + } + } } } } - -} \ No newline at end of file +} diff --git a/ui/qml/xstudio/widgets/dialogs/hotkeys/delegates/XsHotkeyEditorDelegate.qml b/ui/qml/xstudio/widgets/dialogs/hotkeys/delegates/XsHotkeyEditorDelegate.qml new file mode 100644 index 000000000..38ed53ba3 --- /dev/null +++ b/ui/qml/xstudio/widgets/dialogs/hotkeys/delegates/XsHotkeyEditorDelegate.qml @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts + +import xstudio.qml.models 1.0 +import xStudio 1.0 + +Item { + id: delegateRoot + + property string searchFilter: "" + + width: ListView.view ? ListView.view.width : 0 + height: matchesFilter ? XsStyleSheet.widgetStdHeight + 4 : 0 + visible: matchesFilter + clip: true + + property bool matchesFilter: { + if (searchFilter === "") + return true + return hotkeyName.toLowerCase().indexOf(searchFilter) >= 0 || + hotkeySequence.toLowerCase().indexOf(searchFilter) >= 0 || + hotkeyDescription.toLowerCase().indexOf(searchFilter) >= 0 + } + + property bool isCapturing: false + property string capturedSequence: "" + property string conflictText: "" + + // Divider line + Rectangle { + width: parent.width + anchors.bottom: parent.bottom + height: 1 + color: XsStyleSheet.widgetBgNormalColor + } + + // Hover highlight + Rectangle { + anchors.fill: parent + color: mouseArea.containsMouse ? Qt.rgba(1, 1, 1, 0.04) : "transparent" + } + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // Hotkey name + XsLabel { + id: nameLabel + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft + Layout.preferredWidth: row_widths[0] + Layout.topMargin: 4 + Layout.bottomMargin: 4 + Layout.leftMargin: 10 + Layout.rightMargin: 10 + text: hotkeyName + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + + TextMetrics { + font: nameLabel.font + text: nameLabel.text + onWidthChanged: setRowMinWidth(width, 0) + } + } + + Rectangle { + Layout.fillHeight: true + Layout.preferredWidth: 1 + color: XsStyleSheet.widgetBgNormalColor + } + + // Shortcut display / capture area + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 4 + Layout.rightMargin: 4 + + RowLayout { + anchors.fill: parent + anchors.margins: 2 + spacing: 4 + + // Shortcut button - click to capture + Rectangle { + id: shortcutButton + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: 2 + color: delegateRoot.isCapturing ? Qt.rgba(palette.highlight.r, palette.highlight.g, palette.highlight.b, 0.3) : + delegateRoot.conflictText !== "" ? Qt.rgba(1, 0.6, 0, 0.12) : + shortcutMouseArea.containsMouse ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(1, 1, 1, 0.04) + border.color: delegateRoot.isCapturing ? palette.highlight : + delegateRoot.conflictText !== "" ? "#E6A020" : + shortcutMouseArea.containsMouse ? Qt.rgba(1, 1, 1, 0.2) : "transparent" + border.width: delegateRoot.isCapturing ? 2 : delegateRoot.conflictText !== "" ? 1 : 1 + radius: 3 + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 6 + anchors.rightMargin: 6 + spacing: 6 + + XsText { + Layout.alignment: Qt.AlignVCenter + text: delegateRoot.isCapturing ? "Press key..." : hotkeySequence + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + font.italic: delegateRoot.isCapturing + color: delegateRoot.isCapturing ? palette.highlight : + delegateRoot.conflictText !== "" ? "#E6A020" : palette.text + } + + XsText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + visible: delegateRoot.conflictText !== "" && !delegateRoot.isCapturing + text: delegateRoot.conflictText !== "" ? "Conflicts with: " + delegateRoot.conflictText : "" + horizontalAlignment: Text.AlignRight + verticalAlignment: Text.AlignVCenter + font.pixelSize: XsStyleSheet.fontSize - 1 + font.italic: true + color: "#E6A020" + elide: Text.ElideRight + } + } + + MouseArea { + id: shortcutMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + if (!delegateRoot.isCapturing) { + delegateRoot.isCapturing = true + captureInput.forceActiveFocus() + } + } + } + + TextMetrics { + id: seqMetrics + font.pixelSize: XsStyleSheet.fontSize + text: hotkeySequence + onWidthChanged: setRowMinWidth(width + 20, 1) + } + } + + // Reset button (visible when modified) + Rectangle { + Layout.preferredWidth: 22 + Layout.preferredHeight: 22 + Layout.alignment: Qt.AlignVCenter + radius: 2 + color: resetMa.containsMouse ? Qt.rgba(1, 1, 1, 0.15) : "transparent" + visible: true + + XsText { + anchors.centerIn: parent + text: "\u21BA" // ↺ reset symbol + font.pixelSize: 14 + color: resetMa.containsMouse ? palette.highlight : XsStyleSheet.hintColor + } + + MouseArea { + id: resetMa + anchors.fill: parent + hoverEnabled: true + onClicked: { + hotkeysModel.resetHotkey(index) + delegateRoot.conflictText = "" + } + } + + XsToolTip { + text: "Reset to default" + visible: resetMa.containsMouse + } + } + } + } + + } + + // Hidden text input for key capture + TextInput { + id: captureInput + width: 0 + height: 0 + visible: false + focus: delegateRoot.isCapturing + + Keys.onPressed: function(event) { + if (!delegateRoot.isCapturing) + return + + // Ignore lone modifier keys + if (event.key === Qt.Key_Shift || event.key === Qt.Key_Control || + event.key === Qt.Key_Alt || event.key === Qt.Key_Meta) { + event.accepted = true + return + } + + // Escape cancels capture + if (event.key === Qt.Key_Escape) { + delegateRoot.isCapturing = false + event.accepted = true + return + } + + // Build sequence string + var parts = [] + if (event.modifiers & Qt.ControlModifier) parts.push("Ctrl") + if (event.modifiers & Qt.AltModifier) parts.push("Alt") + if (event.modifiers & Qt.ShiftModifier) parts.push("Shift") + if (event.modifiers & Qt.MetaModifier) parts.push("Meta") + + // Get key name + var keyName = "" + var keyText = event.text + + // Map common keys + var keyMap = {} + keyMap[Qt.Key_Space] = "Space Bar" + keyMap[Qt.Key_Return] = "Return" + keyMap[Qt.Key_Enter] = "Enter" + keyMap[Qt.Key_Tab] = "Tab" + keyMap[Qt.Key_Backspace] = "Backspace" + keyMap[Qt.Key_Delete] = "Delete" + keyMap[Qt.Key_Insert] = "Insert" + keyMap[Qt.Key_Home] = "Home" + keyMap[Qt.Key_End] = "End" + keyMap[Qt.Key_PageUp] = "PageUp" + keyMap[Qt.Key_PageDown] = "PageDown" + keyMap[Qt.Key_Left] = "Left" + keyMap[Qt.Key_Right] = "Right" + keyMap[Qt.Key_Up] = "Up" + keyMap[Qt.Key_Down] = "Down" + keyMap[Qt.Key_Escape] = "Escape" + keyMap[Qt.Key_F1] = "F1" + keyMap[Qt.Key_F2] = "F2" + keyMap[Qt.Key_F3] = "F3" + keyMap[Qt.Key_F4] = "F4" + keyMap[Qt.Key_F5] = "F5" + keyMap[Qt.Key_F6] = "F6" + keyMap[Qt.Key_F7] = "F7" + keyMap[Qt.Key_F8] = "F8" + keyMap[Qt.Key_F9] = "F9" + keyMap[Qt.Key_F10] = "F10" + keyMap[Qt.Key_F11] = "F11" + keyMap[Qt.Key_F12] = "F12" + + if (event.key in keyMap) { + keyName = keyMap[event.key] + } else if (event.key >= Qt.Key_A && event.key <= Qt.Key_Z) { + keyName = String.fromCharCode(event.key) + } else if (event.key >= Qt.Key_0 && event.key <= Qt.Key_9) { + keyName = String.fromCharCode(event.key) + } else if (keyText && keyText.length === 1) { + keyName = keyText.toUpperCase() + } else { + // Unknown key - cancel + delegateRoot.isCapturing = false + event.accepted = true + return + } + + parts.push(keyName) + var newSequence = parts.join("+") + + // Check for conflicts before rebinding + var conflict = hotkeysModel.checkConflict(index, newSequence) + delegateRoot.conflictText = conflict + + // Apply the rebind (still allowed even with conflict) + hotkeysModel.rebindHotkey(index, newSequence) + delegateRoot.isCapturing = false + event.accepted = true + } + + onActiveFocusChanged: { + if (!activeFocus && delegateRoot.isCapturing) { + delegateRoot.isCapturing = false + } + } + } +} From e19edf43fe9f1ba45c5ca3f8d58be25c82a402a3 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sat, 14 Mar 2026 10:48:18 -0700 Subject: [PATCH 05/18] feat(ui): add EXR Layer/AOV selector to viewport toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Layer dropdown to the OCIO colour pipeline plugin that lets users switch between EXR layers/AOVs (e.g. RGBA, sky, mask, displace) directly from the viewport toolbar and right-click context menu. The backend stream switching already existed — this wires it to the UI: - New StringChoiceAttribute populated dynamically from media streams - Sends current_media_stream_atom on selection change - Base ColourPipeline stores media source actor ref for stream queries - CLAUDE.md updated with correct plugin deployment paths Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 16 +++- .../colour_pipeline/colour_pipeline.hpp | 8 ++ src/colour_pipeline/src/colour_pipeline.cpp | 2 + .../colour_pipeline/ocio/src/ocio_plugin.cpp | 95 +++++++++++++++++++ .../colour_pipeline/ocio/src/ocio_plugin.hpp | 3 + .../colour_pipeline/ocio/src/ui_text.hpp | 4 + 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f10019967..ea8ed6daa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,15 +24,21 @@ cmake --build build --config Release --target xstudio ### Portable Deployment (CRITICAL) **The user runs xSTUDIO from `portable/bin/`, NOT from `build/bin/Release/`.** -After every build, you MUST copy updated binaries into the portable directory: +After every build, you MUST copy updated binaries to the correct portable locations: ```bash -# Copy the main exe +# Main exe and core DLLs go to portable/bin/ cp build/bin/Release/xstudio.exe portable/bin/ +cp build/src/colour_pipeline/src/Release/colour_pipeline.dll portable/bin/ +cp build/src/module/src/Release/module.dll portable/bin/ -# Copy any updated DLLs (plugin .dll files from build/bin/Release/) -cp build/bin/Release/*.dll portable/bin/ +# PLUGINS go to portable/share/xstudio/plugin/ (NOT portable/bin/) +cp build/src/plugin/colour_pipeline/ocio/src/Release/colour_pipeline_ocio.dll portable/share/xstudio/plugin/ +cp build/src/plugin/media_reader/openexr/src/Release/media_reader_openexr.dll portable/share/xstudio/plugin/ +cp build/src/plugin/media_reader/ffmpeg/src/Release/media_reader_ffmpeg.dll portable/share/xstudio/plugin/ +# Other plugins also live in portable/share/xstudio/plugin/ ``` -Without this step, the user will be running the OLD binary and won't see any changes. +**WARNING: Plugins are loaded from `portable/share/xstudio/plugin/`, NOT `portable/bin/`. +Deploying plugin DLLs to the wrong directory means the old plugin runs silently.** ### Key Build Details - Generator: Visual Studio 17 2022 diff --git a/include/xstudio/colour_pipeline/colour_pipeline.hpp b/include/xstudio/colour_pipeline/colour_pipeline.hpp index ea8e79126..c87795d50 100644 --- a/include/xstudio/colour_pipeline/colour_pipeline.hpp +++ b/include/xstudio/colour_pipeline/colour_pipeline.hpp @@ -229,6 +229,12 @@ namespace colour_pipeline { caf::message_handler message_handler_extensions() override; + /* Returns the actor for the current on-screen media source, if available. + This is set automatically when the playhead reports a media source change. */ + const caf::actor ¤t_media_source_actor() const { + return current_media_source_actor_; + } + utility::Uuid uuid_; private: @@ -276,6 +282,8 @@ namespace colour_pipeline { std::vector< std::pair>> hook_requests_; + + caf::actor current_media_source_actor_; }; } // namespace colour_pipeline diff --git a/src/colour_pipeline/src/colour_pipeline.cpp b/src/colour_pipeline/src/colour_pipeline.cpp index c3e11bb7b..c4245066f 100644 --- a/src/colour_pipeline/src/colour_pipeline.cpp +++ b/src/colour_pipeline/src/colour_pipeline.cpp @@ -355,6 +355,7 @@ caf::message_handler ColourPipeline::message_handler_extensions() { auto rp = make_response_promise(); if (media_source) { + current_media_source_actor_ = media_source.actor(); mail(get_colour_pipe_params_atom_v) .request(media_source.actor(), infinite) .then( @@ -393,6 +394,7 @@ caf::message_handler ColourPipeline::message_handler_extensions() { [=](utility::event_atom, playhead::media_source_atom, utility::UuidActor media_source) { // This message comes from the playhead when the onscreen (key) // media source has changed. + current_media_source_actor_ = media_source.actor(); if (media_source) { mail(get_colour_pipe_params_atom_v) .request(media_source.actor(), infinite) diff --git a/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp b/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp index f0a59b766..e2c6f1445 100644 --- a/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp +++ b/src/plugin/colour_pipeline/ocio/src/ocio_plugin.cpp @@ -465,6 +465,62 @@ void OCIOColourPipeline::media_source_changed( // a source colourspace but (possibly) driven by dynamic plugin logic source_colour_space_->set_value(src_cs, false); } + + // Populate the Layer/AOV dropdown from the media source's stream list + update_layer_dropdown(); +} + +void OCIOColourPipeline::update_layer_dropdown() { + + auto media_source = current_media_source_actor(); + if (!media_source) { + // No media source actor available, reset to default + layer_->set_role_data( + module::Attribute::StringChoices, + std::vector{ui_text_.LAYER_DEFAULT}, + false); + layer_->set_role_data( + module::Attribute::AbbrStringChoices, + std::vector{ui_text_.LAYER_DEFAULT}, + false); + layer_->set_value(ui_text_.LAYER_DEFAULT, false); + return; + } + + // Query the media source for its image stream details + mail(utility::detail_atom_v, media::MT_IMAGE) + .request(media_source, caf::infinite) + .then( + [=](const std::vector &stream_details) { + std::vector layer_names; + for (const auto &detail : stream_details) { + layer_names.push_back(detail.name_); + } + + if (layer_names.empty()) { + layer_names.push_back(ui_text_.LAYER_DEFAULT); + } + + // Preserve current selection if it's still valid + const auto current = layer_->value(); + bool current_valid = std::find( + layer_names.begin(), layer_names.end(), current) != layer_names.end(); + + layer_->set_role_data( + module::Attribute::StringChoices, layer_names, false); + layer_->set_role_data( + module::Attribute::AbbrStringChoices, layer_names, false); + + if (!current_valid) { + // Default to first layer (typically RGBA) + layer_->set_value(layer_names.front(), false); + } + }, + [=](const caf::error &err) { + spdlog::warn( + "OCIOColourPipeline: Failed to query stream details: {}", + to_string(err)); + }); } void OCIOColourPipeline::attribute_changed( @@ -511,6 +567,30 @@ void OCIOColourPipeline::attribute_changed( } // Remaning attributes are synchronized unconditionally + } else if (attribute_uuid == layer_->uuid()) { + + // User selected a different EXR layer/AOV - switch the active stream + // on the media source actor + auto media_source = current_media_source_actor(); + if (media_source) { + const auto layer_name = layer_->value(); + mail(media::current_media_stream_atom_v, media::MT_IMAGE, layer_name) + .request(media_source, caf::infinite) + .then( + [=](bool switched) { + if (!switched) { + spdlog::warn( + "OCIOColourPipeline: Failed to switch to layer '{}'", + layer_name); + } + }, + [=](const caf::error &err) { + spdlog::warn( + "OCIOColourPipeline: Error switching layer: {}", + to_string(err)); + }); + } + } else { synchronize_attribute(attribute_uuid, role, false); @@ -567,6 +647,7 @@ void OCIOColourPipeline::connect_to_viewport( insert_menu_item( viewport_context_menu_model_name, "Display", "OCIO", 2.0f, display_, false); insert_menu_item(viewport_context_menu_model_name, "View", "OCIO", 3.0f, view_, false); + insert_menu_item(viewport_context_menu_model_name, "Layer", "", 21.0f, layer_, false); insert_menu_item(viewport_context_menu_model_name, "Channel", "", 21.5f, channel_, false); insert_menu_item(viewport_context_menu_model_name, "", "", 22.0f, nullptr, true); // divider @@ -631,6 +712,19 @@ void OCIOColourPipeline::setup_ui() { channel_->set_role_data(module::Attribute::ToolbarPosition, 8.0f); channel_->set_role_data(module::Attribute::ToolTip, ui_text_.CS_MSG_CMS_SELECT_CLR_TIP); + // EXR Layer/AOV selection + + layer_ = add_string_choice_attribute( + ui_text_.LAYER, + ui_text_.LAYER_SHORT, + ui_text_.LAYER_DEFAULT, + {ui_text_.LAYER_DEFAULT}, + {ui_text_.LAYER_DEFAULT}); + layer_->set_redraw_viewport_on_change(true); + layer_->set_role_data(module::Attribute::Enabled, true); + layer_->set_role_data(module::Attribute::ToolbarPosition, 7.5f); + layer_->set_role_data(module::Attribute::ToolTip, ui_text_.LAYER_TOOLTIP); + // Exposure slider exposure_ = add_float_attribute( @@ -663,6 +757,7 @@ void OCIOColourPipeline::setup_ui() { make_attribute_visible_in_viewport_toolbar(exposure_); make_attribute_visible_in_viewport_toolbar(channel_); + make_attribute_visible_in_viewport_toolbar(layer_); make_attribute_visible_in_viewport_toolbar(gamma_); make_attribute_visible_in_viewport_toolbar(saturation_); } diff --git a/src/plugin/colour_pipeline/ocio/src/ocio_plugin.hpp b/src/plugin/colour_pipeline/ocio/src/ocio_plugin.hpp index d30151bfd..2bb7404ba 100644 --- a/src/plugin/colour_pipeline/ocio/src/ocio_plugin.hpp +++ b/src/plugin/colour_pipeline/ocio/src/ocio_plugin.hpp @@ -133,6 +133,8 @@ class OCIOColourPipeline : public ColourPipeline { void synchronize_attribute(const utility::Uuid &uuid, int role, bool ocio); + void update_layer_dropdown(); + std::string detect_display( const std::string &name, const std::string &model, @@ -158,6 +160,7 @@ class OCIOColourPipeline : public ColourPipeline { module::StringChoiceAttribute *display_; module::StringChoiceAttribute *view_; module::StringChoiceAttribute *channel_; + module::StringChoiceAttribute *layer_; module::FloatAttribute *exposure_; module::FloatAttribute *gamma_; module::FloatAttribute *saturation_; diff --git a/src/plugin/colour_pipeline/ocio/src/ui_text.hpp b/src/plugin/colour_pipeline/ocio/src/ui_text.hpp index 1e9f8b29c..77dc14687 100644 --- a/src/plugin/colour_pipeline/ocio/src/ui_text.hpp +++ b/src/plugin/colour_pipeline/ocio/src/ui_text.hpp @@ -74,6 +74,10 @@ struct UiText { std::string ENABLE_SATURATION_SHORT = "Enbl. Sat."; std::string CHANNEL = "Channel"; std::string CHANNEL_SHORT = "Chan"; + std::string LAYER = "Layer"; + std::string LAYER_SHORT = "Lyr"; + std::string LAYER_TOOLTIP = "Select the EXR layer/AOV to display."; + std::string LAYER_DEFAULT = "RGBA"; std::string SOURCE_CS = "Source Colour Space"; std::string SOURCE_CS_SHORT = "Source cs"; std::string CMS_OFF = "Bypass Colour Management"; From 81a5ac6a7ad91f8517ec86e2608f680dc7e92524 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sat, 14 Mar 2026 15:46:42 -0700 Subject: [PATCH 06/18] feat(plugin): add Filesystem Browser Python plugin with Windows support Extracted from PR #198 and adapted for Windows. Provides a VFX-oriented filesystem browser panel with directory tree, file sequence detection (via fileseq), version grouping, and thumbnail generation. Key Windows fixes: - Drive letter enumeration under virtual "/" root ("This PC") - Case-insensitive path comparison throughout QML tree - Forward-slash normalization on all path returns - Fixed shadowed `time` import in _get_subdirs - Direct attribute write from DirectoryTree (bypasses signal chain) - Tree auto-syncs to current path on launch - Up-directory button in path bar - Depth spinner persists across sessions (title mismatch fix) Co-Authored-By: Claude Opus 4.6 --- .../filesystem_browser/README.md | 68 + .../filesystem_browser/__init__.py | 1 + .../filesystem_browser/config.json | 47 + .../filesystem_browser/filesystem_browser.py | 1549 +++++++++++ .../qml/FilesystemBrowser.1/DirectoryTree.qml | 525 ++++ .../FilesystemBrowser.1/FilesystemBrowser.qml | 2298 +++++++++++++++++ .../icons/folder_closed.svg | 38 + .../qml/FilesystemBrowser.1/qmldir | 2 + .../filesystem_browser/scanner.py | 417 +++ 9 files changed, 4945 insertions(+) create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/README.md create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/__init__.py create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/config.json create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/qmldir create mode 100644 portable/share/xstudio/plugin-python/filesystem_browser/scanner.py diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/README.md b/portable/share/xstudio/plugin-python/filesystem_browser/README.md new file mode 100644 index 000000000..b69d591ca --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/README.md @@ -0,0 +1,68 @@ +# Filesystem Browser Plugin for xStudio + +A high-performance, multi-threaded filesystem browser for xStudio, designed to handle large directories and image sequences efficiently. + +## Features + +- **Fast Multi-threaded Scanning**: Uses a thread pool and BFS algorithm to scan directories quickly without freezing the UI. +- **Image Sequence Detection**: Automatically detects and groups file sequences (e.g., `shot_001.1001.exr` -> `shot_001.####.exr`). Supports exclusion of specific extensions (e.g., `.mov`, `.mp4`) via configuration. +- **Smart Filtering**: + - **Text Filter**: Supports "AND" logic (space-separated terms). E.g., `comp exr` finds files matchings both "comp" and "exr". + - **Time Filter**: Filter by modification time (Last 1 day, 1 week, etc.). + - **Version Filter**: Filter to show only the latest version or latest 2 versions of a shot. +- **Navigation**: + - Native Directory Picker integration. + - Path completion/suggestions. + - History tracking (via sticky attributes). +- **Playback Integration**: + - **Double-Click**: Loads media and immediately starts playback using the playlist's playhead logic. + - **Context Menu**: + - **Replace**: Replaces the currently viewed media with the selected item. + - **Compare with**: Loads the selected item and sets up an A/B comparison with the current media. + +## Usage + +1. **Open the Browser**: + - Go to `View` -> `Panels` -> `Filesystem Browser`. + - Or use the hotkey **'B'**. +2. **Navigation**: + - Enter a path in the text field or click the folder icon to browse. + - **Double-click** a folder to navigate into it. + - **Quick Access (▼)**: Click the arrow next to the path field to open the Quick Access list. + - **History**: Shows recently visited directories. + - **Pinned**: Shows your pinned locations for easy access. + - **Pinning**: Click the "Pin" icon (📌) next to any item to pin or unpin it. Pinned items appear at the top in gold. + +## Configuration + +### Environment Variables + +- `XSTUDIO_BROWSER_PINS`: Pre-define a list of pinned directories. + - Format: JSON list of objects or simple path string (colon-separated on Unix, semicolon on Windows). + - Example (JSON): `'[{"name": "Show", "path": "/jobs/show"}, "/home/user"]'` + - Example (Simple): `/jobs/show:/home/user` + +3. **Loading Media**: + - **Double-click** a file/sequence to load it into the current or new playlist. + - **Right-click** for advanced actions (Replace, Compare). + +## Logic & Performance + +- **Scanning**: The scanner runs in a background thread, reporting partial results to the UI to keep it responsive. +- **Sequences**: Uses the `fileseq` library (for robust sequence parsing. + +## Testing + +A benchmark script is included to test the scanner performance: + +```bash +python scanner_benchmark.py --threads 2 /shots/MYSHOW/MYSHOT +``` + +This allows you to test the scanning performance at different thread speeds for the specified directory. + +```bash +python test_scanner.py +``` +Unit test for scanner. + diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/__init__.py b/portable/share/xstudio/plugin-python/filesystem_browser/__init__.py new file mode 100644 index 000000000..da8023aea --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/__init__.py @@ -0,0 +1 @@ +from .filesystem_browser import create_plugin_instance diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/config.json b/portable/share/xstudio/plugin-python/filesystem_browser/config.json new file mode 100644 index 000000000..5f97ac007 --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/config.json @@ -0,0 +1,47 @@ +{ + "extensions": [ + ".mov", + ".mp4", + ".mkv", + ".exr", + ".jpg", + ".jpeg", + ".png", + ".dpx", + ".tiff", + ".tif", + ".wav", + ".mp3", + ".pdf" + ], + "ignore_dirs": [ + ".git", + ".quarantine", + "eryx_unreal_plugin", + ".DS_Store" + ], + "root_ignore_dirs": [ + "/Applications", + "/bin", + "/cores", + "/dev", + "/etc", + "/Library", + "/opt", + "/private", + "/sbin", + "/System", + "/usr", + "/var", + "/proc", + "/sys", + "/snap", + "C:\\Windows", + "C:\\Program Files", + "C:\\Program Files (x86)", + "C:\\ProgramData", + "C:\\Recovery", + "C:\\$Recycle.Bin" + ], + "max_recursion_depth": 0 +} \ No newline at end of file diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py new file mode 100644 index 000000000..a3f45db7f --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py @@ -0,0 +1,1549 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards + +from xstudio.plugin import PluginBase +from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid +import os +import sys +import json +import threading +import queue +import time +import subprocess +import shutil +import pathlib +import tempfile +import uuid as _uuid +from datetime import datetime + +# Try importing fileseq +try: + import fileseq + fileseq_available = True +except ImportError: + fileseq_available = False + print("Warning: fileseq module not found. Sequence detection will be disabled.") + +# File-based debug log (more reliable than print in xStudio's embedded Python) +_DEBUG_LOG = os.path.join(tempfile.gettempdir(), "xstudio_thumb_debug.txt") +def _dbg(msg): + try: + with open(_DEBUG_LOG, "a") as _f: + _f.write(f"{msg}\n") + _f.flush() + except Exception: + pass + + +def _find_ffmpeg(): + """Find ffmpeg binary. Checks env var, xStudio app bundle, then system PATH.""" + # 1. Explicit override + env_path = os.environ.get("FFMPEG_PATH") + if env_path and os.path.isfile(env_path): + return env_path, None # (binary, dyld_lib_path) + + # 2. xStudio app bundle (same directory as the main binary) + exe = sys.argv[0] if sys.argv else "" + exe_dir = os.path.dirname(exe) + ffmpeg_name = "ffmpeg.exe" if sys.platform == "win32" else "ffmpeg" + bundle_ffmpeg = os.path.join(exe_dir, ffmpeg_name) + if os.path.isfile(bundle_ffmpeg): + # Bundled ffmpeg needs Frameworks dir on DYLD_LIBRARY_PATH (macOS only) + frameworks = os.path.join(exe_dir, "..", "Frameworks") + frameworks = os.path.normpath(frameworks) + return bundle_ffmpeg, frameworks + + # 3. System PATH + system_ffmpeg = shutil.which("ffmpeg") + if system_ffmpeg: + return system_ffmpeg, None + + return None, None + + +# PySide6 dependency removed +# from PySide6.QtCore import QObject, Signal, Qt +# from PySide6.QtWidgets import QApplication, QFileDialog + +# MainThreadExecutor removed. +# xstudio attributes .set_value() is generally thread-safe (posts to actor). +# For GUI dialogs, we need another approach or they are disabled without PySide. + + +class FilesystemBrowserPlugin(PluginBase): + def __init__(self, connection): + PluginBase.__init__( + self, + connection, + "Filesystem Browser", + qml_folder="qml/FilesystemBrowser.1" + ) + # Load Configuration + self.config = self.load_config() + + # self.main_executor = MainThreadExecutor() + + # Attribute to communicate list of files to QML (as JSON string) + self.files_attr = self.add_attribute( + "file_list", + "[]", # Empty JSON list + {"title": "file_list"}, # Explicit title for QML lookup + register_as_preference=False + ) + self.files_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Attribute for current path + self.current_path_attr = self.add_attribute( + "current_path", + os.getcwd(), + {"title": "current_path"}, + register_as_preference=True + ) + self.current_path_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Attribute for commands from QML + self.command_attr = self.add_attribute( + "command_channel", + "", + {"title": "command_channel"}, + register_as_preference=False + ) + self.command_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Action to toggle the panel + self.toggle_action_uuid = "2669e4a3-7186-4556-9818-80949437b018" + + self.toggle_browser_action = self.register_hotkey( + self.toggle_browser, # hotkey_callback + "B", # default_keycode + 0, # default_modifier + "Show Filesystem Browser", + "Toggles the Filesystem Browser panel", + False, # auto_repeat + "FilesystemBrowser", # component + "Window" # context + ) + + # Menu item triggers this action + # Removed manual callback to rely on hotkey_uuid linkage + # which should toggle the panel automatically if registered correctly. + self.insert_menu_item( + "main menu bar", + "Filesystem Browser", + "View|Panels", + 0.0, + hotkey_uuid=self.toggle_browser_action, + callback=self.toggle_browser_from_menu + ) + + # Add menu item to open as floating window + self.insert_menu_item( + "main menu bar", + "Browser Open", + "Plugins", + 0.1, + callback=self.open_floating_browser + ) + + # Register the panel, passing the action + self.register_ui_panel_qml( + "Filesystem Browser", + """ + FilesystemBrowser { + anchors.fill: parent + } + """, + 10.0, # Position in menu + "", # No icon = Standard Panel (Dockable) + -1.0, + self.toggle_browser_action # Pass the action UUID + ) + + # New: Completion attribute + self.completions_attr = self.add_attribute( + "completions_attribute", + "[]", + {"title": "completions_attr"}, + register_as_preference=False + ) + self.completions_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Search state attribute + self.searching_attr = self.add_attribute( + "searching", + False, + {"title": "searching"}, + register_as_preference=False + ) + self.searching_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Progress attribute + self.progress_attr = self.add_attribute( + "scan_progress", + "0", + {"title": "scan_progress"}, + register_as_preference=False + ) + self.progress_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Progress attribute + self.scanned_attr = self.add_attribute( + "scanned_count", + "0", + {"title": "scanned_count"}, + register_as_preference=False + ) + self.scanned_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Scanned directories list + self.scanned_dirs_attr = self.add_attribute( + "scanned_dirs", + "[]", + {"title": "scanned_dirs"}, + register_as_preference=False + ) + self.scanned_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Directory Query Result (for Tree View) + self.directory_query_result = self.add_attribute( + "directory_query_result", + "{}", + {"title": "directory_query_result"}, + register_as_preference=False + ) + self.directory_query_result.expose_in_ui_attrs_group("Filesystem Browser") + + self.depth_limit_attr = self.add_attribute( + "recursion_limit", + self.config.get("max_recursion_depth", 0), + {"title": "recursion_limit"}, + register_as_preference=True + ) + self.depth_limit_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Scan Required flag (for manual scan mode) + self.scan_required_attr = self.add_attribute( + "scan_required", + False, + {"title": "scan_required"}, + register_as_preference=False + ) + self.scan_required_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Auto-scan threshold (read-only for UI logic) + self.auto_scan_threshold_attr = self.add_attribute( + "auto_scan_threshold", + self.config.get("auto_scan_threshold", 4), + {"title": "auto_scan_threshold"}, + register_as_preference=False + ) + self.auto_scan_threshold_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Filter attributes + self.filter_time_attr = self.add_attribute( + "filter_time", + "Any", + {"title": "filter_time", "values": ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"]}, + register_as_preference=True + ) + self.filter_time_attr.expose_in_ui_attrs_group("Filesystem Browser") + + self.filter_version_attr = self.add_attribute( + "filter_version", + "All Versions", + {"title": "filter_version", "values": ["All Versions", "Latest Version", "Latest 2 Versions"]}, + register_as_preference=True + ) + self.filter_version_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # History and Pinned Attributes + self.history_attr = self.add_attribute( + "history_paths", + "[]", + {"title": "history_paths"}, # Must match QML attributeTitle + register_as_preference=True + ) + self.history_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Default pinned items + default_pins = [] + + # 1. Environment Variable Pre-defines (JSON list of dicts or paths) + env_pins = os.environ.get("XSTUDIO_BROWSER_PINS") + if env_pins: + try: + # Try parsing as JSON first + parsed = json.loads(env_pins) + if isinstance(parsed, list): + for item in parsed: + if isinstance(item, dict) and "path" in item: + default_pins.append(item) + elif isinstance(item, str): + default_pins.append({"name": os.path.basename(item), "path": item}) + except: + # Fallback to standard path separator (colon on Unix, semicolon on Win) + # We also normalize semicolons to os.pathsep to be lenient + normalized = env_pins + if os.pathsep == ":": + normalized = env_pins.replace(";", ":") + + paths = normalized.split(os.pathsep) + for p in paths: + p = p.strip() + if p: + default_pins.append({"name": os.path.basename(p), "path": p}) + + # 2. Standard Defaults + home = os.path.expanduser("~") + if home and home != "~": + # Avoid duplicates + if not any(p["path"] == home for p in default_pins): + default_pins.append({"name": "Home", "path": home}) + + downloads = os.path.join(home, "Downloads") + if os.path.exists(downloads): + if not any(p["path"] == downloads for p in default_pins): + default_pins.append({"name": "Downloads", "path": downloads}) + + self.pinned_attr = self.add_attribute( + "pinned_paths", + json.dumps(default_pins), + {"title": "pinned_paths"}, # Must match QML attributeTitle + register_as_preference=True + ) + self.pinned_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # ENFORCE: Merge default_pins (env vars + explicit defaults) into the actual attribute value + try: + current_val = self.pinned_attr.value() + + current_pins = [] + if current_val: + try: + current_pins = json.loads(current_val) + except Exception: + current_pins = [] + + # Merge + changed = False + existing_paths = set(p["path"] for p in current_pins) + + for pin in reversed(default_pins): + if pin["path"] not in existing_paths: + current_pins.insert(0, pin) + existing_paths.add(pin["path"]) + changed = True + + if changed or not current_val: + new_val = json.dumps(current_pins) + self.pinned_attr.set_value(new_val) + + except Exception as e: + print(f"FilesystemBrowser: Error merging pins: {e}") + + # Connect listeners + # Note: We need to register callbacks properly. + # attribute_changed method handles all. + + # Internal state + # Load extensions and ignore dirs from config + self.extensions = set(self.config.get("extensions", [])) + self.ignore_dirs = set(self.config.get("ignore_dirs", [])) + self.root_ignore_dirs = set(os.path.normpath(p) for p in self.config.get("root_ignore_dirs", [])) + self.search_thread = None + self.cancel_search = False + self.results_lock = threading.Lock() # Protects current_scan_results + self.current_scan_results = [] + + # Thumbnail setup — ffmpeg-based, no xStudio actor system needed + self._ffmpeg_bin, self._ffmpeg_dyld = _find_ffmpeg() + if self._ffmpeg_bin: + print(f"FilesystemBrowser: using ffmpeg at {self._ffmpeg_bin}") + else: + print("FilesystemBrowser: WARNING — ffmpeg not found, thumbnails disabled") + self._temp_dir = tempfile.mkdtemp(prefix="xstudio_thumbs_") + self._thumbnail_cache = {} # path -> file:///... thumb URI + self._thumb_lock = threading.Lock() + self._thumb_pending = set() # paths currently in queue/processing + self._thumb_queue = queue.Queue() + # 4 worker threads — daemon so they die with the process + for _ in range(4): + t = threading.Thread(target=self._thumb_worker_loop, daemon=True) + t.start() + + # State tracking for Preview Mode + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + + # Dedicated attribute for batch thumbnail requests from QML. + # QML writes a JSON array of paths; Python reads and queues them all at once. + self.thumbnail_request_attr = self.add_attribute( + "thumbnail_request", + "[]", + {"title": "thumbnail_request"}, + register_as_preference=False + ) + self.thumbnail_request_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Initial search + self.start_search(self.current_path_attr.value()) + + def toggle_browser_from_menu(self, menu_item=None, user_data=None): + # Wrapper for menu callback + # Since we are now a standard dockable panel, the user should use View -> Panels -> Filesystem Browser + # or rely on the hotkey's default action if it maps to the view. + # We'll just log here. + print("Menu item clicked. The Filesystem Browser is available in the Panels menu.") + self.toggle_browser(None, "Menu Click") + + def open_floating_browser(self): + # Create a floating window containing the FilesystemBrowser component + qml = """ + import QtQuick.Window 2.15 + import QtQuick.Controls 2.15 + + Window { + width: 900 + height: 600 + visible: true + title: "Filesystem Browser" + + FilesystemBrowser { + anchors.fill: parent + } + } + """ + self.create_qml_item(qml) + + def toggle_browser(self, converting, context): + print(f"Toggling Filesystem Browser (Action Triggered). Context: {context}") + # We can also verify visibility here if possible, but the Model handles it. + + + def _open_browser_dialog(self, initial_path): + """Runs on main thread to show dialog.""" + try: + from PySide6.QtWidgets import QFileDialog + dir_path = QFileDialog.getExistingDirectory(None, "Select Directory", initial_path) + if dir_path: + self.current_path_attr.set_value(dir_path) + self.start_search(dir_path) + except ImportError: + print("PySide6 not available. Directory dialog disabled.") + except Exception as e: + print(f"Error opening dialog: {e}") + + + def attribute_changed(self, attribute, role): + # Handle commands from QML via the command attribute + from xstudio.core import AttributeRole + + # Check if it's our command attribute and the Value changed + if attribute.uuid == self.command_attr.uuid and role == AttributeRole.Value: + # Safely get value + try: + val = self.command_attr.value() + except TypeError: + # Can happen if connection is shutting down or not ready + return + + if not val: + return # Empty command + + try: + data = json.loads(val) + action = data.get("action") + _dbg(f"CMD: action={action} data={data}") + + if action == "change_path": + new_path = data.get("path", "").replace("\\", "/") + # Strip trailing slash unless it's a drive root like "X:/" + if len(new_path) > 1 and new_path.endswith("/") and not new_path.endswith(":/"): + new_path = new_path.rstrip("/") + # Virtual root "/" on Windows — just update the path, no scan + if new_path == "/" and sys.platform == "win32": + self.current_path_attr.set_value("/") + return + if os.path.exists(new_path) and os.path.isdir(new_path): + self.current_path_attr.set_value(new_path) + self._add_to_history(new_path) + self.start_search(new_path) + else: + print(f"Invalid path: {new_path}") + + elif action == "load_file": + file_path = data.get("path") + self.load_file(file_path) + + elif action == "preview_file": + file_path = data.get("path") + self._preview_file(file_path) + + elif action == "request_browser": + # Open native directory dialog + current = self.current_path_attr.value() + # Execute directly (will fail gracefully if PySide6 missing) + self._open_browser_dialog(current) + + elif action == "complete_path": + partial = data.get("path", "") + self.compute_completions(partial) + + elif action == "replace_current_media": + path = data.get("path") + self._replace_current_media(path) + + elif action == "compare_with_current_media": + path = data.get("path") + self._compare_with_current_media(path) + + elif action == "set_attribute": + attr_name = data.get("name") + attr_value = data.get("value") + if attr_name == "filter_time": + self.filter_time_attr.set_value(attr_value) + elif attr_name == "filter_version": + self.filter_version_attr.set_value(attr_value) + elif attr_name == "recursion_limit": + self.depth_limit_attr.set_value(attr_value) + + elif action == "add_pin": + path = data.get("path") + self._add_pin(path) + + elif action == "remove_pin": + path = data.get("path") + self._remove_pin(path) + + elif action == "force_scan": + # User clicked "Scan" button + path = data.get("path") + if path: + # Ensure we update the attribute (and thus the QML path field) + self.current_path_attr.set_value(path) + self._add_to_history(path) + # Use deep recursion for manual scan (e.g., 20) + self.start_search(path, force=True, depth=20) + else: + # Fallback for the main Refresh button + current = self.current_path_attr.value() + self.start_search(current, force=True, depth=20) + + elif action == "remove_pin": + path = data.get("path") + self._remove_pin(path) + + elif action == "get_subdirs": + path = data.get("path") + self._get_subdirs(path) + + elif action == "request_thumbnail": + path = data.get("path") + self._request_thumbnail(path) + + # Clear command channel + self.command_attr.set_value("") + + except Exception as e: + print(f"Command error: {e}") + import traceback + traceback.print_exc() + + elif attribute.uuid in (self.filter_time_attr.uuid, self.filter_version_attr.uuid): + if role == AttributeRole.Value: + self._on_filter_changed(attribute, role) + elif attribute.uuid == self.depth_limit_attr.uuid: + if role == AttributeRole.Value: + # Recursion limit changed, re-scan + current = self.current_path_attr.value() + self.start_search(current) + elif attribute.uuid == self.thumbnail_request_attr.uuid and role == AttributeRole.Value: + # QML has written a JSON array of paths to request thumbnails for. + # Handle here on the plugin's message thread, then clear the attribute. + try: + val = attribute.value() + if val and val not in ("", "[]"): + paths = json.loads(val) + _dbg(f"BATCH: received {len(paths)} paths") + for p in paths: + self._request_thumbnail(p) + self.thumbnail_request_attr.set_value("[]") + except Exception as e: + import traceback + _dbg(f"BATCH ERROR: {e}\n{traceback.format_exc()}") + + def start_search(self, start_path, force=False, depth=None): + """ + Start the file search in a separate thread. + If force=False and depth <= 4, skip auto-scan and ask user to confirm. + """ + if not start_path: + return + + self.scan_required_attr.set_value(False) + + if self.search_thread and self.search_thread.is_alive(): + self.cancel_search = True + if hasattr(self, 'scanner'): + self.scanner.stop() + self.search_thread.join() + + self.cancel_search = False + self.searching_attr.set_value(True) + self.search_thread = threading.Thread(target=self._search_worker, args=(start_path, depth)) + self.search_thread.daemon = True + self.search_thread.start() + + def _search_worker(self, start_path, custom_depth=None): + print(f"Starting search in {start_path} (depth={custom_depth if custom_depth is not None else 'default'})") + + from .scanner import FileScanner + + self.cached_filter_time = self.filter_time_attr.value() + self.cached_filter_version = self.filter_version_attr.value() + + max_depth = custom_depth if custom_depth is not None else self.depth_limit_attr.value() + config = { + "extensions": list(self.extensions), + "ignore_dirs": list(self.ignore_dirs), + "max_depth": max_depth + } + + self.scanner = FileScanner(config) + with self.results_lock: + self.current_scan_results = [] + self.pending_scan_results = [] + self.scanned_dirs_cache = [] + self.scanned_dirs_attr.set_value("[]") + self.last_update = 0 + + def progress_callback(results, info): + scanned = info.get("scanned", 0) + phase = info.get("phase", "") + progress = info.get("progress", 0) + new_dirs = info.get("scanned_dirs", []) + + biased_progress = pow(progress / 100.0, 2.0)*100 + self.progress_attr.set_value(str(biased_progress)) + self.scanned_attr.set_value(str(scanned)) + + if new_dirs: + self.scanned_dirs_cache.extend(new_dirs) + import json + self.scanned_dirs_attr.set_value(json.dumps(self.scanned_dirs_cache)) + + if results and phase == "scanning": + self.pending_scan_results.extend(results) + now = time.time() + if now - self.last_update > 5: + self.last_update = now + with self.results_lock: + self.current_scan_results.extend(self.pending_scan_results) + self.apply_filters() + self.pending_scan_results = [] + + if phase == "complete": + self.searching_attr.set_value(False) + + try: + results = self.scanner.scan(start_path, callback=progress_callback) + + if self.cancel_search: + return + + with self.results_lock: + self.current_scan_results = results + self.apply_filters() + + print(f"Search finished, found {len(results)} items") + + except Exception as e: + print(f"Search error: {e}") + import traceback + traceback.print_exc() + finally: + self.searching_attr.set_value(False) + + def compute_completions(self, partial_path): + """Minimal logic to find subdirectories matching partial path.""" + try: + # If empty, do nothing + if not partial_path: + self.completions_attr.set_value("[]") + return + + # Determine directory to scan + # Handle absolute paths vs relative correctly + if partial_path.endswith(os.path.sep): + directory = partial_path + base = "" + else: + directory = os.path.dirname(partial_path) + base = os.path.basename(partial_path) + + # If directory part is empty (e.g. user typed "home") + if not directory: + directory = "." + + if not os.path.exists(directory) or not os.path.isdir(directory): + self.completions_attr.set_value("[]") + return + + candidates = [] + try: + with os.scandir(directory) as it: + for entry in it: + if entry.name in self.ignore_dirs or entry.name.startswith('.'): + continue + + if entry.is_dir(): + # Filter by base case-insensitive + if entry.name.lower().startswith(base.lower()): + candidates.append(entry.path + os.path.sep) + except OSError: + pass + + # Sort and limit + candidates.sort() + self.completions_attr.set_value(json.dumps(candidates[:20])) + + except Exception as e: + print(f"Completion error: {e}") + self.completions_attr.set_value("[]") + + + + def load_config(self): + """Load configuration from config.json in the plugin directory.""" + config_path = os.path.join(os.path.dirname(__file__), "config.json") + default_config = { + "extensions": [".mov", ".mp4", ".mkv", ".exr", ".jpg", ".jpeg", ".png", + ".dpx", ".tiff", ".tif", ".wav", ".mp3"], + "ignore_dirs": [".git", ".quarantine", "eryx_unreal_plugin", ".DS_Store"], + "root_ignore_dirs": [], + "max_recursion_depth": 0, + "auto_scan_threshold": 4 + } + + if os.path.exists(config_path): + try: + with open(config_path, 'r') as f: + loaded_config = json.load(f) + # Merge with defaults + for key, value in loaded_config.items(): + default_config[key] = value + print(f"FilesystemBrowser: Loaded config from {config_path}") + except Exception as e: + print(f"FilesystemBrowser: Error loading config: {e}") + + return default_config + + def _get_subdirs(self, path): + """Fetch subdirectories for the given path and update attribute.""" + _dbg(f"_get_subdirs called for path='{path}' (type={type(path).__name__})") + print(f"FilesystemBrowser: _get_subdirs called for {path}") + result = {"path": path, "dirs": []} + + # On Windows, the virtual root "/" should list available drive letters + if sys.platform == "win32" and (path == "/" or path == "\\"): + import string + dirs = [] + for letter in string.ascii_uppercase: + drive = f"{letter}:\\" + if os.path.exists(drive): + dirs.append({"name": f"{letter}:", "path": drive.replace("\\", "/")}) + result["dirs"] = dirs + result["timestamp"] = time.time() + self.directory_query_result.set_value(json.dumps(result)) + return + + try: + if os.path.exists(path) and os.path.isdir(path): + dirs = [] + with os.scandir(path) as it: + for entry in it: + # Check ignore dirs (names) + if entry.name in self.ignore_dirs or entry.name.startswith('.'): + continue + + # Check root ignore dirs (paths, normalized) + if os.path.normpath(entry.path) in self.root_ignore_dirs: + continue + + if entry.is_dir(): + dirs.append({ + "name": entry.name, + "path": entry.path.replace("\\", "/") + }) + # Sort alphabetically + dirs.sort(key=lambda x: x["name"].lower()) + result["dirs"] = dirs + print(f"FilesystemBrowser: Found {len(dirs)} subdirs in {path}") + except Exception as e: + print(f"Error getting subdirs for {path}: {e}") + + result["timestamp"] = time.time() + self.directory_query_result.set_value(json.dumps(result)) + + def load_file(self, path): + """Logic to load file into xstudio.""" + # Handle directory navigation + if os.path.isdir(path): + self.current_path_attr.set_value(path) + self._add_to_history(path) + self.start_search(path) + return + + try: + valid_playlist = None + + # 1. Try Selected Containers + try: + selection = self.connection.api.session.selected_containers + for item in selection: + if hasattr(item, 'add_media') and item.name != "Preview": + valid_playlist = item + self.last_used_playlist_uuid = item.uuid + print(f"Targeting Selected Playlist: {item.name}") + break + except Exception: + pass + + # 2. Try Cached Playlist (Sticky) + if not valid_playlist and hasattr(self, 'last_used_playlist_uuid'): + try: + target_uuid_str = str(self.last_used_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == target_uuid_str and p.name != "Preview": + valid_playlist = p + print(f"Targeting Cached Playlist: {p.name}") + break + except: + pass + + # 3. Try Viewed Container + if not valid_playlist: + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + valid_playlist = viewed + self.last_used_playlist_uuid = viewed.uuid + print(f"Targeting Viewed Playlist: {viewed.name}") + except Exception: + pass + + # 4. Fallback to first non-preview playlist + if not valid_playlist: + playlists = [p for p in self.connection.api.session.playlists if p.name != "Preview"] + if playlists: + valid_playlist = playlists[0] + # print(f"Targeting First Playlist (Fallback): {valid_playlist.name}") + else: + self.connection.api.session.create_playlist("Filesystem Import") + valid_playlist = [p for p in self.connection.api.session.playlists if p.name != "Preview"][0] + # Update cache to this fallback + self.last_used_playlist_uuid = valid_playlist.uuid + + # If we were in preview mode, switch back to the original playlist + if self.preview_playlist_uuid is not None: + if self.original_playlist_uuid is not None: + orig_uuid_str = str(self.original_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == orig_uuid_str: + valid_playlist = p + print(f"Restoring to original playlist from preview: {p.name}") + break + + # Capture the preview uuid to delete later + self.pending_preview_deletion_uuid = self.preview_playlist_uuid + + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + + playlist = valid_playlist + + # --- Duplicate Check Logic: Local Cache --- + if not hasattr(self, 'playlist_path_cache'): + self.playlist_path_cache = {} # Dict[uuid_str, set(paths)] + + pl_uuid = str(playlist.uuid) + if pl_uuid not in self.playlist_path_cache: + self.playlist_path_cache[pl_uuid] = set() + + # Check if media already exists in playlist + existing_media = None + try: + # Force refresh of media list?? No direct method, accessing .media should request it. + current_media_list = playlist.media + + # Normalize input path: absolute + normpath + tgt_path = os.path.normpath(os.path.abspath(path)) + + print(f"Checking for duplicates of: {tgt_path}") + + for m in current_media_list: + try: + ms = m.media_source() + mr = ms.media_reference + if mr: + # URI path might include file:// scheme or be absolute + u = mr.uri() + mp = u.path() + if mp: + # Also abspath/normpath the existing media path + mp_norm = os.path.normpath(os.path.abspath(mp)) + # print(f" Existing: {mp_norm}") + if mp_norm == tgt_path: + existing_media = m + print(" -> Match found!") + break + except: + continue + except Exception as e: + print(f"Dup check error: {e}") + + + if existing_media: + media = existing_media + print(f"Media already exists: {path}") + elif tgt_path in self.playlist_path_cache[pl_uuid]: + # In cache but not in media list yet (pending) + print(f"Skipping duplicate (pending load): {path}") + return + else: + # --- Sequence Handling --- + loaded_as_sequence = False + if fileseq_available: + try: + seq = fileseq.FileSequence(path) + if len(seq) > 1: + # It's a sequence! + # Construct xstudio-compatible sequence string with Explicit Range: + # /path/to/prefix_{:04d}.ext=1001-1050 + + dirname = seq.dirname() + basename = seq.basename() # e.g. 'shot_' or 'shot.' + + # Calculate padding width from '####' or '@@@@@' + pad_str = seq.padding() + if pad_str == '#': + pad_len = 4 + else: + pad_len = len(pad_str) if pad_str else 0 + + # Construct brace pattern e.g. {:04d} + # If no padding, just empty brace? No, xstudio expects {:0Nd} usually. + # But fileseq handling > 1 implies padding. + + brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "" + + frames = str(seq.frameSet()) # e.g. 1001-1050 + ext = seq.extension() # e.g. .exr + + # Normalize basename: sometimes fileseq puts the whole thing in basename. + # But typical usage: dirname + basename + padded_part + ext + + # Construct the special path for xstudio parsing + # IMPORTANT: xstudio regex expects: ^(.*\{.+\}.*?)(=([-0-9x,]+))?$ + # So we put the brace pattern in the path, and the range at end. + + seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" + + # playlist.add_media(path) calls parse_posix_path internally + # which handles this pattern. + media = playlist.add_media(seq_path) + loaded_as_sequence = True + + except Exception as e: + print(f"Sequence load error: {e}") + + if not loaded_as_sequence: + media = playlist.add_media(path) + print(f"Loaded File: {path}") + else: + print(f"Loaded Sequence: {seq_path}") + # Add to cache immediately + self.playlist_path_cache[pl_uuid].add(tgt_path) + + # Force the viewport to display the playlist (parent of the media) + # We can't set the media directly as source if we want to use the playlist's playhead logic effectively + # (and avoid "create_playhead_atom" errors on MediaActor). + self.connection.api.session.set_on_screen_source(playlist) + + # also try setting the selected/viewed container to force UI update + try: + self.connection.api.session.viewed_container = playlist + except: + pass + + # Select the media in the playlist's playhead selection + # This ensures the playhead jumps to/plays this specific media + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection([media.uuid]) + + # Start playback + try: + # Use the playlist's playhead to control playback + playlist.playhead.playing = True + except: + pass + + # Final cleanup of the Preview playlist if we have one pending + if hasattr(self, 'pending_preview_deletion_uuid') and self.pending_preview_deletion_uuid: + try: + prev_uuid = self.pending_preview_deletion_uuid + self.pending_preview_deletion_uuid = None + + _dbg(f"Attempting to delete Preview playlist node for actor: {prev_uuid}") + + # We need the tree node UUID, not the actor UUID + tree = self.connection.api.session.playlist_tree + cuuid = self._find_container_uuid(tree, prev_uuid) + + if cuuid: + _dbg(f"Found tree node UUID: {cuuid}, calling remove_container") + res = self.connection.api.session.remove_container(cuuid) + _dbg(f"Deletion result: {res}") + print(f"FilesystemBrowser: Deleted Preview playlist (Node: {cuuid})") + else: + _dbg(f"Could not find tree node UUID for {prev_uuid}") + # Fallback to old method just in case, though likely to fail + for p in self.connection.api.session.playlists: + if str(p.uuid) == str(prev_uuid): + self.connection.api.session.remove_container(p) + break + except Exception as e: + _dbg(f"Final cleanup error: {e}") + print(f"Error in final preview cleanup: {e}") + + except Exception as e: + print(f"Error loading file: {e}") + import traceback + traceback.print_exc() + + + + def apply_filters(self): + """Re-run filtering logic on the current results cache.""" + try: + with self.results_lock: + results = list(self.current_scan_results) + + # Offload heavy filtering if list is huge? + # For now, do it in main thread or worker? + # Safe to do in main thread if count < 100k? + # Better to spawn a thread if we want UI responsiveness. + + # Doing it synchronously for now, but catching errors + self._apply_filters_logic(results) + except Exception as e: + print(f"Error applying filters: {e}") + + def _apply_filters_logic(self, results): + import os + # Use cached values if available (from worker), else fetch live (UI update) + if hasattr(self, 'cached_filter_time'): + filter_time = self.cached_filter_time + else: + filter_time = self.filter_time_attr.value() if hasattr(self, 'filter_time_attr') else "Any" + + if hasattr(self, 'cached_filter_version'): + filter_version = self.cached_filter_version + else: + filter_version = self.filter_version_attr.value() if hasattr(self, 'filter_version_attr') else "All Versions" + + print(f"Applying filters: Time={filter_time}, Version={filter_version}, Count={len(results)}") + + # Separate directories and files + dirs = [] + files = [] + for r in results: + if r.get("is_folder") or r.get("type") == "Folder": + dirs.append(r) + else: + files.append(r) + + # 1. Apply Time Filter (to files only?) + # User wants to see directories "even if there isnt data in them". + # So we probably shouldn't filter directories by time unless requested. + # Let's Apply Time Filter ONLY to files for now. + if filter_time != "Any": + now = time.time() + cutoff = 0 + if filter_time == "Last 1 day": + cutoff = now - 86400 + elif filter_time == "Last 2 days": + cutoff = now - 2 * 86400 + elif filter_time == "Last 1 week": + cutoff = now - 7 * 86400 + elif filter_time == "Last 1 month": + cutoff = now - 30 * 86400 + + if cutoff > 0: + files = [r for r in files if r.get("date", 0) >= cutoff] + + # 2. Apply Version Filter with Grouping (Files only) + grouped_results = {} + for r in files: + grp = r.get("version_group") + if grp: + grouped_results.setdefault(grp, []).append(r) + else: + grouped_results.setdefault(id(r), [r]) + + filtered_files = [] + + for grp, items in grouped_results.items(): + if len(items) <= 1: + filtered_files.extend(items) + continue + + items.sort(key=lambda x: x.get("version", 0), reverse=True) + + if filter_version == "Latest Version": + filtered_files.extend(items[:1]) + elif filter_version == "Latest 2 Versions": + filtered_files.extend(items[:2]) + else: + filtered_files.extend(items) + + + # Combine: Keep all discovered directories to facilitate browsing, + # and combine with filtered files. + final_results = dirs + filtered_files + + # Resort by name for display + final_results.sort(key=lambda x: x["name"]) + + # Serialize + json_str = json.dumps(final_results) + + self.files_attr.set_value(json_str) + + def _on_filter_changed(self, attribute, role): + from xstudio.core import AttributeRole + if role == AttributeRole.Value: + # Re-apply filters on cached results + threading.Thread(target=self.apply_filters).start() + + + def _replace_current_media(self, path): + try: + print(f"Replacing current media with: {path}") + # 1. Identify valid playlist (use same logic as load_file or simplify) + # For replace, we usually mean the "active" playlist/viewed one. + playlist = None + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media'): + playlist = viewed + except: + pass + + if not playlist: + # Fallback to selection + try: + selection = self.connection.api.session.selected_containers + if selection and hasattr(selection[0], 'add_media'): + playlist = selection[0] + except: + pass + + if not playlist: + print("No active playlist found for replace.") + return + + self.connection.api.session.set_on_screen_source(playlist) + + # 2. Add new media + # Use same helpers as load_file for sequences? + # Ideally load_file should be refactored to return the media object. + # For now, duplicate simple add logic or internal helper. + # Let's use simple add for now to save complexity, or better, + # we need sequence logic. + # Refactor load_file is risky mid-flight. + # I will assume path is safe or reuse the sequence logic block? + # Let's extract sequence loading to a helper `_add_media_to_playlist(playlist, path)` + + new_media = self._add_media_to_playlist(playlist, path) + if not new_media: + return + + # 3. Find currently selected/playing components to remove + # We want to remove the item that playhead is focusing on? + # Or just the selection? + # "Replaces the media in the current viewport" implies the one being watched. + + items_to_remove = [] + if hasattr(playlist, 'playhead_selection'): + # Get what is currently selected/playing + # selected_sources returns list of Media objects + current_selection = playlist.playhead_selection.selected_sources + if current_selection: + items_to_remove = current_selection + + # 4. Select new media + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection([new_media.uuid]) + + # 5. Move new media to position of old media? + # playlist.move_media(new_media, before=old_media_uuid) + if items_to_remove: + # Move before the first removed item + try: + playlist.move_media(new_media, before=items_to_remove[0].uuid) + except Exception as e: + print(f"Move error: {e}") + + # 6. Remove old media + for m in items_to_remove: + try: + playlist.remove_media(m) + except Exception as e: + print(f"Remove error: {e}") + + # 7. Play + if hasattr(playlist, 'playhead'): + playlist.playhead.playing = True + + except Exception as e: + print(f"Replace error: {e}") + import traceback + traceback.print_exc() + + def _compare_with_current_media(self, path): + try: + print(f"Comparing current media with: {path}") + # 1. Identify valid playlist + playlist = None + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media'): + playlist = viewed + except: + pass + + if not playlist: + print("No active playlist found for compare.") + return + + self.connection.api.session.set_on_screen_source(playlist) + + # 2. Add new media + new_media = self._add_media_to_playlist(playlist, path) + if not new_media: + return + + # 3. Get current selection and append new media + new_selection = [] + if hasattr(playlist, 'playhead_selection'): + current_m = playlist.playhead_selection.selected_sources + for m in current_m: + new_selection.append(m.uuid) + + new_selection.append(new_media.uuid) + + # 4. Set selection + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection(new_selection) + + # 5. Set Compare Mode + if hasattr(playlist, 'playhead'): + # Check for AB mode availability? + # Assuming "A/B" string is correct based on other plugins/docs + playlist.playhead.compare_mode = "A/B" + playlist.playhead.playing = True + + except Exception as e: + print(f"Compare error: {e}") + import traceback + traceback.print_exc() + + def _add_to_history(self, path): + try: + current_history = json.loads(self.history_attr.value()) + except: + current_history = [] + + # Remove if exists to bubble to top + try: + current_history.remove(path) + except ValueError: + pass + + current_history.insert(0, path) + # Limit history + if len(current_history) > 20: + current_history = current_history[:20] + + self.history_attr.set_value(json.dumps(current_history)) + + def _add_pin(self, name, path): + try: + pins = json.loads(self.pinned_attr.value()) + except: + pins = [] + + # Check if already pinned + for p in pins: + if p["path"] == path: + return # Already pinned + + pins.append({"name": name, "path": path}) + self.pinned_attr.set_value(json.dumps(pins)) + + def _remove_pin(self, path): + try: + pins = json.loads(self.pinned_attr.value()) + except: + pins = [] + + new_pins = [p for p in pins if p["path"] != path] + if len(new_pins) != len(pins): + self.pinned_attr.set_value(json.dumps(new_pins)) + + def _request_thumbnail(self, path): + """Queue an async thumbnail fetch if not already cached or pending.""" + if path in self._thumbnail_cache: + # Already done — push the cached URI back to UI immediately + self._update_file_thumbnail(path, self._thumbnail_cache[path]) + return + with self._thumb_lock: + if path not in self._thumb_pending: + self._thumb_pending.add(path) + self._thumb_queue.put(path) + + def _thumb_worker_loop(self): + """Daemon worker pulling thumbnail requests from the queue.""" + while True: + path = self._thumb_queue.get() + try: + self._generate_thumbnail(path) + except Exception as e: + _dbg(f"WORKER_ERR: {e}") + finally: + with self._thumb_lock: + self._thumb_pending.discard(path) + self._thumb_queue.task_done() + + def _resolve_sequence_frame(self, path): + """Given a fileseq path string, return (concrete_file_path, frame_number). + For single files, returns (path, 0).""" + if not fileseq_available: + return path, 0 + try: + seq = fileseq.FileSequence(path) + frames = list(seq.frameSet()) + if len(frames) > 1: + mid = frames[len(frames) // 2] + return seq.frame(mid), mid + elif len(frames) == 1: + return seq.frame(frames[0]), frames[0] + except Exception: + pass + return path, 0 + + def _generate_thumbnail(self, path): + """Generate a thumbnail JPEG using ffmpeg subprocess.""" + if not self._ffmpeg_bin: + _dbg(f"GEN_SKIP (no ffmpeg): {path}") + return + + target_file, _frame = self._resolve_sequence_frame(path) + _dbg(f"GEN_START: {path} -> {target_file}") + + if not os.path.exists(target_file): + _dbg(f"GEN_MISSING: {target_file}") + return + + out_file = os.path.join(self._temp_dir, f"{_uuid.uuid4().hex}.jpg") + + env = os.environ.copy() + if self._ffmpeg_dyld and sys.platform == "darwin": + existing = env.get("DYLD_LIBRARY_PATH", "") + env["DYLD_LIBRARY_PATH"] = ( + self._ffmpeg_dyld + (":" + existing if existing else "") + ) + + cmd = [ + self._ffmpeg_bin, + "-y", # overwrite output file + "-i", target_file, + "-vf", "scale=150:-1,format=rgb24", + "-frames:v", "1", + "-update", "1", # allow single-image output + out_file, + ] + + _dbg(f"GEN_CMD: {' '.join(cmd)}") + try: + result = subprocess.run( + cmd, env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=30 + ) + if result.returncode == 0 and os.path.exists(out_file): + thumb_uri = pathlib.Path(out_file).as_uri() + _dbg(f"GEN_OK: {thumb_uri}") + self._thumbnail_cache[path] = thumb_uri + self._update_file_thumbnail(path, thumb_uri) + else: + stderr = result.stderr.decode("utf-8", errors="replace")[-500:] + _dbg(f"GEN_FAIL (rc={result.returncode}): {stderr}") + except subprocess.TimeoutExpired: + _dbg(f"GEN_TIMEOUT: {target_file}") + except Exception as exc: + _dbg(f"GEN_EXCEPTION: {exc}") + + def _update_file_thumbnail(self, path, thumb_uri): + """Update thumbnailSource in current_scan_results and push to files_attr + WITHOUT calling apply_filters() to avoid a full QML model rebuild.""" + with self.results_lock: + found = False + for r in self.current_scan_results: + if r.get("path") == path: + if r.get("thumbnailSource") == thumb_uri: + return # Already up to date; don't trigger another rebuild + r["thumbnailSource"] = thumb_uri + found = True + break + if not found: + return + # Serialise only what QML needs — same JSON format as apply_filters + serialised = json.dumps(self.current_scan_results) + + # Push the update; QML will merge thumbnailSource via the Image.source binding + self.files_attr.set_value(serialised) + + + def _add_media_to_playlist(self, playlist, path): + """Helper to add media handling sequences.""" + import os + try: + tgt_path = os.path.normpath(os.path.abspath(path)) + + # Check for sequence + if fileseq_available: + try: + seq = fileseq.FileSequence(path) + if len(seq) > 1: + dirname = seq.dirname() + basename = seq.basename() + pad_str = seq.padding() + pad_len = len(pad_str) if pad_str else 0 + brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "" + frames = str(seq.frameSet()) + ext = seq.extension() + seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" + return playlist.add_media(seq_path) + except: + pass + + return playlist.add_media(path) + except Exception as e: + print(f"Add media error: {e}") + return None + + def _find_container_uuid(self, tree, target_value_uuid): + """Recursively find the tree node UUID for a given playlist actor UUID.""" + if hasattr(tree, 'value_uuid') and str(tree.value_uuid) == str(target_value_uuid): + return tree.uuid + if hasattr(tree, 'children'): + for child in tree.children: + res = self._find_container_uuid(child, target_value_uuid) + if res: + return res + return None + + def _preview_file(self, path): + """Load a file into the transient Preview playlist.""" + try: + print(f"FilesystemBrowser: Previewing {path}") + + # If we are not already in preview mode, capture the current playlist context + if self.preview_playlist_uuid is None: + self.original_playlist_uuid = None + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + self.original_playlist_uuid = viewed.uuid + print(f"FilesystemBrowser: Saving original playlist {viewed.name}") + except Exception as e: + print(f"FilesystemBrowser: Could not get viewed container: {e}") + + # Attempt to capture the exact frame number we are currently looking at + current_frame = None + try: + # Need to use viewport playhead or session playhead to find logical frame + # Or try the playlist's playhead + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'playhead'): + current_frame = viewed.playhead.position + print(f"FilesystemBrowser: Captured frame sync position: {current_frame}") + except Exception as e: + print(f"FilesystemBrowser: Could not capture playhead position: {e}") + + # Find or Create the 'Preview' playlist + preview_playlist = None + for p in self.connection.api.session.playlists: + if p.name == "Preview": + preview_playlist = p + break + + if not preview_playlist: + self.connection.api.session.create_playlist("Preview") + for p in self.connection.api.session.playlists: + if p.name == "Preview": + preview_playlist = p + break + + if not preview_playlist: + print("FilesystemBrowser: Could not create or find Preview playlist") + return + + self.preview_playlist_uuid = preview_playlist.uuid + + # Clear the remote preview playlist + for m in list(preview_playlist.media): + preview_playlist.remove_media(m) + + # Add the new media + media = self._add_media_to_playlist(preview_playlist, path) + if not media: + return + + # Force the viewport to display the preview playlist + self.connection.api.session.set_on_screen_source(preview_playlist) + + # also try setting the selected/viewed container to force UI update + try: + # XStudio python API may support setting viewed_container or selected_containers + # This ensures the session panel highlights the preview playlist + self.connection.api.session.viewed_container = preview_playlist + except: + pass + + # Select the media + if hasattr(preview_playlist, 'playhead_selection'): + preview_playlist.playhead_selection.set_selection([media.uuid]) + + # Restore the frame number if we have one + if hasattr(preview_playlist, 'playhead'): + if current_frame is not None: + try: + preview_playlist.playhead.position = current_frame + print(f"FilesystemBrowser: Restored frame position: {current_frame}") + except Exception as e: + print(f"FilesystemBrowser: Error restoring frame: {e}") + + # pause on load for preview + preview_playlist.playhead.playing = False + + except Exception as e: + print(f"FilesystemBrowser Preview error: {e}") + import traceback + traceback.print_exc() + +def create_plugin_instance(connection): + return FilesystemBrowserPlugin(connection) diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml new file mode 100644 index 000000000..d43372f5f --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -0,0 +1,525 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import xStudio 1.0 + +Rectangle { + id: treeRoot + color: "#222222" + + // Properties to communicate with parent + property var pluginData: null + property var currentPath: "/" + + signal sendCommand(var cmd) + + // Direct command channel — bypasses signal chain for reliability + XsAttributeValue { + id: tree_command_attr + attributeTitle: "command_channel" + model: pluginData + } + + function sendTreeCommand(cmd) { + console.log("DirectoryTree: sendTreeCommand: " + JSON.stringify(cmd)); + // Write directly to the attribute, bypassing signal chain + if (tree_command_attr.index && tree_command_attr.index.valid) { + tree_command_attr.value = JSON.stringify(cmd); + } else { + console.log("DirectoryTree: WARNING - command attr index not valid, falling back to signal"); + sendCommand(cmd); + } + } + + // Style constants to match FilesystemBrowser + property real rowHeight: 30 + property color textColor: "#e0e0e0" + property color hintColor: "#aaaaaa" + property real fontSize: 12 + property color selectionColor: "#555555" + property color hoverColor: "#333333" + property color backgroundColor: "#222222" + + // Auto-expand logic + property string pendingExpandPath: "" + property bool isSyncing: false + property int autoScanThreshold: 4 + + // Normalize path for consistent comparison (case-insensitive on Windows, strip trailing slash) + function normPath(p) { + if (!p) return ""; + var n = p.replace(/\\/g, "/"); + // Remove trailing slash unless it's just "/" + if (n.length > 1 && n.charAt(n.length - 1) === '/') { + n = n.substring(0, n.length - 1); + } + // Case-insensitive on Windows + if (Qt.platform.os === "windows") { + n = n.toLowerCase(); + } + return n; + } + + function getPathDepth(p) { + if (!p || p === "/") return 0; + var parts = p.split("/"); + var count = 0; + for(var i=0; i deepestLen || (np === "/" && deepestLen === 0)) { + deepestLen = np.length; + deepestIndex = i; + } + } + } + + if (deepestIndex !== -1) { + var node = treeModel.get(deepestIndex); + var nodePath = normPath(node.path); + + if (nodePath === pendingExpandPath) { + // We reached the target! + treeView.currentIndex = deepestIndex; + pendingExpandPath = ""; + isSyncing = false; + treeView.positionViewAtIndex(deepestIndex, ListView.Center); + if (!node.expanded) expandNode(deepestIndex); + } else { + if (!node.expanded) { + expandNode(deepestIndex); + // wait for handleQueryResult to call us back + } else { + if (node.isLoading) { + // Waiting for results + } else { + console.log("DirectoryTree: syncToPath stuck at " + nodePath + ", target=" + pendingExpandPath); + pendingExpandPath = ""; + isSyncing = false; + } + } + } + } else { + isSyncing = false; + } + } + + // Attribute for directory query results + XsAttributeValue { + id: dir_query_attr + attributeTitle: "directory_query_result" + model: pluginData + role: "value" + + onValueChanged: { + console.log("DirectoryTree: dir_query_attr changed. Value length: " + (value ? value.length : "null")); + try { + var val = value; + if (val && val !== "{}") { + var result = JSON.parse(val); + console.log("DirectoryTree: Parsed result for path: " + result.path + ", dirs: " + (result.dirs ? result.dirs.length : "0") + ", isSyncing: " + isSyncing); + handleQueryResult(result); + } + } catch(e) { + console.log("DirectoryTree: Query result parse error: " + e); + } + } + } + + // Tree Model + // We'll use a ListModel and manually manage hierarchical indentation + ListModel { + id: treeModel + } + + // Delay initial expand so parent signal connections are wired up first + Timer { + id: initTimer + interval: 500 + repeat: false + onTriggered: { + console.log("DirectoryTree: initTimer fired. cmdIndex valid=" + (tree_command_attr.index ? tree_command_attr.index.valid : "null")); + if (tree_command_attr.index && tree_command_attr.index.valid) { + expandNode(0); + // After root expands and results come back, sync to the current path + syncAfterInit = true; + } else { + console.log("DirectoryTree: command attr not ready yet, retrying in 500ms"); + initTimer.restart(); + } + } + } + + // Flag to trigger sync after the initial root expand completes + property bool syncAfterInit: false + + Component.onCompleted: { + var rootName = Qt.platform.os === "windows" ? "This PC" : "Root"; + treeModel.append({ + "name": rootName, + "path": "/", + "level": 0, + "expanded": false, + "hasChildren": true, + "isLoading": false + }); + initTimer.start(); + } + + function expandNode(index) { + var node = treeModel.get(index); + console.log("DirectoryTree: expandNode called for: " + node.path + ", expanded: " + node.expanded + ", isLoading: " + node.isLoading); + + // If already expanded and not loading, we might still need to load if it has no children but should + // However, for now let's just allow re-requesting if isLoading is false or if explicitly called when collapsed + if (node.expanded && !node.isLoading) { + // Check if children already exist + var nextIndex = index + 1; + if (nextIndex < treeModel.count) { + var next = treeModel.get(nextIndex); + if (next.level > node.level) { + console.log("DirectoryTree: Node already expanded with children."); + return; + } + } + // No children? Trigger load anyway + console.log("DirectoryTree: Node expanded but no children, re-requesting."); + } else if (node.expanded) { + return; + } + + treeModel.setProperty(index, "expanded", true); + + if (node.isLoading) { + console.log("DirectoryTree: Node is already loading, skipping command."); + return; + } + + treeModel.setProperty(index, "isLoading", true); + + // Request subdirs + sendTreeCommand({"action": "get_subdirs", "path": node.path}); + } + + function collapseNode(index) { + var node = treeModel.get(index); + console.log("DirectoryTree: collapseNode called for: " + node.path); + + treeModel.setProperty(index, "expanded", false); + treeModel.setProperty(index, "isLoading", false); // Important: stop loading if collapsed + + // Remove children from model + // We need to remove all items following this node that have a level > node.level + // AND stop when we hit a node with level <= node.level + var currentLevel = node.level; + var i = index + 1; + var count = 0; + + while (i < treeModel.count) { + var child = treeModel.get(i); + if (child.level > currentLevel) { + count++; + i++; + } else { + break; + } + } + + if (count > 0) { + treeModel.remove(index + 1, count); + } + } + + function handleQueryResult(result) { + var path = normPath(result.path); + var dirs = result.dirs; + + var foundIndex = -1; + for(var i=0; i parentLevel) { + console.log("DirectoryTree: Removing existing children before re-populating."); + collapseNode(foundIndex); + treeModel.setProperty(foundIndex, "expanded", true); + } + } + + // Insert children + console.log("DirectoryTree: Inserting " + dirs.length + " children for " + path); + for(var j=0; j { + sendTreeCommand({"action": "change_path", "path": model.path}); + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // Indentation + Item { + Layout.preferredWidth: model.level * 20 + 5 + Layout.fillHeight: true + } + + // Expander Arrow + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + + Text { + anchors.centerIn: parent + text: model.hasChildren ? (model.expanded ? "▼" : "▶") : "" + color: treeRoot.hintColor + font.pixelSize: 10 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (model.expanded) { + collapseNode(index); + } else { + expandNode(index); + } + } + } + } + + // Folder Icon + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + Text { + anchors.centerIn: parent + text: "📁" + font.pixelSize: treeRoot.fontSize + } + } + + // Name + Text { + text: model.name + color: treeRoot.textColor + font.pixelSize: treeRoot.fontSize + Layout.fillWidth: true + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 5 + } + + // Scan Button Container (Prevents layout jitter by taking space only if scan is possible) + Item { + Layout.preferredWidth: 56 + Layout.fillHeight: true + visible: treeRoot.getPathDepth(model.path) <= treeRoot.autoScanThreshold && !model.isLoading + + Rectangle { + anchors.centerIn: parent + visible: msgMouse.containsMouse || scanMouse.containsMouse + width: 46 + height: 18 + color: scanMouse.containsMouse ? "#2a2a2a" : "#1a1a1a" + radius: 4 + border.color: "#333333" + border.width: 1 + + Text { + anchors.centerIn: parent + text: "SCAN" + color: "#666666" + font.pixelSize: 8 + font.bold: true + } + + MouseArea { + id: scanMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + // Set path AND trigger scan in one atomic action + sendTreeCommand({"action": "force_scan", "path": model.path}) + } + } + + ToolTip.visible: scanMouse.containsMouse + ToolTip.text: "Force media scan in this folder" + ToolTip.delay: 500 + } + } + + // Loading Indicator + Text { + text: "..." + color: treeRoot.hintColor + visible: model.isLoading + Layout.rightMargin: 5 + } + } + + + } + + ScrollBar.vertical: ScrollBar { + active: true + policy: ScrollBar.AsNeeded + width: 10 + background: Rectangle { color: "#222222" } + contentItem: Rectangle { + implicitWidth: 6 + implicitHeight: 100 + radius: 3 + color: treeView.active ? "#555555" : "#333333" + } + } + } + } +} diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml new file mode 100644 index 000000000..944454b2b --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -0,0 +1,2298 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import Qt.labs.qmlmodels 1.0 +import QtQuick.Shapes 1.15 // Added for vector icon + +import xStudio 1.0 +import xstudio.qml.models 1.0 +import xStudio 1.0 + +Rectangle { + id: root + color: "#222222" + anchors.fill: parent // Ensure it fills the panel + + // Access the attributes exposed by the plugin + property string currentFilterTime: "Any" + property string currentFilterVersion: "All Versions" + + XsModuleData { + id: pluginData + modelDataName: "Filesystem Browser" + } + + // State for Preview Mode + property bool isPreviewMode: false + property string pendingPreviewPath: "" + + Timer { + id: previewTimer + interval: 200 // Wait for double click + repeat: false + onTriggered: { + if (pendingPreviewPath !== "") { + isPreviewMode = true + sendCommand({"action": "preview_file", "path": pendingPreviewPath}) + pendingPreviewPath = "" + } + } + } + + // Additional Attributes for History/Pins + XsAttributeValue { + id: history_attr + attributeTitle: "history_paths" + model: pluginData + role: "value" + + function updateList() { + var rawVal = value + try { + if (typeof(rawVal) === "string" && rawVal !== "") { + if (rawVal === "[]") { + historyList = [] + } else { + var parsed = JSON.parse(rawVal) + historyList = parsed + } + } else { + historyList = [] + } + } catch(e) { + console.log("history_attr: Parse Error: " + e) + historyList = [] + } + } + + onValueChanged: updateList() + Component.onCompleted: updateList() + } + + XsAttributeValue { + id: pinned_attr + attributeTitle: "pinned_paths" + model: pluginData + role: "value" + + function updateList() { + var rawVal = value + try { + if (typeof(rawVal) === "string" && rawVal !== "") { + if (rawVal === "[]") { + pinnedList = [] + } else { + var parsed = JSON.parse(rawVal) + pinnedList = parsed + } + } else { + pinnedList = [] + } + } catch(e) { + console.log("pinned_attr: Parse Error: " + e) + pinnedList = [] + } + } + + onValueChanged: updateList() + Component.onCompleted: updateList() + } + + property var historyList: [] + property var pinnedList: [] + property var combinedList: [] + + function updateCombinedList() { + var combined = [] + var seen = new Set() // Set of paths + + // 1. Add Pinned Items + if (pinnedList) { + for (var i = 0; i < pinnedList.length; i++) { + var p = pinnedList[i] + combined.push({ + "name": p.name, + "path": p.path, + "isPinned": true + }) + // Add to seen set (mock Set using object for ES5/QML compat if needed, but modern QML has Set) + // actually JS in QML usually has Set. If not, use object keys. + seen.add(p.path) + } + } + + // 2. Add History Items + if (historyList) { + for (var j = 0; j < historyList.length; j++) { + var h = historyList[j] + if (!seen.has(h)) { + // Determine name (basename) + var name = h + if (h && h.indexOf("/") !== -1) { + var parts = h.split("/") + // Handle trailing slash + var last = parts[parts.length-1] + if (!last && parts.length > 1) last = parts[parts.length-2] + if (last) name = last + } + + combined.push({ + "name": name, + "path": h, + "isPinned": false + }) + seen.add(h) + } + } + } + + combinedList = combined + } + + // Trigger update when source lists change + onHistoryListChanged: updateCombinedList() + onPinnedListChanged: updateCombinedList() + + property bool isCurrentPinned: { + var curr = current_path_attr.value + for(var i=0; i 0 && pathField.activeFocus) { + completionPopup.open() + } else { + completionPopup.close() + } + } catch(e) { + completionList = [] + } + } + } + } + + XsAttributeValue { + id: scan_required_attr + attributeTitle: "scan_required" + model: pluginData + role: "value" + } + + // Dedicated attribute for sending batch thumbnail requests to Python. + // We write a JSON array of paths; Python queues them all into the ffmpeg worker pool. + XsAttributeValue { + id: thumbnail_request_attr + attributeTitle: "thumbnail_request" + model: pluginData + role: "value" + } + + XsAttributeValue { + id: auto_scan_threshold_attr + attributeTitle: "auto_scan_threshold" + model: pluginData + role: "value" + } + + XsAttributeValue { + id: searching_attr + attributeTitle: "searching" + model: pluginData + role: "value" + } + + XsAttributeValue { + id: scanned_dirs_attr + attributeTitle: "scanned_dirs" + model: pluginData + role: "value" + onValueChanged: { + try { + var val = value + if (val && val !== "[]") { + scannedDirsList = JSON.parse(val) + } else { + scannedDirsList = [] + } + } catch(e) { } + } + } + + XsAttributeValue { + id: depth_limit_attr + attributeTitle: "recursion_limit" + model: pluginData + role: "value" + } + + property var scannedDirsList: [] + + function sendCommand(cmd) { + command_attr.value = JSON.stringify(cmd) + } + + // Local property to hold the parsed JSON file list + property var fileList: [] + onFileListChanged: buildTree() + property var completionList: [] + + // Sorting State + property string sortColumn: "name" + property int sortOrder: 1 // 1 for asc, -1 for desc + + // View Mode: 0=List, 1=Tree, 2=Grouped + property int viewMode: 2 + onViewModeChanged: buildTree() + + + // Column Widths (Default values) + property real minWidthName: 250 + property real colWidthName: 250 // kept for legacy reference or init + property real colWidthOwner: 80 + property real colWidthVersion: 60 + property real colWidthDate: 140 + property real colWidthSize: 80 + property real colWidthFrames: 120 + + // Width Calculations + readonly property real fixedColumnsWidth: colWidthVersion + colWidthOwner + colWidthDate + colWidthSize + colWidthFrames + 20 // +20 spacer + property real totalContentWidth: Math.max(fileListView.width, minWidthName + fixedColumnsWidth + 10) // +10 margin/padding + + + // tree logic + property var treeRoots: [] + property var visibleTreeList: [] + property var collapsedPaths: ({}) + // Flat list of *file* items only — used for thumbnail request calculations. + property var thumbnailFileList: [] + // Complete mixed flat model (all groups + files). Not assigned to Repeater directly. + property var fullFlatModel: [] + // Paginated slice of fullFlatModel actually shown in the Repeater. + property var flatThumbnailModel: [] + // How many items from fullFlatModel are currently rendered. + property int thumbRenderCount: 0 + readonly property int thumbPageSize: 150 // initial page + readonly property int thumbPageStep: 100 // items added per scroll-load + onThumbnailFileListChanged: { + if (root.viewMode === 3) + Qt.callLater(requestVisibleThumbnails) + } + // Re-request thumbnails when the rendered page extends + onFlatThumbnailModelChanged: { + if (root.viewMode === 3) + Qt.callLater(requestVisibleThumbnails) + } + + // Only request thumbnails for items currently visible in the Flow. + // Estimates Y positions mathematically from scroll position, cell size, and Flow width. + function requestVisibleThumbnails() { + if (root.viewMode !== 3 || flatThumbnailModel.length === 0) return + + var scrollY = thumbFlickable.contentY + var viewH = thumbFlickable.height + var cellW = 160 + var headerH = 24 + var cellH = 160 + var cols = Math.max(1, Math.floor(Math.max(1, thumbFlickable.width) / cellW)) + + // One cell row above/below as prefetch buffer + var topY = Math.max(0, scrollY - cellH) + var bottomY = scrollY + viewH + cellH + + var pending = [] + var y = 0 + var col = 0 + + for (var i = 0; i < flatThumbnailModel.length; i++) { + var item = flatThumbnailModel[i] + if (item.type === "header") { + if (col > 0) { y += cellH; col = 0 } + y += headerH + } else { + if (col >= cols) { col = 0; y += cellH } + if (y + cellH >= topY && y <= bottomY) { + if (!item.thumbnailSource) pending.push(item.path) + } + col++ + } + } + + if (pending.length > 0) { + console.log("QML: requesting " + pending.length + " visible thumbnails") + thumbnail_request_attr.value = JSON.stringify(pending) + } + } + + function isVisible(data) { + if (!data) return true; + + // Text Filter + var filterText = filterField.text.trim(); + if (filterText !== "") { + if (data.name.toLowerCase().indexOf(filterText.toLowerCase()) === -1) return false; + } + + // Time Filter + var t_val = currentFilterTime; + if (t_val !== "Any" && data.date) { + var now = Date.now() / 1000.0; + var diff = now - data.date; + var day = 86400; + var timeMatch = true; + if (t_val === "Last 1 day") timeMatch = diff <= day; + else if (t_val === "Last 2 days") timeMatch = diff <= 2*day; + else if (t_val === "Last 1 week") timeMatch = diff <= 7*day; + else if (t_val === "Last 1 month") timeMatch = diff <= 30*day; + + if (!timeMatch) return false; + } + + // Version Filter + var v_val = currentFilterVersion; + if (v_val === "Latest Version") { + if (data.is_latest_version !== true) return false; + } else if (v_val === "Latest 2 Versions") { + if (data.version_rank !== undefined && data.version_rank > 1) return false; + } + + return true; + } + + function updateTreeVisibility(nodes) { + var hasVisible = false; + for(var i=0; i 0) { + var rootAbs = current_path_attr.value || "" + + // Fast O(N·depth) group compression using cumulative descendant counts. + // For each file dir, walk UP until we find an ancestor with 2+ total + // descendant files. That ancestor becomes the group header. + + // 1. Accumulate file counts up the tree + var descCount = {} + for (var i = 0; i < thumbList.length; i++) { + var cursor = thumbList[i].folderGroup + while (cursor.length > rootAbs.length) { + descCount[cursor] = (descCount[cursor] || 0) + 1 + var sl = cursor.lastIndexOf("/") + cursor = sl > 0 ? cursor.substring(0, sl) : rootAbs + } + descCount[rootAbs] = (descCount[rootAbs] || 0) + 1 + } + + // 2. For each file, walk up from leaf to find lowest ancestor with >= 2 files + // (cache results to avoid redundant walks) + var groupCache = {} + for (var i = 0; i < thumbList.length; i++) { + var leaf = thumbList[i].folderGroup + if (groupCache[leaf] !== undefined) { + thumbList[i].folderGroup = groupCache[leaf] + continue + } + var d = leaf + while (d.length > rootAbs.length && (descCount[d] || 0) < 2) { + var sl = d.lastIndexOf("/") + d = sl > 0 ? d.substring(0, sl) : rootAbs + } + var grouped = (descCount[d] || 0) >= 2 ? d : rootAbs + groupCache[leaf] = grouped + thumbList[i].folderGroup = grouped + } + } + + // Sort by group then name + thumbList.sort(function(a, b) { + if (a.folderGroup < b.folderGroup) return -1 + if (a.folderGroup > b.folderGroup) return 1 + return a.name < b.name ? -1 : 1 + }) + thumbnailFileList = thumbList + + // Build complete flat mixed model + var flat = [] + var prevGrp = null + for (var j = 0; j < thumbList.length; j++) { + var t = thumbList[j] + if (t.folderGroup !== prevGrp) { + flat.push({ type: "header", path: t.folderGroup }) + prevGrp = t.folderGroup + } + flat.push({ type: "file", name: t.name, path: t.path, + frames: t.frames, thumbnailSource: t.thumbnailSource || "", data: t.data }) + } + + // Paginate: only render the first page to avoid freezing on large dirs + fullFlatModel = flat + thumbRenderCount = Math.min(thumbPageSize, flat.length) + flatThumbnailModel = flat.slice(0, thumbRenderCount) + return + } + + // TREE / GROUPED VIEW + var lookups = {} + + function getFolderNode(path, name, parent) { + if (lookups[path]) return lookups[path]; + var node = { + "name": name, + "path": path, + "isFolder": true, + "children": [], + "data": null, + "expanded": (collapsedPaths[path] === undefined), + "visible": true + } + lookups[path] = node + if (parent) parent.children.push(node); + else roots.push(node); + return node + } + + var rootAbs = current_path_attr.value || "" + if (rootAbs !== "" && rootAbs.charAt(rootAbs.length-1) !== '/') rootAbs += '/' + + for(var i=0; i 0) compressNodes(node.children); + + while (node.children.length === 1) { + var child = node.children[0]; + node.name = node.name + "/" + child.name; + node.path = child.path; + node.data = child.data; + node.isFolder = child.isFolder; + node.children = child.children; + + if (node.isFolder) { + node.expanded = (collapsedPaths[node.path] === undefined); + } else { + node.expanded = false; + } + } + } + } + } + + // Only compress if in Grouped mode (2) + if (viewMode === 2) { + compressNodes(roots) + } + + treeRoots = roots + refreshFiltering() // Calculate visibility and flatten + sortTree() + } + + function sortTree() { + var col = sortColumn + var ord = sortOrder + + function recursiveSort(nodes) { + nodes.sort(function(a, b) { + if (a.isFolder !== b.isFolder) return (a.isFolder ? -1 : 1); + + if (a.isFolder) return a.name.localeCompare(b.name); + + var valA = a.data ? a.data[col] : "" + var valB = b.data ? b.data[col] : "" + + if (col === "size_str") { + var nA = parseFloat(valA) || 0 + var nB = parseFloat(valB) || 0 + return (nA - nB) * ord + } + if (col === "date" || col === "version" || col === "frames") { + return ((a.data ? (a.data[col]||0) : 0) - (b.data ? (b.data[col]||0) : 0)) * ord + } + + var sA = String(valA).toLowerCase() + var sB = String(valB).toLowerCase() + if (sA < sB) return -1 * ord + if (sA > sB) return 1 * ord + return 0 + }) + + for(var i=0; i 0) recursiveSort(nodes[i].children) + } + } + recursiveSort(treeRoots) + flattenTree() + } + + function flattenTree() { + var visible = [] + function traverse(nodes, depth) { + for(var i=0; i root.sendCommand(cmd) + + property int autoScanThreshold: auto_scan_threshold_attr.value || 4 + } + } + } + + // Main Content Side + ColumnLayout { + SplitView.fillWidth: true + anchors.margins: 10 + spacing: 5 + + // Path Input Row + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + spacing: 5 + + // Up Directory Button + Rectangle { + Layout.preferredWidth: 28 + Layout.preferredHeight: rowHeight + color: upMouse.containsMouse ? "#444444" : "#333333" + radius: 3 + border.color: "#555555" + border.width: 1 + + Text { + anchors.centerIn: parent + text: "▲" + color: "#cccccc" + font.pixelSize: 12 + } + + MouseArea { + id: upMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + var cur = current_path_attr.value || "/"; + cur = cur.replace(/\\/g, "/"); + // Strip trailing slash (but not for drive roots like "X:/") + if (cur.length > 1 && cur.charAt(cur.length - 1) === '/' && cur.charAt(cur.length - 2) !== ':') { + cur = cur.substring(0, cur.length - 1); + } + var lastSlash = cur.lastIndexOf("/"); + if (lastSlash > 0) { + var parent_path = cur.substring(0, lastSlash); + // If we'd end up at just "X:", add the slash back + if (parent_path.length === 2 && parent_path.charAt(1) === ':') { + parent_path += "/"; + } + sendCommand({"action": "change_path", "path": parent_path}); + } else if (lastSlash === 0) { + // We're at a top-level Unix path, go to root + sendCommand({"action": "change_path", "path": "/"}); + } else { + // Drive root like "X:/" — go to virtual root + sendCommand({"action": "change_path", "path": "/"}); + } + } + } + + ToolTip.visible: upMouse.containsMouse + ToolTip.text: "Go up one directory" + ToolTip.delay: 500 + } + + Text { + text: "Path:" + color: hintColor + verticalAlignment: Text.AlignVCenter + } + + TextField { + id: pathField + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + text: current_path_attr.value || "/" + onTextChanged: { + // This ensures that even if user is typing, a programmatic update + // to current_path_attr.value (e.g. from SCAN button) can force a refresh if needed. + // However, standard QML binding `text: ...` usually breaks if user edits. + // We'll add a listener to the attribute to force it back if it changes externally. + } + + Connections { + target: current_path_attr + function onValueChanged() { + pathField.text = current_path_attr.value || "/" + } + } + + background: Rectangle { + color: "#333333" + border.color: "#555555" + border.width: 1 + } + focus: true + + onAccepted: { + sendCommand({"action": "change_path", "path": text}) + } + + onTextEdited: { + sendCommand({"action": "complete_path", "path": text}) + } + + // Keys handling for completion (omitted for brevity, assume similar to before) + // Keys handling for completion + Keys.priority: Keys.BeforeItem + Keys.onPressed: (event) => { + // TAB + if (event.key === Qt.Key_Tab) { + event.accepted = true; + + var hasCompleted = false; + + // 1. Try Single Match or Common Prefix Completion first + if (completionList.length > 0) { + var prefix = getCommonPrefix(completionList); + // If we have a single match, prefix is the match itself. + + // If the calculated prefix is longer than what we currently have, utilize it. + // This covers both "Single Match" and "Partial Shell Completion" + if (prefix.length > text.length) { + text = prefix; + hasCompleted = true; + sendCommand({"action": "complete_path", "path": text}); + } + } + + // 2. If we didn't extend the text (ambiguous state), then Cycle through the list + if (!hasCompleted && completionPopup.opened && completionListView.count > 0) { + if (event.modifiers & Qt.ShiftModifier) { + completionListView.currentIndex = (completionListView.currentIndex - 1 + completionListView.count) % completionListView.count; + } else { + completionListView.currentIndex = (completionListView.currentIndex + 1) % completionListView.count; + } + } + } + // UP / DOWN + else if (event.key === Qt.Key_Up) { + if (completionPopup.opened && completionList.length > 0) { + event.accepted = true; + completionListView.decrementCurrentIndex(); + } + } + else if (event.key === Qt.Key_Down) { + if (completionPopup.opened && completionList.length > 0) { + event.accepted = true; + completionListView.incrementCurrentIndex(); + } + } + // RIGHT + else if (event.key === Qt.Key_Right) { + if (completionPopup.opened && completionListView.currentItem) { + // Drill Down + event.accepted = true; + text = completionList[completionListView.currentIndex]; + // Reset selection + completionListView.currentIndex = 0; + } + } + // ENTER / RETURN + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + event.accepted = true; + // Always submit the current text, regardless of completion popup + sendCommand({"action": "change_path", "path": text}); + completionPopup.close(); + } + // ESC + else if (event.key === Qt.Key_Escape) { + event.accepted = true; + completionPopup.close(); + } + // CTRL+BACKSPACE + else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier || event.modifiers & Qt.AltModifier) { + event.accepted = true; + // Directory Delete + var txt = text; + if (txt.endsWith("/")) txt = txt.slice(0, -1); + var lastSlash = txt.lastIndexOf("/"); + if (lastSlash !== -1) { + text = txt.substring(0, lastSlash + 1); + } else { + text = ""; + } + } + } + } + + // Keep completion popup + Popup { + id: completionPopup + width: parent.width + height: 200 + y: parent.height + 2 // Offset slightly + background: Rectangle { color: "#333333"; border.color: "#555555" } + contentItem: ListView { + id: completionListView + model: completionList + clip: true + highlight: Rectangle { color: "#444444" } + highlightMoveDuration: 0 + delegate: Item { + width: parent.width + height: 25 + Rectangle { anchors.fill: parent; color: "transparent" } + Text { + text: modelData + color: "#cccccc" + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + font.pixelSize: fontSize + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + pathField.text = modelData + completionPopup.close() + pathField.forceActiveFocus() + sendCommand({"action": "complete_path", "path": pathField.text}) + } + } + } + } + } + } + + Button { + id: refreshBtn + Layout.preferredHeight: rowHeight + Layout.preferredWidth: rowHeight + text: "↻" + font.pixelSize: 16 + flat: true + onClicked: sendCommand({"action": "force_scan"}) + ToolTip.visible: hovered + ToolTip.text: "Refresh directory scan" + + background: Rectangle { + color: parent.down ? "#222222" : (parent.hovered ? "#444444" : "transparent") + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + Button { + id: historyBtn + Layout.preferredHeight: rowHeight + Layout.preferredWidth: rowHeight + + // Using a down arrow character for simplicity if icon not available, + // but user asked for "Down Triangle". + text: "▼" + font.pixelSize: 10 + + contentItem: Text { + text: parent.text + font: parent.font + color: "#e0e0e0" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.down || pathPopup.opened ? "#222222" : (parent.hovered ? "#444444" : "transparent") + border.width: 0 + } + + property var lastCloseTime: 0 + + onClicked: { + var timeSinceClose = Date.now() - lastCloseTime + if (timeSinceClose > 100) { + pathPopup.open() + } + } + + Popup { + id: pathPopup + y: parent.height + x: parent.width - width // Right align with button + width: 500 + height: 300 + padding: 0 + + onClosed: { + historyBtn.lastCloseTime = Date.now() + } + + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: "#2a2a2a" + border.color: "#555555" + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 1 + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 25 + color: "#333333" + Label { + text: "QUICK ACCESS" + color: "#aaaaaa" + font.pixelSize: 10 + anchors.centerIn: parent + } + } + + ListView { + id: combinedView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: combinedList + + delegate: Rectangle { + width: ListView.view.width + height: 25 + color: mouseArea.containsMouse ? "#444444" : "transparent" + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + sendCommand({"action": "change_path", "path": modelData.path}) + pathPopup.close() + } + } + + RowLayout { + anchors.fill: parent + spacing: 5 + + // Pin Toggle Button + Button { + Layout.preferredWidth: 25 + Layout.preferredHeight: 25 + + background: Rectangle { + color: "transparent" + } + + contentItem: Shape { + anchors.centerIn: parent + width: 14 + height: 14 + + // Scale the 24x24 SVG path to our 14x14 box + scale: 14/24.0 + transformOrigin: Item.Center + + ShapePath { + strokeWidth: 0 + strokeColor: "transparent" + fillColor: modelData.isPinned ? "#ffffff" : "#444444" + + // M16 12V4h1V2H7v2h1v8l-2 5v2h6v3.5l1 1 1-1V19h6v-2l-2-5z (Standard Pin) + // Coordinate system is roughly 24x24 + PathSvg { + path: "M16 12V4h1V2H7v2h1v8l-2 5v2h6v3.5l1 1 1-1V19h6v-2l-2-5z" + } + } + } + + onClicked: { + if (modelData.isPinned) { + sendCommand({"action": "remove_pin", "path": modelData.path}) + } else { + sendCommand({"action": "add_pin", "name": modelData.name, "path": modelData.path}) + } + } + } + + // Path Name + Text { + text: modelData.name + color: "#e0e0e0" + font.pixelSize: 11 + Layout.fillWidth: true + elide: Text.ElideMiddle + verticalAlignment: Text.AlignVCenter + } + + // Path Hint (Right aligned, faded) + Text { + text: modelData.path + color: "#666666" + font.pixelSize: 9 + Layout.preferredWidth: parent.width * 0.4 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + visible: parent.width > 300 + } + + Item { Layout.preferredWidth: 5 } + } + } + ScrollBar.vertical: ScrollBar {} + } + } + } + } + } + + + + // Filter Row + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + spacing: 5 + + ComboBox { + id: filterTimeCombo + model: ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"] + Layout.preferredWidth: 120 + Layout.preferredHeight: rowHeight + currentIndex: model.indexOf(currentFilterTime) + onActivated: { + console.log("Time Filter Changed to: " + currentText) + // Send command to update backend + sendCommand({"action": "set_attribute", "name": "filter_time", "value": currentText}) + // Optimistically update local state (backend update will confirm it via onValueChanged) + currentFilterTime = currentText + fileListView.forceLayout() + } + delegate: ItemDelegate { + width: ListView.view.width + contentItem: Text { + text: modelData + color: "#e0e0e0" + font.pixelSize: fontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.highlighted ? "#444444" : "#222222" + } + highlighted: filterTimeCombo.highlightedIndex === index + } + + popup: Popup { + y: parent.height - 1 + width: parent.width + implicitHeight: contentItem.implicitHeight + padding: 1 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: filterTimeCombo.popup.visible ? filterTimeCombo.delegateModel : null + currentIndex: filterTimeCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + border.color: "#555555" + color: "#222222" + } + } + } + + ComboBox { + id: filterVersionCombo + model: ["All Versions", "Latest Version", "Latest 2 Versions"] + Layout.preferredWidth: 140 + Layout.preferredHeight: rowHeight + currentIndex: model.indexOf(currentFilterVersion) + onActivated: { + sendCommand({"action": "set_attribute", "name": "filter_version", "value": currentText}) + currentFilterVersion = currentText + fileListView.forceLayout() + } + delegate: ItemDelegate { + width: ListView.view.width + contentItem: Text { + text: modelData + color: "#e0e0e0" + font.pixelSize: fontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.highlighted ? "#444444" : "#222222" + } + highlighted: filterVersionCombo.highlightedIndex === index + } + + popup: Popup { + y: parent.height - 1 + width: parent.width + implicitHeight: contentItem.implicitHeight + padding: 1 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: filterVersionCombo.popup.visible ? filterVersionCombo.delegateModel : null + currentIndex: filterVersionCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + border.color: "#555555" + color: "#222222" + } + } + } + + // recursion limit + RowLayout { + spacing: 5 + Label { + text: "Depth:" + color: "#aaaaaa" + font.pixelSize: fontSize + verticalAlignment: Text.AlignVCenter + } + SpinBox { + id: depthSpin + from: 0 + to: 10 + value: depth_limit_attr.value !== undefined ? parseInt(depth_limit_attr.value) : 0 + editable: true + Layout.preferredWidth: 80 + Layout.preferredHeight: rowHeight + + onValueModified: { + sendCommand({"action": "set_attribute", "name": "recursion_limit", "value": value}) + // Optimistic update + depth_limit_attr.value = value + } + + // Customizing background to match dark theme + contentItem: TextInput { + z: 2 + text: depthSpin.textFromValue(depthSpin.value, depthSpin.locale) + font: depthSpin.font + color: "#e0e0e0" + selectionColor: "#21be2b" + selectedTextColor: "#ffffff" + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + readOnly: !depthSpin.editable + validator: depthSpin.validator + inputMethodHints: Qt.ImhDigitsOnly + } + background: Rectangle { + implicitWidth: 80 + implicitHeight: rowHeight + color: "#333333" + border.color: "#555555" + } + } + } + + // Text Filter + TextField { + id: filterField + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + placeholderText: "Filter String..." + placeholderTextColor: "#888888" + color: "white" + font.pixelSize: fontSize + leftPadding: 5 + background: Rectangle { color: "#333333"; border.color: "#555555" } + onTextEdited: refreshFiltering() + } + } + + + + // Table Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + color: "#2a2a2a" // Background + + Item { + anchors.fill: parent + clip: true + RowLayout { + x: -fileListView.contentX + width: Math.max(parent.width, totalContentWidth) + height: parent.height + spacing: 0 + + // Helper to create columns + component HeaderColumn: Rectangle { + property string title + property string colId + property alias colWidth: rect.width + property bool resizable: true + id: rect + Layout.fillHeight: true + color: "transparent" + Layout.preferredWidth: width + Text { + text: title + (sortColumn === colId ? (sortOrder === 1 ? " ▲" : " ▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: hintColor + font.pixelSize: fontSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles(colId) + cursorShape: Qt.PointingHandCursor + } + Rectangle { + visible: resizable + width: 5; height: parent.height + anchors.right: parent.right + color: "transparent" + MouseArea { + anchors.fill: parent; cursorShape: Qt.SplitHCursor + drag.target: rect; drag.axis: Drag.XAxis + property real startX + onPressed: startX = mouseX + onPositionChanged: if(pressed) { var d=mouseX-startX; if(rect.width+d>30) rect.width+=d } + } + } + } + + HeaderColumn { title: "Name"; colId: "name"; Layout.fillWidth: true; Layout.minimumWidth: minWidthName; resizable: false } + HeaderColumn { title: "Version"; colId: "version"; width: colWidthVersion; onWidthChanged: colWidthVersion=width } + HeaderColumn { title: "Frames"; colId: "frames"; width: colWidthFrames; onWidthChanged: colWidthFrames=width } + HeaderColumn { title: "Owner"; colId: "owner"; width: colWidthOwner; onWidthChanged: colWidthOwner=width } + HeaderColumn { title: "Date"; colId: "date"; width: colWidthDate; onWidthChanged: colWidthDate=width } + HeaderColumn { title: "Size"; colId: "size_str"; width: colWidthSize; onWidthChanged: colWidthSize=width } + Item { width: 20 } // Spacer at end + } + } + } + + // File List + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { anchors.fill: parent; color: "#222222" } + + ListView { + id: fileListView + anchors.fill: parent + anchors.rightMargin: 12 + visible: root.viewMode !== 3 + focus: visible + onVisibleChanged: { if (visible) forceActiveFocus() } + + Keys.onLeftPressed: (event) => { + if (currentIndex > 0) currentIndex-- + event.accepted = true + } + Keys.onRightPressed: (event) => { + if (currentIndex < count - 1) currentIndex++ + event.accepted = true + } + Keys.onReturnPressed: (event) => _handleListReturn(event) + Keys.onEnterPressed: (event) => _handleListReturn(event) + + function _handleListReturn(event) { + if (currentIndex >= 0 && currentIndex < count) { + var md = visibleTreeList[currentIndex] + if (md) { + previewTimer.stop() + if (md.isFolder) { + sendCommand({"action": "change_path", "path": md.path}) + } else { + isPreviewMode = false + sendCommand({"action": "load_file", "path": md.path}) + } + } + } + event.accepted = true + } + + onCurrentIndexChanged: { + if (activeFocus && currentItem) { + if (!currentItem.isItemFolder) { + root.pendingPreviewPath = currentItem.itemPath + previewTimer.restart() + } + } + } + + // Nothing found message + Text { + anchors.centerIn: parent + text: "Nothing found" + color: "#666666" + font.pixelSize: 18 + visible: fileListView.count === 0 && !searching_attr.value && !scan_required_attr.value + } + clip: true + model: visibleTreeList + + contentWidth: totalContentWidth + flickableDirection: Flickable.HorizontalAndVerticalFlick + boundsBehavior: Flickable.StopAtBounds + + // Manual Scan Overlay + Rectangle { + anchors.centerIn: parent + width: 200 + height: 100 + color: "#333333" + visible: scan_required_attr.value === true + z: 100 // Ensure it's on top + + ColumnLayout { + anchors.centerIn: parent + spacing: 10 + + Text { + text: "Manual Scan Required" + color: "#aaaaaa" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: "Scan Directory" + Layout.alignment: Qt.AlignHCenter + onClicked: sendCommand({"action": "force_scan"}) + + background: Rectangle { + color: parent.down ? "#444444" : "#555555" + radius: 3 + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + } + } + + delegate: Rectangle { + id: delegate + width: totalContentWidth + height: rowHeight + + property bool isSelected: ListView.isCurrentItem + property bool isHovered: false + property string itemPath: modelData.path + property bool isItemFolder: modelData.isFolder + + Rectangle { + anchors.fill: parent + color: isSelected ? "#555555" : (isHovered ? "#333333" : (index % 2 == 0 ? "#222222" : "#252525")) + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: isHovered = true + onExited: isHovered = false + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + fileListView.currentIndex = index + fileListView.forceActiveFocus() + if (mouse.button === Qt.RightButton) { + contextMenu.popup() + } else if (mouse.button === Qt.LeftButton) { + if (!modelData.isFolder) { + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } + } + } + onDoubleClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + previewTimer.stop() + fileListView.currentIndex = index + if (modelData.isFolder) { + sendCommand({"action": "change_path", "path": modelData.path}) + } else { + isPreviewMode = false + sendCommand({"action": "load_file", "path": modelData.path}) + } + } + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // Cells + component Cell: Text { + property real w + property int elideMode: Text.ElideRight + Layout.preferredWidth: w + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: elideMode + leftPadding: 5 + color: isSelected ? "#ffffff" : "#cccccc" + font.pixelSize: fontSize + } + + // Indentation + Item { + Layout.preferredWidth: (modelData.depth||0) * 20 + Layout.fillHeight: true + } + + // Expander + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + Text { + anchors.centerIn: parent + text: (root.viewMode !== 0 && root.viewMode !== 3 && modelData.isFolder) ? (modelData.expanded ? "▼" : "▶") : "" + color: "#aaaaaa" + font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent + onClicked: toggleExpand(index) + } + } + + Cell { text: modelData.name || ""; Layout.fillWidth: true; Layout.minimumWidth: minWidthName; elideMode: Text.ElideMiddle } + Cell { text: (modelData.data && modelData.data.version) ? "v"+modelData.data.version : ""; w: colWidthVersion; color: isSelected?"#eee":"#999" } + Cell { text: (modelData.data && modelData.data.frames) || ""; w: colWidthFrames } + Cell { text: (modelData.data && modelData.data.owner) || ""; w: colWidthOwner; color: isSelected?"#eee":"#999" } + Cell { text: modelData.data ? formatDate(modelData.data.date) : ""; w: colWidthDate; color: isSelected?"#eee":"#999" } + Cell { text: (modelData.data && modelData.data.size_str) || ""; w: colWidthSize; horizontalAlignment: Text.AlignRight; rightPadding: 5 } + Item { width: 20 } // Spacer at end + } + + Menu { + id: contextMenu + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 40 + color: "#333333" + border.color: "#555555" + radius: 3 + } + + delegate: MenuItem { + id: menuItem + + contentItem: Text { + text: menuItem.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: menuItem.highlighted ? "#555555" : "transparent" + } + } + + MenuItem { + text: "Replace" + onTriggered: sendCommand({"action": "replace_current_media", "path": modelData.path}) + } + MenuItem { + text: "Compare with" + onTriggered: sendCommand({"action": "compare_with_current_media", "path": modelData.path}) + } + } + } + } + + // Thumbnail view: Flickable + Flow for reliable scrolling with folder headers + Flickable { + id: thumbFlickable + anchors.fill: parent + visible: root.viewMode === 3 + clip: true + contentWidth: width + contentHeight: thumbFlow.implicitHeight + flickableDirection: Flickable.VerticalFlick + + focus: visible + onVisibleChanged: { if (visible) forceActiveFocus() } + + property int thumbCurrentIndex: -1 + + Keys.onLeftPressed: (event) => { + var newIdx = thumbCurrentIndex + do { + if (newIdx > 0) newIdx-- + else break + } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") + if (newIdx !== thumbCurrentIndex) { + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onRightPressed: (event) => { + var newIdx = thumbCurrentIndex + var maxIdx = flatThumbnailModel.length - 1 + do { + if (newIdx < maxIdx) newIdx++ + else break + } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") + if (newIdx !== thumbCurrentIndex) { + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onUpPressed: (event) => { + var cols = Math.max(1, Math.floor(thumbFlow.width / 160)) + var newIdx = thumbCurrentIndex - cols + if (newIdx >= 0) { + while (newIdx > 0 && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx-- + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onDownPressed: (event) => { + var cols = Math.max(1, Math.floor(thumbFlow.width / 160)) + var maxIdx = flatThumbnailModel.length - 1 + var newIdx = thumbCurrentIndex + cols + if (newIdx <= maxIdx) { + while (newIdx < maxIdx && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx++ + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onReturnPressed: (event) => _handleThumbReturn(event) + Keys.onEnterPressed: (event) => _handleThumbReturn(event) + + function _handleThumbReturn(event) { + if (thumbCurrentIndex >= 0 && thumbCurrentIndex < flatThumbnailModel.length) { + var md = flatThumbnailModel[thumbCurrentIndex] + if (md && md.type === "file") { + previewTimer.stop() + isPreviewMode = false + sendCommand({"action": "load_file", "path": md.path}) + } + } + event.accepted = true + } + + function _handleThumbKeyPreview() { + if (thumbCurrentIndex >= 0 && thumbCurrentIndex < flatThumbnailModel.length) { + var md = flatThumbnailModel[thumbCurrentIndex] + if (md && md.type === "file") { + root.pendingPreviewPath = md.path + previewTimer.restart() + } + } + } + + onContentYChanged: { + // Extend the rendered page when the user scrolls near the bottom + var remaining = contentHeight - contentY - height + if (remaining < 600 && thumbRenderCount < fullFlatModel.length) { + thumbRenderCount = Math.min(thumbRenderCount + thumbPageStep, fullFlatModel.length) + flatThumbnailModel = fullFlatModel.slice(0, thumbRenderCount) + } + Qt.callLater(requestVisibleThumbnails) + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + Flow { + id: thumbFlow + width: thumbFlickable.contentWidth + spacing: 0 + + Repeater { + model: flatThumbnailModel + + delegate: Item { + id: flatDelegate + width: modelData.type === "header" ? thumbFlow.width : 160 + height: modelData.type === "header" ? 24 : 160 + + // ── Folder path header (spans full row) ──────────── + Rectangle { + anchors.fill: parent + visible: modelData.type === "header" + color: "#1a1a1a" + Rectangle { width: 3; height: parent.height; color: "#4a9eff" } + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 10 + text: modelData.type === "header" ? modelData.path : "" + color: "#7aacce"; font.pixelSize: 11; font.bold: true + elide: Text.ElideLeft + width: parent.width - 16 + } + } + + // ── Thumbnail cell ────────────────────────────────── + property bool isSelected: (index === thumbFlickable.thumbCurrentIndex) + + Rectangle { + anchors.fill: parent; anchors.margins: 5 + visible: modelData.type === "file" + color: (isSelected) ? "#555555" : (cellMouse.containsMouse ? "#333333" : "#2a2a2a") + radius: 4 + border.color: (isSelected || cellMouse.containsMouse) ? "#777777" : "transparent" + border.width: isSelected ? 2 : (cellMouse.containsMouse ? 1 : 0) + } + + ColumnLayout { + anchors.fill: parent; anchors.margins: 10 + spacing: 4 + visible: modelData.type === "file" + + Item { + Layout.fillWidth: true; Layout.fillHeight: true + BusyIndicator { + anchors.centerIn: parent; width: 30; height: 30 + running: !modelData.thumbnailSource && modelData.type === "file" + visible: running + } + Image { + anchors.fill: parent + source: modelData.thumbnailSource || "" + fillMode: Image.PreserveAspectFit + asynchronous: true + visible: !!modelData.thumbnailSource + } + } + + Item { + Layout.fillWidth: true; height: 32; clip: true + property string rawName: modelData.name || "" + property string ext: { + var d = rawName.lastIndexOf(".") + return d >= 0 ? rawName.slice(d + 1) : "" + } + property string stem: { + var d = rawName.lastIndexOf(".") + return d >= 0 ? rawName.slice(0, d) : rawName + } + property string baseName: stem.replace(/[#@%]+$/, "").replace(/\.$/, "") + property string frameRange: modelData.frames || "" + + Text { + anchors.top: parent.top + anchors.left: parent.left; anchors.right: parent.right + text: parent.baseName; color: "#e0e0e0"; font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter; elide: Text.ElideMiddle + } + Text { + anchors.bottom: parent.bottom; anchors.left: parent.left + text: parent.ext; color: "#888888"; font.pixelSize: 10 + visible: parent.ext !== "" + } + Text { + anchors.bottom: parent.bottom; anchors.right: parent.right + text: parent.frameRange; color: "#888888"; font.pixelSize: 10 + visible: parent.frameRange !== "" + } + } + } + + MouseArea { + id: cellMouse + anchors.fill: parent; hoverEnabled: true + visible: modelData.type === "file" + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + thumbFlickable.forceActiveFocus() + thumbFlickable.thumbCurrentIndex = index + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } + } + onDoubleClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + previewTimer.stop() + isPreviewMode = false + sendCommand({"action": "load_file", "path": modelData.path}) + } + } + + ToolTip { + delay: 500 + visible: cellMouse.containsMouse && modelData.type === "file" + + contentItem: Text { + text: { + if (modelData.type !== "file") return "" + // parent directory path only + var txt = modelData.path + var sl = txt.lastIndexOf("/") + if (sl >= 0) txt = txt.substring(0, sl) + + txt += "\n" + (modelData.name || "") + if (modelData.frames) txt += "\nFrames: " + modelData.frames + if (modelData.data && modelData.data.date) txt += "\nModified: " + formatDate(modelData.data.date) + if (modelData.data && modelData.data.size_str) txt += "\nSize: " + modelData.data.size_str + return txt + } + color: "#e0e0e0" + font.pixelSize: 11 + } + + background: Rectangle { + color: "#333333" + radius: 3 + border.color: "#555555" + } + } + } + } // delegate + } // Repeater + } // Flow + } // Flickable + + // Nothing found message + Text { + anchors.centerIn: parent + text: "Nothing found" + color: "#666666" + font.pixelSize: 18 + visible: root.viewMode === 3 && flatThumbnailModel.length === 0 && !searching_attr.value && !scan_required_attr.value + } + + // Manual Scan Overlay + Rectangle { + anchors.centerIn: parent + width: 200 + height: 100 + color: "#333333" + visible: root.viewMode === 3 && scan_required_attr.value === true + z: 100 // Ensure it's on top + + ColumnLayout { + anchors.centerIn: parent + spacing: 10 + + Text { + text: "Manual Scan Required" + color: "#aaaaaa" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: "Scan Directory" + Layout.alignment: Qt.AlignHCenter + onClicked: sendCommand({"action": "force_scan"}) + + background: Rectangle { + color: parent.down ? "#444444" : "#555555" + radius: 3 + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + } + } + + ScrollBar { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + active: true + // The ScrollView (thumbnail mode) has its own built-in scrollbar + visible: root.viewMode !== 3 + policy: ScrollBar.AsNeeded + size: fileListView.visibleArea.heightRatio + position: fileListView.visibleArea.yPosition + onPositionChanged: if(pressed) { + fileListView.contentY = position * fileListView.contentHeight + } + } + } + + // Scanned Dirs Log (Visible during scan) + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: searching_attr.value ? 100 : 0 + color: "#1a1a1a" + visible: searching_attr.value === true + clip: true + + ListView { + anchors.fill: parent + model: scannedDirsList + clip: true + delegate: Text { + text: modelData + color: "#888888" + font.pixelSize: 10 + width: ListView.view.width + elide: Text.ElideMiddle + } + + // Auto-scroll to bottom + onCountChanged: { + positionViewAtEnd() + } + } + } + + // Bottom Footer: Progress + View Modes + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: 10 + + // Progress Bar (Left - fills remaining space) + ProgressBar { + id: scanProgress + Layout.fillWidth: true + Layout.preferredHeight: 6 + Layout.alignment: Qt.AlignVCenter + + // Only visible when scanning + visible: searching_attr.value === true + + from: 0 + to: 100 + value: progress_attr.value + indeterminate: true + + background: Rectangle { + implicitWidth: 200 + implicitHeight: 6 + color: "#444444" + radius: 3 + } + contentItem: Item { + implicitWidth: 200 + implicitHeight: 4 + Rectangle { + width: scanProgress.visualPosition * parent.width + height: parent.height + radius: 2 + color: "#17a81a" + } + } + } + + // If not scanning, we need a spacer to push buttons to right + Item { + Layout.fillWidth: true + visible: !scanProgress.visible + } + + // Preview Indicator + Rectangle { + Layout.preferredWidth: 60 + Layout.preferredHeight: 18 + Layout.alignment: Qt.AlignVCenter + color: "transparent" + + Text { + anchors.centerIn: parent + text: "Preview" + color: isPreviewMode ? "#66ff66" : "#444444" + font.pixelSize: 10 + font.bold: isPreviewMode + } + } + + // Divider (Vertical line) + Rectangle { + Layout.preferredWidth: 1 + Layout.preferredHeight: 14 + color: "#444444" + Layout.alignment: Qt.AlignVCenter + } + + + // View Mode Selector (Right) + RowLayout { + spacing: 0 + Layout.alignment: Qt.AlignVCenter + + Repeater { + model: ["List", "Tree", "Grouped", "Thumbnails"] + delegate: Rectangle { + width: 60 + height: 18 + color: (viewMode === index) ? "#444444" : "transparent" + border.color: "#555555" + border.width: 1 + + // Connecting borders + anchors.leftMargin: index > 0 ? -1 : 0 + + Text { + anchors.centerIn: parent + text: modelData + color: (viewMode === index) ? "#ffffff" : "#888888" + font.pixelSize: 10 + } + + MouseArea { + anchors.fill: parent + onClicked: viewMode = index + hoverEnabled: true + onEntered: parent.color = (viewMode === index) ? "#555555" : "#333333" + onExited: parent.color = (viewMode === index) ? "#444444" : "transparent" + } + } + } + } + + Item { Layout.preferredWidth: 5 } // Right margin + } + } + } +} +} diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg new file mode 100644 index 000000000..36f119c96 --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/qmldir b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/qmldir new file mode 100644 index 000000000..1065d17c8 --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/qmldir @@ -0,0 +1,2 @@ +module FilesystemBrowser +FilesystemBrowser 1.0 FilesystemBrowser.qml diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/scanner.py b/portable/share/xstudio/plugin-python/filesystem_browser/scanner.py new file mode 100644 index 000000000..dcea5c0c3 --- /dev/null +++ b/portable/share/xstudio/plugin-python/filesystem_browser/scanner.py @@ -0,0 +1,417 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards + +import os +import re +import threading +import queue +import time +import json + +try: + import pwd +except ImportError: + pwd = None # Not available on Windows +from concurrent.futures import ThreadPoolExecutor + +try: + import fileseq +except ImportError: + fileseq = None + +class FileScanner: + def __init__(self, config=None): + self.config = config or {} + self.extensions = set(self.config.get("extensions", [".mov", ".exr", ".png", ".mp4", ".jpg", ".jpeg", ".dpx", ".tiff", ".tif"])) + self.ignore_dirs = set(self.config.get("ignore_dirs", [".git", ".svn", "__pycache__"])) + self.non_sequence_extensions = set(self.config.get("non_sequence_extensions", [".mov", ".mp4"])) + self.version_regex = re.compile(self.config.get("version_regex", r"_v(\d+)")) + self.max_workers = self.config.get("thread_count", 4) + self.max_depth = self.config.get("max_depth", 6) + + self.cancel_event = threading.Event() + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) + + + + def get_owner(self, st): + """Get file owner from stat object. Returns username or 'unknown' on Windows.""" + if pwd: + try: + return pwd.getpwuid(st.st_uid).pw_name + except (KeyError, AttributeError): + return str(st.st_uid) + return "unknown" + + def format_size_str(self, size_bytes): + if size_bytes == 0: + return "0 B" + + size_name = ("B", "KB", "MB", "GB", "TB", "PB") + i = 0 + p = float(size_bytes) + + while i < len(size_name) - 1 and p >= 1024: + p /= 1024.0 + i += 1 + + return f"{p:.2f} {size_name[i]}" + + def scan(self, start_path, callback=None): + """ + Scans from start_path using BFS and weighted progress. + callback(results, progress_info) is called periodically. + """ + self.cancel_event.clear() + + from collections import deque + from concurrent.futures import wait, FIRST_COMPLETED + + # Queue of (path, weight, depth) + queue = deque([(start_path, 1.0, 0)]) + + # Futures set + futures = set() + + # Results accumulator + all_items = [] + + # Progress tracking + total_progress = 0.0 + scanned_count = 0 + last_update = time.time() + + # Scanned paths tracking + recent_scanned_dirs = [] + + # Helper to schedule + def schedule_next(): + while queue and len(futures) < self.max_workers: + path, weight, depth = queue.popleft() + # Submit task + futures.add(self.executor.submit(self._scan_and_process_worker, path, start_path, weight, depth)) + + schedule_next() + + while (futures or queue) and not self.cancel_event.is_set(): + # Wait for some work to complete + done, _ = wait(futures, timeout=0.05, return_when=FIRST_COMPLETED) + + for f in done: + futures.remove(f) + try: + subdirs, items, weight, depth, scanned_path = f.result() + + # Accumulate results + if items: + all_items.extend(items) + scanned_count += len(items) + + recent_scanned_dirs.append(scanned_path) + + if callback and items: + # Send partial results + # Note: We send empty list for items here if we want to batch them? + # Original code sent items immediately. + callback(items, {"scanned": scanned_count, "progress": total_progress * 100, "phase": "scanning", "scanned_dirs": []}) + + # Distribute weight or complete it + if subdirs and depth < self.max_depth: + if len(subdirs) > 0: + child_weight = weight / len(subdirs) + for d in subdirs: + queue.append((d, child_weight, depth + 1)) + else: + # Leaf node (in terms of dirs or recursion limit), this weight is done + total_progress += weight + + except Exception as e: + print(f"Scan error: {e}") + + # Schedule more + schedule_next() + + # Periodic Progress update + if time.time() - last_update > 0.2: + if callback: + callback([], { + "scanned": scanned_count, + "progress": min(100, int(total_progress * 100)), + "phase": "scanning", + "scanned_dirs": list(recent_scanned_dirs) + }) + recent_scanned_dirs = [] + last_update = time.time() + + if self.cancel_event.is_set(): + for f in futures: + f.cancel() + return all_items # Return what we have + + # Final update + if callback: + callback([], {"scanned": scanned_count, "progress": 100, "phase": "complete", "scanned_dirs": list(recent_scanned_dirs)}) + + return all_items + + def _scan_and_process_worker(self, path, root_path, weight, depth): + """ + Scans a directory, processes files therein, returns (subdirs, items, weight, depth, path). + """ + subdirs = [] + raw_files = [] + + if self.cancel_event.is_set(): + return [], [], weight, depth, path + + try: + with os.scandir(path) as entries: + for entry in entries: + if self.cancel_event.is_set(): + break + + if entry.is_dir(follow_symlinks=False): + if entry.name not in self.ignore_dirs and not entry.name.startswith('.'): + subdirs.append(entry.path) + # Also add directory as an item + try: + raw_files.append((entry.path, entry.name, entry.stat(), True)) # True for is_dir + except OSError: + pass + elif entry.is_file(): + ext = os.path.splitext(entry.name)[1].lower() + if ext in self.extensions: + try: + raw_files.append((entry.path, entry.name, entry.stat(), False)) # False for is_dir + except OSError: + pass + except OSError: + pass + + # Process files immediately + items = self._process_files(raw_files, root_path) + return subdirs, items, weight, depth, path + + def _process_files(self, raw_files, start_path): + """ + raw_files: list of (full_path, basename, stat_obj, is_dir) + """ + path_map = {f[0]: (f[1], f[2]) for f in raw_files} # path -> (name, stat) + + final_items = [] + sequence_candidate_paths = [] + + # Split into sequence candidates and singles + for p, name, st, is_dir in raw_files: + if is_dir: + final_items.append(self._make_item(p, name, st, start_path, is_directory=True)) + continue + + ext = os.path.splitext(name)[1].lower() + if ext in self.non_sequence_extensions: + # Treat strictly as single file + final_items.append(self._make_item(p, name, st, start_path)) + else: + sequence_candidate_paths.append(p) + + # Use fileseq to find sequences among candidates + # Import moved to top level or check self.HAS_FILESEQ? + # The file has 'try: import fileseq ...' at top level + + sequences = [] + if fileseq and sequence_candidate_paths: + try: + sequences = fileseq.findSequencesInList(sequence_candidate_paths) + except Exception as e: + sequences = [] # Fallback? + + if not fileseq and sequence_candidate_paths: + # Fallback: Treat all as singles + for p in sequence_candidate_paths: + info = path_map.get(p) + if info: + final_items.append(self._make_item(p, info[0], info[1], start_path)) + + for seq in sequences: + # Check if we should explode this sequence (if it's actually versioned files matching config) + explode = False + + # If length is 1, it's virtually a single file, but fileseq wraps it. + # If length > 1, check if it matches version regex but shouldn't? + # Existing logic: + if len(seq) > 1: + try: + # If the basename doesn't match version regex, but one file does?? + # This logic seems to prevent detecting a sequence if the naming is ambiguous? + # Let's keep existing logic but careful. + # Actually, if len > 1, it IS a sequence usually. + pass + except Exception as e: + pass + + if len(seq) == 1: + # Treat as single file + str_p = str(seq[0]) + info = path_map.get(str_p) + if info: + final_items.append(self._make_item(str_p, info[0], info[1], start_path)) + continue + + # It's a sequence + max_mtime = 0 + total_size = 0 + valid_seq = True + + # Calculate stats + for p in seq: + info = path_map.get(str(p)) + if info: + st = info[1] + if st.st_mtime > max_mtime: + max_mtime = st.st_mtime + total_size += st.st_size + else: + # Should not happen as we built candidates from map + pass + + # Retrieve owner from first + first_path = str(seq[0]) + first_info = path_map.get(first_path) + owner = self.get_owner(first_info[1]) if first_info else "?" + + # Format name + try: + pad = seq.padding() + if pad == '#': pad = "@@@@" + elif '#' in pad: pad = "@" * len(pad) + elif not pad: pad = "@@@@" # Default? + except: + pad = "@@@@" + + name = f"{seq.basename()}{pad}{seq.extension()}" + + # Create item + # Use abspath for seq path? + # fileseq string representation might be relative if input was relative? + # input was 'p' from raw_files which is full path. + + # fileseq.FileSequence string conversion gives the sequence string (path-#.ext). + # We want that as 'path'? + # xstudio expects 'path' to be loadable. + + item = { + "name": name, + "path": str(seq), # Sequence string path + "relpath": os.path.relpath(first_path, start_path).replace("\\", "/"), # Relative path of ONE file? Or sequence? + # relpath is used for tree building. + # If we use first_path, detailed logic might split it. + # But we want the sequence to appear in the folder. + # So we should use relation of the FOLDER containing the sequence. + # relpath logic in QML splits by /. + # If path is /foo/bar/seq.####.exr. relpath = bar/seq.####.exr. + # parts = [bar, seq...]. + # This works. + "type": "Sequence", + "frames": str(seq.frameRange()), + "size": total_size, + "size_str": self.format_size_str(total_size), + "date": max_mtime, + "owner": owner, + "extension": seq.extension(), + "is_sequence": True, + "is_folder": False + } + # Fix relpath to be based on the abstract sequence path if possible? + # actually `str(seq)` gives the sequence path. + # `os.path.relpath(str(seq), start_path)` should work. + item["relpath"] = os.path.relpath(str(seq), start_path).replace("\\", "/") + + final_items.append(item) + + return self._group_versions(final_items) + + def _make_item(self, path, name, st, start_path, is_directory=False): + return { + "name": name, + "path": path, + "relpath": os.path.relpath(path, start_path).replace("\\", "/"), + "type": "Folder" if is_directory else "File", + "frames": "" if is_directory else "1", + "size": 0 if is_directory else st.st_size, + "size_str": "" if is_directory else self.format_size_str(st.st_size), + "date": st.st_mtime, + "owner": self.get_owner(st), + "extension": "" if is_directory else os.path.splitext(name)[1], + "is_sequence": False, + "is_folder": is_directory + } + + def _group_versions(self, items): + # items is a list of dicts. + # We want to identify items that are versions of the same thing. + # Regex: _v(\d+) + + # Key: (prefix, suffix) -> [item1, item2, ...] + groups = {} + ungrouped = [] + + for item in items: + name = item["name"] + # Apply regex + match = self.version_regex.search(name) + if match: + # Found a version + v_str = match.group(1) + v_num = int(v_str) + + # remove the version string from name to get the key + # e.g. shot_v01.exr -> shot_.exr (or similar) + # We replace the FULL match _v01 with a placeholder or empty + + # We need to handle where it is. + # If we have shot_v1.exr and shot_v2.exr -> Key: shot_.exr + span = match.span() + prefix = name[:span[0]] + suffix = name[span[1]:] + key = (prefix, suffix) + + if key not in groups: + groups[key] = [] + + # Attach version info to item + item["version"] = v_num + groups[key].append(item) + else: + ungrouped.append(item) + + # Now process groups + # If config says to group, we return a hybrid list + # We assume we just annotate them for now, or do we structure them? + # The user said: "group files of a similar basename... by removing version string" + # "Filter only the highest version" + + # If we just adding metadata, we can just return the flat list but with "version_group_id" or something. + # But for the UI to show "Latest Version", it needs to know which ones are older. + + # Let's add "latest_in_group" flag to items? + # And "group_key". + + final_output = list(ungrouped) + + for key, group_items in groups.items(): + # Sort by version + group_items.sort(key=lambda x: x["version"], reverse=True) + + # Highest version + for i, item in enumerate(group_items): + item["is_latest_version"] = (i == 0) + item["version_rank"] = i # 0-indexed rank (0 is latest) + item["version_group"] = str(key) + final_output.append(item) + + # Sort by name + final_output.sort(key=lambda x: x["name"]) + return final_output + + def stop(self): + self.cancel_event.set() From c857d5e262e4aded607e1ee042364d15834c2727 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sat, 14 Mar 2026 17:48:59 -0700 Subject: [PATCH 07/18] perf(exr): optimize EXR read pipeline with 4 targeted fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cache dump_json_headers() per part index — metadata is identical across all frames in a sequence, eliminates ~400 RTTI dynamic_casts per frame - Cache MultiPartInputFile handle — reuse when same file path is re-read (scrub-back, multi-stream), avoids repeated open+header-parse syscalls - Batch precache cache checks — single preserve_atom message per group instead of N individual request/response round-trips to cache actor - Bump max_in_flight from 4 to 8 for better pipeline saturation on multi-core CPUs (benchmark: 83→88 fps on 32-thread system) Benchmark on 4312x2274 ZIPS EXR (NVMe): confirms no regression, app-level overhead reduced by eliminating per-frame redundant work. Co-Authored-By: Claude Opus 4.6 --- .../media_reader/media_reader_actor.hpp | 6 + src/media_reader/src/media_reader_actor.cpp | 304 +++++++++++------- .../media_reader/openexr/src/openexr.cpp | 45 ++- .../media_reader/openexr/src/openexr.hpp | 14 + 4 files changed, 246 insertions(+), 123 deletions(-) diff --git a/include/xstudio/media_reader/media_reader_actor.hpp b/include/xstudio/media_reader/media_reader_actor.hpp index c85af04ad..0fb08906d 100644 --- a/include/xstudio/media_reader/media_reader_actor.hpp +++ b/include/xstudio/media_reader/media_reader_actor.hpp @@ -46,6 +46,12 @@ namespace media_reader { const caf::uri &_uri, const caf::actor_addr &_key, const std::string &hint = ""); void do_precache(); + void dispatch_precache_read( + const FrameRequest &fr, + caf::actor cache_actor, + utility::time_point cache_out_of_date_threshold, + bool is_background_cache); + void keep_cache_hot( const media::MediaKey &new_entry, const utility::time_point &tp, diff --git a/src/media_reader/src/media_reader_actor.cpp b/src/media_reader/src/media_reader_actor.cpp index 2281fbf6f..828bb5e5d 100644 --- a/src/media_reader/src/media_reader_actor.cpp +++ b/src/media_reader/src/media_reader_actor.cpp @@ -4,6 +4,7 @@ #include #include +#include #include "xstudio/atoms.hpp" #include "xstudio/global_store/global_store.hpp" @@ -785,140 +786,221 @@ void GlobalMediaReaderActor::prune_readers() { void GlobalMediaReaderActor::on_exit() { system().registry().erase(media_reader_registry); } +void GlobalMediaReaderActor::dispatch_precache_read( + const FrameRequest &fr, + caf::actor cache_actor, + utility::time_point cache_out_of_date_threshold, + bool is_background_cache) { + + const std::shared_ptr mptr = fr.requested_frame_; + const utility::Uuid playhead_uuid = fr.requesting_playhead_uuid_; + + try { + auto reader = + get_reader(mptr->uri(), mptr->media_source_addr(), mptr->reader()); + if (not reader) { + // we need a new reader + mail(get_reader_atom_v, mptr->uri(), mptr->reader()) + .request(pool_, infinite) + .then( + [=](caf::actor new_reader) mutable { + auto key = + reader_key(mptr->uri(), mptr->media_source_addr()); + new_reader = add_reader(new_reader, key); + if (cache_actor == image_cache_) { + read_and_cache_image( + new_reader, + fr, + cache_out_of_date_threshold, + is_background_cache); + } else { + read_and_cache_audio( + new_reader, + fr, + cache_out_of_date_threshold, + is_background_cache); + } + }, + [=](caf::error &err) { + mark_playhead_received_precache_result(playhead_uuid); + continue_precacheing(); + }); + } else { + if (cache_actor == image_cache_) { + read_and_cache_image( + reader, + fr, + cache_out_of_date_threshold, + is_background_cache); + } else { + read_and_cache_audio( + reader, + fr, + cache_out_of_date_threshold, + is_background_cache); + } + } + } catch (std::exception &e) { + // We have been unable to create a reader - the file is unreadable + // for some reason. We do not want to report an error because we are + // currently pre-cacheing. The error *will* get reported when we + // actually want to show the image as an immediate frame request will + // be made as the image isn't in the cache, and at that point error + // message propagation will give the user feedback about the frame + // being unreadable. + // We MUST release the in-flight marker and continue, otherwise + // the precache pipeline stalls permanently for this playhead. + spdlog::warn("Precache get_reader failed: {}", e.what()); + mark_playhead_received_precache_result(playhead_uuid); + continue_precacheing(); + } +} + void GlobalMediaReaderActor::do_precache() { // Allow up to max_in_flight concurrent precache reads per playhead. - // Previously this was limited to 1, serializing all reads. - // We loop here to dispatch multiple requests up to the limit. - static constexpr int max_in_flight = 4; + static constexpr int max_in_flight = 8; - // Try to dispatch as many requests as allowed - for (int dispatched = 0; dispatched < max_in_flight; ++dispatched) { + // Phase 1: Pop up to max_in_flight requests from the queue in a tight + // loop, collecting them all before any async work. This avoids + // interleaving pop -> async_request -> pop which serializes on the + // cache actor's message queue. - std::optional fr = playback_precache_request_queue_.pop_request( - playheads_with_precache_requests_in_flight_, max_in_flight); + struct PoppedRequest { + FrameRequest fr; + bool is_background; + caf::actor cache_actor; + utility::time_point cache_out_of_date_threshold; + }; - // when putting new images in the cache, images older than this timepoint can - // be discarded - bool is_background_cache = false; - if (not fr) { - fr = background_precache_request_queue_.pop_request( - playheads_with_precache_requests_in_flight_, max_in_flight); + std::vector popped; + popped.reserve(max_in_flight); + + for (int i = 0; i < max_in_flight; ++i) { + std::optional fr = playback_precache_request_queue_.pop_request( + playheads_with_precache_requests_in_flight_, max_in_flight); + bool is_background_cache = false; if (not fr) { - return; // no more requests to dispatch + fr = background_precache_request_queue_.pop_request( + playheads_with_precache_requests_in_flight_, max_in_flight); + + if (not fr) { + break; // no more requests available + } + is_background_cache = true; } - is_background_cache = true; - } - const std::shared_ptr mptr = fr->requested_frame_; + const auto &mptr = fr->requested_frame_; + const auto &playhead_uuid = fr->requesting_playhead_uuid_; + + time_point cache_out_of_date_threshold = + utility::clock::now() - std::chrono::milliseconds(10); - const time_point &predicted_time = fr->required_by_; - const utility::Uuid &playhead_uuid = fr->requesting_playhead_uuid_; - time_point cache_out_of_date_threshold = - utility::clock::now() - std::chrono::milliseconds(10); + if (background_cached_ref_timepoint_.find(playhead_uuid) != + background_cached_ref_timepoint_.end()) { + cache_out_of_date_threshold = + background_cached_ref_timepoint_[playhead_uuid] - std::chrono::seconds(1); + } - if (background_cached_ref_timepoint_.find(playhead_uuid) != - background_cached_ref_timepoint_.end()) { - cache_out_of_date_threshold = - background_cached_ref_timepoint_[playhead_uuid] - std::chrono::seconds(1); + caf::actor cache_actor = + mptr->media_type() == media::MediaType::MT_IMAGE ? image_cache_ : audio_cache_; + + // Mark in-flight before any async work so subsequent pops respect limits + mark_playhead_waiting_for_precache_result(playhead_uuid); + + popped.push_back(PoppedRequest{ + std::move(*fr), is_background_cache, cache_actor, cache_out_of_date_threshold}); } - // mark that the playhead is waiting for something. This prevents queuing multiple requests, - // we only want to send a new request when we've received the previous one. + if (popped.empty()) { + return; + } + // Phase 2: Group requests by (cache_actor, playhead_uuid) so we can + // send one batched preserve_atom per group instead of N individual + // request/response round-trips to the cache actor. - // this flag prevents us from making pre-cache read requests while other - // requests haven't yet been responded to, without needing to use 'await' - // which would otherwise block this crucial actor + struct GroupKey { + bool is_image_cache; // true = image_cache_, false = audio_cache_ + utility::Uuid playhead_uuid; - caf::actor cache_actor = - mptr->media_type() == media::MediaType::MT_IMAGE ? image_cache_ : audio_cache_; - mark_playhead_waiting_for_precache_result(playhead_uuid); + bool operator<(const GroupKey &o) const { + if (is_image_cache != o.is_image_cache) return is_image_cache < o.is_image_cache; + return playhead_uuid < o.playhead_uuid; + } + }; + + struct GroupData { + caf::actor cache_actor; + media::AVFrameIDsAndTimePoints batch_keys; // for the batched preserve_atom + std::vector requests; // full request data for dispatch + }; + + std::map groups; + + for (auto &pr : popped) { + GroupKey gk{pr.cache_actor == image_cache_, pr.fr.requesting_playhead_uuid_}; + auto &gd = groups[gk]; + gd.cache_actor = pr.cache_actor; + gd.batch_keys.push_back( + std::make_pair(pr.fr.required_by_, pr.fr.requested_frame_)); + gd.requests.push_back(std::move(pr)); + } - mail(media_cache::preserve_atom_v, mptr->key(), predicted_time, playhead_uuid) - .request(cache_actor, std::chrono::milliseconds(500)) - .then( + // Phase 3: Send one batched preserve_atom per group. The cache actor + // preserves entries that exist and returns only the ones that are NOT + // cached, so we only dispatch reads for truly uncached frames. + + for (auto &[gk, gd] : groups) { + auto batch_keys = std::make_shared( + std::move(gd.batch_keys)); + auto requests = std::make_shared>( + std::move(gd.requests)); + auto cache_actor = gd.cache_actor; + auto playhead_uuid = gk.playhead_uuid; + + mail(media_cache::preserve_atom_v, *batch_keys, playhead_uuid) + .request(cache_actor, std::chrono::milliseconds(500)) + .then( + [=](const media::AVFrameIDsAndTimePoints &uncached) mutable { + // Build a set of uncached keys for fast lookup + std::set uncached_keys; + for (const auto &p : uncached) { + uncached_keys.insert(p.second->key()); + } - [=](const bool exists) mutable { - if (exists) { - // already have in the cache, but might still have work to do - mark_playhead_received_precache_result(playhead_uuid); - // if (is_background_cache) { - // keep_cache_hot(mptr.key(), predicted_time, playhead_uuid); - // } - continue_precacheing(); - } else { - try { - auto reader = - get_reader(mptr->uri(), mptr->media_source_addr(), mptr->reader()); - if (not reader) { - // we need a new reader - mail(get_reader_atom_v, mptr->uri(), mptr->reader()) - .request(pool_, infinite) - .then( - [=](caf::actor new_reader) mutable { - auto key = - reader_key(mptr->uri(), mptr->media_source_addr()); - new_reader = add_reader(new_reader, key); - if (cache_actor == image_cache_) { - read_and_cache_image( - new_reader, - *fr, - cache_out_of_date_threshold, - is_background_cache); - } else { - read_and_cache_audio( - new_reader, - *fr, - cache_out_of_date_threshold, - is_background_cache); - } - }, - [=](caf::error &err) { - mark_playhead_received_precache_result(playhead_uuid); - continue_precacheing(); - }); + // Process each request in this group + for (auto &pr : *requests) { + const auto &key = pr.fr.requested_frame_->key(); + if (uncached_keys.count(key)) { + // Not in cache - dispatch the actual read + dispatch_precache_read( + pr.fr, + pr.cache_actor, + pr.cache_out_of_date_threshold, + pr.is_background); } else { - if (cache_actor == image_cache_) { - read_and_cache_image( - reader, - *fr, - cache_out_of_date_threshold, - is_background_cache); - } else { - read_and_cache_audio( - reader, - *fr, - cache_out_of_date_threshold, - is_background_cache); - } + // Already cached - release in-flight marker and continue + mark_playhead_received_precache_result( + pr.fr.requesting_playhead_uuid_); } - } catch (std::exception &e) { - // we have been unable to create a reader - the file is - // unreadable for some reason. We do not want to report an - // error because we are currently pre-cacheing. The error - // *will* get reported when we actually want to show the - // image as an immediate frame request will be made as the - // image isn't in the cache, and at that point error message - // propagation will give the user feedback about the frame - // being unreadable. - // We MUST release the in-flight marker and continue, otherwise - // the precache pipeline stalls permanently for this playhead. - spdlog::warn("Precache get_reader failed: {}", e.what()); - mark_playhead_received_precache_result(playhead_uuid); - continue_precacheing(); } - } - }, - [=](const caf::error &err) { - mark_playhead_received_precache_result(playhead_uuid); - spdlog::warn( - "Failed preserve buffer {} {}", to_string(mptr->key()), to_string(err)); - }); - - } // end for (dispatched) + continue_precacheing(); + }, + [=](const caf::error &err) { + // On error, release all in-flight markers for this group + for (const auto &pr : *requests) { + mark_playhead_received_precache_result( + pr.fr.requesting_playhead_uuid_); + } + spdlog::warn( + "Failed batched preserve for playhead {}: {}", + to_string(playhead_uuid), + to_string(err)); + }); + } } void GlobalMediaReaderActor::keep_cache_hot( diff --git a/src/plugin/media_reader/openexr/src/openexr.cpp b/src/plugin/media_reader/openexr/src/openexr.cpp index 52d5b0abd..cba3a6fdf 100644 --- a/src/plugin/media_reader/openexr/src/openexr.cpp +++ b/src/plugin/media_reader/openexr/src/openexr.cpp @@ -229,15 +229,29 @@ ImageBufPtr OpenEXRMediaReader::image(const media::AVFrameID &mptr) { // DebugTimer dd(path); - Imf::MultiPartInputFile input(path.c_str()); - int parts = input.parts(); + // Reuse the cached MultiPartInputFile if the path matches, otherwise open + // a new one. This avoids repeated open+header-parse when the same file is + // read multiple times (multiple streams, scrub-back, etc.). + if (cached_file_path_ != path || !cached_input_) { + try { + cached_input_ = std::make_shared(path.c_str()); + cached_file_path_ = path; + } catch (...) { + // Ensure stale cache is cleared on failure before re-throwing. + cached_input_.reset(); + cached_file_path_.clear(); + throw; + } + } + + int parts = cached_input_->parts(); int part_idx = -1; std::array pix_type; std::vector exr_channels_to_load; for (int prt = 0; prt < parts; ++prt) { // skip incomplete parts - maybe better error/handling messaging required? - const Imf::Header &part_header = input.header(prt); + const Imf::Header &part_header = cached_input_->header(prt); std::vector stream_ids; stream_ids_from_exr_part(part_header, stream_ids); for (const auto &stream_id : stream_ids) { @@ -260,7 +274,7 @@ ImageBufPtr OpenEXRMediaReader::image(const media::AVFrameID &mptr) { // It's not reasonable to expect xSTUDIO to be able to predict how to // load an EXR sequence where the parts/layers in the files are not // consistent - const Imf::Header &part_header = input.header(0); + const Imf::Header &part_header = cached_input_->header(0); std::vector stream_ids; stream_ids_from_exr_part(part_header, stream_ids); if (stream_ids.empty()) { @@ -281,15 +295,22 @@ ImageBufPtr OpenEXRMediaReader::image(const media::AVFrameID &mptr) { throw std::runtime_error(ss.str().c_str()); } - Imf::InputPart in(input, part_idx); + Imf::InputPart in(*cached_input_, part_idx); - utility::JsonStore part_metadata; - try { - const Imf::Header &h = in.header(); - exr_reader::dump_json_headers(h, part_metadata.ref()); - } catch (const std::exception &e) { - part_metadata["METADATA LOAD ERROR"] = e.what(); + // Use cached headers: metadata is identical for every frame in a sequence, + // so we only call dump_json_headers() once per part index. + auto cache_it = cached_headers_.find(part_idx); + if (cache_it == cached_headers_.end()) { + utility::JsonStore part_meta; + try { + const Imf::Header &h = in.header(); + exr_reader::dump_json_headers(h, part_meta.ref()); + } catch (const std::exception &e) { + part_meta["METADATA LOAD ERROR"] = e.what(); + } + cache_it = cached_headers_.emplace(part_idx, std::move(part_meta)).first; } + const utility::JsonStore &part_metadata = cache_it->second; Imath::Box2i data_window = in.header().dataWindow(); Imath::Box2i display_window = in.header().displayWindow(); @@ -337,7 +358,7 @@ ImageBufPtr OpenEXRMediaReader::image(const media::AVFrameID &mptr) { auto b = buf->allocate(buf_size); - if (!input.partComplete(part_idx)) { + if (!cached_input_->partComplete(part_idx)) { // expecting to read only part of the image, so clear the buffer memset(b, 0, buf_size); } diff --git a/src/plugin/media_reader/openexr/src/openexr.hpp b/src/plugin/media_reader/openexr/src/openexr.hpp index f5c5ccec4..7b6e74011 100644 --- a/src/plugin/media_reader/openexr/src/openexr.hpp +++ b/src/plugin/media_reader/openexr/src/openexr.hpp @@ -1,13 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once +#include #include +#include #include "xstudio/media_reader/media_reader.hpp" #include "xstudio/thumbnail/thumbnail.hpp" #include "xstudio/utility/helpers.hpp" #include #include // staticInitialize +#include namespace xstudio { namespace media_reader { @@ -54,6 +57,17 @@ namespace media_reader { float max_exr_overscan_percent_; int readers_per_source_; int exr_thread_count_; + + // Cache of JSON headers per EXR part index. Metadata is identical across + // all frames in a sequence, so we compute it once and reuse it. + std::unordered_map cached_headers_; + + // Cache the last opened MultiPartInputFile to avoid reopening the same + // file on consecutive reads (e.g. multiple streams from one file, or + // scrubbing back to an already-read frame). Each OpenEXRMediaReader + // instance is used by a single worker actor, so no locking is needed. + std::string cached_file_path_; + std::shared_ptr cached_input_; }; } // namespace media_reader } // namespace xstudio From 0c3e2ec151a255f8d4615e7579bbacdd112f921d Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sun, 15 Mar 2026 00:11:21 -0700 Subject: [PATCH 08/18] fix(plugin): fix filesystem browser navigation and drag-drop interactions - Add missing single-click directory navigation in file list view (onClicked only handled files, completely ignored folder clicks) - Add folder navigation to thumbnail view click handler - Fix directory tree command channel race: change_path and get_subdirs fired simultaneously through same attribute, one stomping the other. Added 100ms sync delay so get_subdirs waits for change_path to finish. - Clear file list immediately on path change for instant visual feedback (previously old results lingered until new scan completed) - Auto-expand selected node in directory tree on navigation Co-Authored-By: Claude Opus 4.6 --- .../filesystem_browser/filesystem_browser.py | 7 +- .../qml/FilesystemBrowser.1/DirectoryTree.qml | 15 ++- .../FilesystemBrowser.1/FilesystemBrowser.qml | 106 +++++++++++++++++- 3 files changed, 120 insertions(+), 8 deletions(-) diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py index a3f45db7f..30a9c363b 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py +++ b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py @@ -460,16 +460,21 @@ def attribute_changed(self, attribute, role): # Strip trailing slash unless it's a drive root like "X:/" if len(new_path) > 1 and new_path.endswith("/") and not new_path.endswith(":/"): new_path = new_path.rstrip("/") + print(f"FilesystemBrowser: change_path -> '{new_path}'") # Virtual root "/" on Windows — just update the path, no scan if new_path == "/" and sys.platform == "win32": self.current_path_attr.set_value("/") + # Clear file list immediately so QML shows empty state + self.files_attr.set_value("[]") return if os.path.exists(new_path) and os.path.isdir(new_path): self.current_path_attr.set_value(new_path) + # Clear file list immediately so user sees feedback + self.files_attr.set_value("[]") self._add_to_history(new_path) self.start_search(new_path) else: - print(f"Invalid path: {new_path}") + print(f"FilesystemBrowser: Invalid path: {new_path}") elif action == "load_file": file_path = data.get("path") diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml index d43372f5f..baef409f9 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -82,6 +82,10 @@ Rectangle { treeView.currentIndex = i; treeView.positionViewAtIndex(i, ListView.Center); found = true; + // Expand if not already expanded (so children show) + if (!treeModel.get(i).expanded) { + expandNode(i); + } break; } } @@ -90,11 +94,20 @@ Rectangle { if (!found) { pendingExpandPath = target; isSyncing = true; - syncToPath(); + // Delay sync slightly to let the change_path command finish + // processing before we send get_subdirs through the same channel + syncTimer.start(); } } } + Timer { + id: syncTimer + interval: 100 + repeat: false + onTriggered: syncToPath() + } + function syncToPath() { if (!pendingExpandPath) return; diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 944454b2b..35492abc0 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -1602,7 +1602,20 @@ Rectangle { Layout.fillHeight: true Rectangle { anchors.fill: parent; color: "#222222" } - + + // Drag proxy — invisible item that carries mime data during drag + Item { + id: dragProxy + x: 0; y: 0 + width: 1; height: 1 + property string filePath: "" + + Drag.mimeData: { "text/uri-list": "file://" + filePath } + Drag.supportedActions: Qt.CopyAction + Drag.dragType: Drag.Internal + Drag.keys: ["text/uri-list"] + } + ListView { id: fileListView anchors.fill: parent @@ -1718,18 +1731,61 @@ Rectangle { } MouseArea { + id: fileMouseArea anchors.fill: parent hoverEnabled: true onEntered: isHovered = true onExited: isHovered = false acceptedButtons: Qt.LeftButton | Qt.RightButton + + // Drag support + property point dragStartPos + property bool dragActive: false + + drag.target: dragActive ? dragProxy : undefined + + onPressed: (mouse) => { + if (mouse.button === Qt.LeftButton) { + dragStartPos = Qt.point(mouse.x, mouse.y); + dragActive = false; + } + } + onPositionChanged: (mouse) => { + if (mouse.buttons & Qt.LeftButton) { + var dx = mouse.x - dragStartPos.x; + var dy = mouse.y - dragStartPos.y; + if (!dragActive && Math.sqrt(dx*dx + dy*dy) > 10) { + dragActive = true; + if (!modelData.isFolder) { + // Build file URI for the drag + var filePath = modelData.path.replace(/\\/g, "/"); + // Ensure proper file:/// URI format + if (filePath.charAt(0) !== '/') { + filePath = "/" + filePath; // Windows: C:/foo → /C:/foo + } + dragProxy.filePath = filePath; + dragProxy.Drag.active = true; + } + } + } + } + onReleased: (mouse) => { + if (dragProxy.Drag.active) { + dragProxy.Drag.drop(); + } + dragActive = false; + } + onClicked: (mouse) => { + if (dragActive) return; // Don't click after drag fileListView.currentIndex = index fileListView.forceActiveFocus() if (mouse.button === Qt.RightButton) { contextMenu.popup() } else if (mouse.button === Qt.LeftButton) { - if (!modelData.isFolder) { + if (modelData.isFolder) { + sendCommand({"action": "change_path", "path": modelData.path}) + } else { root.pendingPreviewPath = modelData.path previewTimer.restart() } @@ -2039,19 +2095,57 @@ Rectangle { id: cellMouse anchors.fill: parent; hoverEnabled: true visible: modelData.type === "file" + + // Drag support + property point dragStartPos + property bool dragActive: false + + onPressed: (mouse) => { + if (mouse.button === Qt.LeftButton) { + dragStartPos = Qt.point(mouse.x, mouse.y); + dragActive = false; + } + } + onPositionChanged: (mouse) => { + if (mouse.buttons & Qt.LeftButton) { + var dx = mouse.x - dragStartPos.x; + var dy = mouse.y - dragStartPos.y; + if (!dragActive && Math.sqrt(dx*dx + dy*dy) > 10) { + dragActive = true; + var filePath = modelData.path.replace(/\\/g, "/"); + if (filePath.charAt(0) !== '/') filePath = "/" + filePath; + dragProxy.filePath = filePath; + dragProxy.Drag.active = true; + } + } + } + onReleased: (mouse) => { + if (dragProxy.Drag.active) dragProxy.Drag.drop(); + dragActive = false; + } + onClicked: (mouse) => { + if (dragActive) return; if (mouse.button === Qt.LeftButton) { thumbFlickable.forceActiveFocus() thumbFlickable.thumbCurrentIndex = index - root.pendingPreviewPath = modelData.path - previewTimer.restart() + if (modelData.isFolder) { + sendCommand({"action": "change_path", "path": modelData.path}) + } else { + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } } } onDoubleClicked: (mouse) => { if (mouse.button === Qt.LeftButton) { previewTimer.stop() - isPreviewMode = false - sendCommand({"action": "load_file", "path": modelData.path}) + if (modelData.isFolder) { + sendCommand({"action": "change_path", "path": modelData.path}) + } else { + isPreviewMode = false + sendCommand({"action": "load_file", "path": modelData.path}) + } } } From 7613638370bf53999b20466e27a44c2dae02e7a7 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sun, 15 Mar 2026 05:12:46 -0700 Subject: [PATCH 09/18] feat(plugin): add favorites and multi-select to filesystem browser - Add right-click "Add/Remove from Favorites" on folders in file list and directory tree, fix add_pin bug (missing name parameter) - Add multi-select: Ctrl+click toggles, Shift+click range selects - Add "Load N Selected Files" context menu and batch double-click - Add directory tree right-click context menu with favorites and scan - Remove duplicate unreachable remove_pin handler Co-Authored-By: Claude Opus 4.6 --- .../filesystem_browser/filesystem_browser.py | 24 +++- .../qml/FilesystemBrowser.1/DirectoryTree.qml | 70 ++++++++++- .../FilesystemBrowser.1/FilesystemBrowser.qml | 119 +++++++++++++++++- 3 files changed, 200 insertions(+), 13 deletions(-) diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py index 30a9c363b..52779cea2 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py +++ b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py @@ -480,6 +480,11 @@ def attribute_changed(self, attribute, role): file_path = data.get("path") self.load_file(file_path) + elif action == "load_files": + paths = data.get("paths", []) + if paths: + self._load_multiple_files(paths) + elif action == "preview_file": file_path = data.get("path") self._preview_file(file_path) @@ -514,7 +519,12 @@ def attribute_changed(self, attribute, role): elif action == "add_pin": path = data.get("path") - self._add_pin(path) + name = data.get("name") + if not name and path: + # Derive name from path: use last directory component + stripped = path.rstrip("/\\") + name = stripped.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] or path + self._add_pin(name, path) elif action == "remove_pin": path = data.get("path") @@ -534,10 +544,6 @@ def attribute_changed(self, attribute, role): current = self.current_path_attr.value() self.start_search(current, force=True, depth=20) - elif action == "remove_pin": - path = data.get("path") - self._remove_pin(path) - elif action == "get_subdirs": path = data.get("path") self._get_subdirs(path) @@ -1026,6 +1032,14 @@ def load_file(self, path): import traceback traceback.print_exc() + def _load_multiple_files(self, paths): + """Load multiple files into the current playlist.""" + print(f"FilesystemBrowser: Loading {len(paths)} files") + for path in paths: + try: + self.load_file(path) + except Exception as e: + print(f"Error loading file {path}: {e}") def apply_filters(self): diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml index baef409f9..fc0b095a4 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -10,6 +10,7 @@ Rectangle { // Properties to communicate with parent property var pluginData: null property var currentPath: "/" + property var pinnedList: [] signal sendCommand(var cmd) @@ -404,10 +405,73 @@ Rectangle { id: msgMouse anchors.fill: parent hoverEnabled: true - acceptedButtons: Qt.LeftButton - + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { - sendTreeCommand({"action": "change_path", "path": model.path}); + if (mouse.button === Qt.RightButton) { + treeContextMenu.targetPath = model.path + treeContextMenu.targetName = model.name + treeContextMenu.popup() + } else { + sendTreeCommand({"action": "change_path", "path": model.path}); + } + } + } + + Menu { + id: treeContextMenu + + property string targetPath: "" + property string targetName: "" + + background: Rectangle { + implicitWidth: 180 + implicitHeight: 40 + color: "#333333" + border.color: "#555555" + radius: 3 + } + + delegate: MenuItem { + id: treeMenuItem + contentItem: Text { + text: treeMenuItem.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 10 + } + background: Rectangle { + implicitWidth: 180 + implicitHeight: 25 + color: treeMenuItem.highlighted ? "#555555" : "transparent" + } + } + + MenuItem { + property bool pathIsPinned: { + var p = treeContextMenu.targetPath + if (!p) return false + for (var i = 0; i < treeRoot.pinnedList.length; i++) { + if (treeRoot.pinnedList[i].path === p) return true + } + return false + } + text: pathIsPinned ? "Remove from Favorites" : "Add to Favorites" + onTriggered: { + if (pathIsPinned) { + sendTreeCommand({"action": "remove_pin", "path": treeContextMenu.targetPath}) + } else { + sendTreeCommand({"action": "add_pin", "name": treeContextMenu.targetName, "path": treeContextMenu.targetPath}) + } + } + } + + MenuItem { + text: "Scan" + onTriggered: sendTreeCommand({"action": "force_scan", "path": treeContextMenu.targetPath}) } } diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 35492abc0..f1a2c8e7c 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -101,6 +101,41 @@ Rectangle { property var historyList: [] property var pinnedList: [] property var combinedList: [] + property var selectedFiles: [] // Array of indices selected in file list + + function isFileSelected(index) { + return selectedFiles.indexOf(index) !== -1 + } + + function clearSelection() { + selectedFiles = [] + } + + function toggleFileSelection(index) { + var sel = selectedFiles.slice() + var pos = sel.indexOf(index) + if (pos !== -1) { + sel.splice(pos, 1) + } else { + sel.push(index) + } + selectedFiles = sel + } + + function selectRange(fromIndex, toIndex) { + var sel = selectedFiles.slice() + var start = Math.min(fromIndex, toIndex) + var end = Math.max(fromIndex, toIndex) + for (var i = start; i <= end; i++) { + var item = visibleTreeList[i] + if (item && !item.isFolder) { + if (sel.indexOf(i) === -1) { + sel.push(i) + } + } + } + selectedFiles = sel + } function updateCombinedList() { var combined = [] @@ -169,6 +204,7 @@ Rectangle { function updateList() { var rawVal = value + root.clearSelection() try { if (typeof(rawVal) === "string" && rawVal !== "") { if (rawVal === "[]") { @@ -934,9 +970,10 @@ Rectangle { id: dirTree Layout.fillWidth: true Layout.fillHeight: true - + pluginData: pluginData currentPath: current_path_attr.value + pinnedList: root.pinnedList onSendCommand: (cmd) => root.sendCommand(cmd) @@ -1720,7 +1757,7 @@ Rectangle { width: totalContentWidth height: rowHeight - property bool isSelected: ListView.isCurrentItem + property bool isSelected: ListView.isCurrentItem || root.isFileSelected(index) property bool isHovered: false property string itemPath: modelData.path property bool isItemFolder: modelData.isFolder @@ -1778,16 +1815,25 @@ Rectangle { onClicked: (mouse) => { if (dragActive) return; // Don't click after drag - fileListView.currentIndex = index fileListView.forceActiveFocus() if (mouse.button === Qt.RightButton) { + fileListView.currentIndex = index contextMenu.popup() } else if (mouse.button === Qt.LeftButton) { if (modelData.isFolder) { + root.clearSelection() sendCommand({"action": "change_path", "path": modelData.path}) } else { - root.pendingPreviewPath = modelData.path - previewTimer.restart() + if (mouse.modifiers & Qt.ControlModifier) { + root.toggleFileSelection(index) + } else if (mouse.modifiers & Qt.ShiftModifier) { + root.selectRange(fileListView.currentIndex, index) + } else { + root.clearSelection() + fileListView.currentIndex = index + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } } } } @@ -1797,6 +1843,19 @@ Rectangle { fileListView.currentIndex = index if (modelData.isFolder) { sendCommand({"action": "change_path", "path": modelData.path}) + } else if (root.selectedFiles.length > 1) { + // Load all selected files + var paths = [] + for (var i = 0; i < root.selectedFiles.length; i++) { + var item = visibleTreeList[root.selectedFiles[i]] + if (item && !item.isFolder) { + paths.push(item.path) + } + } + if (paths.length > 0) { + isPreviewMode = false + sendCommand({"action": "load_files", "paths": paths}) + } } else { isPreviewMode = false sendCommand({"action": "load_file", "path": modelData.path}) @@ -1884,14 +1943,64 @@ Rectangle { } } + MenuItem { + text: "Load " + root.selectedFiles.length + " Selected Files" + visible: root.selectedFiles.length > 1 + height: visible ? implicitHeight : 0 + onTriggered: { + var paths = [] + for (var i = 0; i < root.selectedFiles.length; i++) { + var item = visibleTreeList[root.selectedFiles[i]] + if (item && !item.isFolder) { + paths.push(item.path) + } + } + if (paths.length > 0) { + sendCommand({"action": "load_files", "paths": paths}) + } + } + } + MenuSeparator { + visible: root.selectedFiles.length > 1 + height: visible ? implicitHeight : 0 + contentItem: Rectangle { implicitHeight: 1; color: "#555555" } + } MenuItem { text: "Replace" + visible: !modelData.isFolder + height: visible ? implicitHeight : 0 onTriggered: sendCommand({"action": "replace_current_media", "path": modelData.path}) } MenuItem { text: "Compare with" + visible: !modelData.isFolder + height: visible ? implicitHeight : 0 onTriggered: sendCommand({"action": "compare_with_current_media", "path": modelData.path}) } + MenuSeparator { + visible: !modelData.isFolder + height: visible ? implicitHeight : 0 + contentItem: Rectangle { implicitHeight: 1; color: "#555555" } + } + MenuItem { + property bool pathIsPinned: { + if (!modelData || !modelData.path) return false + for (var i = 0; i < pinnedList.length; i++) { + if (pinnedList[i].path === modelData.path) return true + } + return false + } + visible: modelData.isFolder + height: visible ? implicitHeight : 0 + text: pathIsPinned ? "Remove from Favorites" : "Add to Favorites" + onTriggered: { + if (pathIsPinned) { + sendCommand({"action": "remove_pin", "path": modelData.path}) + } else { + sendCommand({"action": "add_pin", "name": modelData.name, "path": modelData.path}) + } + } + } } } } From a1433ee37e924fc385d6b13c6ccf9040a2f08c0a Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sun, 15 Mar 2026 06:38:05 -0700 Subject: [PATCH 10/18] feat(timeline): enable clip drag handles and zoom-to-fit in timeline - Show trim/move drag handles on clip hover in Select mode - Suppress slip handles (leftleft/rightright) in Select mode to avoid visual doubling with basic trim handles - Add clip:true to XsClipItem to prevent handle overflow - Fix rightright drag indicator height when reparented to ancestor - Add Fit Selection and Fit All menu items to timeline context menu Co-Authored-By: Claude Opus 4.6 --- ui/qml/xstudio/views/timeline/XsTimeline.qml | 16 ++++++++-------- .../xstudio/views/timeline/XsTimelineMenu.qml | 18 ++++++++++++++++++ .../views/timeline/widgets/XsClipItem.qml | 18 ++++++++++++++---- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/ui/qml/xstudio/views/timeline/XsTimeline.qml b/ui/qml/xstudio/views/timeline/XsTimeline.qml index 6a5789b80..a712bbec2 100644 --- a/ui/qml/xstudio/views/timeline/XsTimeline.qml +++ b/ui/qml/xstudio/views/timeline/XsTimeline.qml @@ -1565,8 +1565,8 @@ Rectangle { if(!updateRegionTimer.running) updateRegionTimer.start() } else { - if(editMode == "Move" || editMode == "Roll") { - showHandles(mouse.x, mouse.y) + if(editMode == "Select" || editMode == "Move" || editMode == "Roll") { + showHandles(mouse.x, mouse.y, editMode == "Select") } else if(editMode == "Cut") { // calculate timeline frame posisiton. // from mouse position. @@ -1636,7 +1636,7 @@ Rectangle { } } - function showHandles(mousex, mousey) { + function showHandles(mousex, mousey, selectMode) { let [item, item_type, local_x, local_y] = resolveItem(mousex, mousey) if(hovered != item) { @@ -1645,11 +1645,11 @@ Rectangle { hovered = item if(hovered) { - if(["Select"].includes(editMode)) - return - - if("Clip" == item_type) - theSessionData.updateTimelineItemDragFlag([hovered.modelIndex()], editMode == "Roll", rippleMode, overwriteMode) + if("Clip" == item_type) { + // In Select mode, force ripple=true to suppress slip handles (leftleft/rightright) + let effectiveRipple = selectMode ? true : rippleMode + theSessionData.updateTimelineItemDragFlag([hovered.modelIndex()], editMode == "Roll", effectiveRipple, overwriteMode) + } } } } diff --git a/ui/qml/xstudio/views/timeline/XsTimelineMenu.qml b/ui/qml/xstudio/views/timeline/XsTimelineMenu.qml index 672d7c1a9..b64f0d386 100644 --- a/ui/qml/xstudio/views/timeline/XsTimelineMenu.qml +++ b/ui/qml/xstudio/views/timeline/XsTimelineMenu.qml @@ -63,6 +63,24 @@ XsPopupMenu { onActivated: hideMarkers = !hideMarkers } + XsMenuModelItem { + text: qsTr("Fit Selection") + menuPath: "" + menuItemPosition: 1.6 + menuModelName: timelineMenu.menu_model_name + onActivated: theTimeline.fitItems(timelineSelection.selectedIndexes) + panelContext: timelineMenu.panelContext + } + + XsMenuModelItem { + text: qsTr("Fit All") + menuPath: "" + menuItemPosition: 1.7 + menuModelName: timelineMenu.menu_model_name + onActivated: theTimeline.fitItems() + panelContext: timelineMenu.panelContext + } + XsMenuModelItem { text: qsTr("Frame") menuPath: "Time Mode" diff --git a/ui/qml/xstudio/views/timeline/widgets/XsClipItem.qml b/ui/qml/xstudio/views/timeline/widgets/XsClipItem.qml index 70347cb5a..4fc6b76ef 100644 --- a/ui/qml/xstudio/views/timeline/widgets/XsClipItem.qml +++ b/ui/qml/xstudio/views/timeline/widgets/XsClipItem.qml @@ -42,6 +42,7 @@ XsGradientRectangle { signal tapped(button: int, x: real, y: real, modifiers: int) opacity: isHovered ? 1.0 : isEnabled ? (isLocked ? 0.6 : 1.0) : 0.3 + clip: true border.width: 1 border.color: isHovered ? palette.highlight : (isMissingMedia && isEnabled ? "Red" : (isInvalidRange && isEnabled ? "Orange" : Qt.darker(realColor, 0.8))) @@ -309,9 +310,14 @@ XsGradientRectangle { onHoveredChanged: { if(!isDragging && !rightRightDragHandler.active) { if(hovered) { - dragRightRightIndicator.parent = control.parent.parent.parent.parent + var ancestor = control.parent.parent.parent.parent + dragRightRightIndicator.parent = ancestor + dragRightRightIndicator.y = Qt.binding(function() { return control.mapToItem(ancestor, 0, 0).y }) + dragRightRightIndicator.height = Qt.binding(function() { return control.height }) } else { dragRightRightIndicator.parent = parent + dragRightRightIndicator.y = 0 + dragRightRightIndicator.height = Qt.binding(function() { return parent.height }) } } } @@ -320,8 +326,7 @@ XsGradientRectangle { XsClipDragBoth { id: dragRightRightIndicator visible: (hoveredDragRightRight.hovered && !isDragging) || rightRightDragHandler.active - anchors.top: parent.top - anchors.bottom: parent.bottom + height: parent.height x: control.mapToItem(parent, 0, 0).x + (control.width - width/2) thickness: 2 gap: 4 @@ -340,12 +345,17 @@ XsGradientRectangle { onTranslationChanged: dragging("rightright", translation.x, 0) onActiveChanged: { if(active) { - dragRightRightIndicator.parent = control.parent.parent.parent.parent + var ancestor = control.parent.parent.parent.parent + dragRightRightIndicator.parent = ancestor + dragRightRightIndicator.y = Qt.binding(function() { return control.mapToItem(ancestor, 0, 0).y }) + dragRightRightIndicator.height = Qt.binding(function() { return control.height }) parent.anchors.right = undefined draggingStarted("rightright") } else { draggingStopped("rightright") dragRightRightIndicator.parent = parent + dragRightRightIndicator.y = 0 + dragRightRightIndicator.height = Qt.binding(function() { return parent.height }) parent.anchors.right = parent.parent.right } } From bf77227e6bcb54f21e2a1ee7e0b10d40b7e648c9 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sun, 15 Mar 2026 06:48:26 -0700 Subject: [PATCH 11/18] feat(timeline,browser): add zoom controls, drag-drop, and compare items - Alt+wheel zoom centered on cursor position in timeline - Shift+Z hotkey to zoom-to-fit all clips - Ctrl+wheel zoom improved with multiplicative scaling - Drag files from filesystem browser directly onto timeline tracks - Auto-create video/audio tracks when dropping on Stack - Compare Items: select multiple files, right-click to load in A/B or Grid compare mode Co-Authored-By: Claude Opus 4.6 --- .../filesystem_browser/filesystem_browser.py | 73 ++++++++ .../FilesystemBrowser.1/FilesystemBrowser.qml | 17 ++ ui/qml/xstudio/views/timeline/XsTimeline.qml | 169 +++++++++++++----- 3 files changed, 210 insertions(+), 49 deletions(-) diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py index 52779cea2..d46c88679 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py +++ b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py @@ -485,6 +485,11 @@ def attribute_changed(self, attribute, role): if paths: self._load_multiple_files(paths) + elif action == "compare_items": + paths = data.get("paths", []) + if len(paths) >= 2: + self._compare_items(paths) + elif action == "preview_file": file_path = data.get("path") self._preview_file(file_path) @@ -1278,6 +1283,74 @@ def _compare_with_current_media(self, path): import traceback traceback.print_exc() + def _compare_items(self, paths): + """Load multiple files and set viewport to compare mode.""" + try: + print(f"Compare items: {len(paths)} files") + + # 1. Find a valid playlist + playlist = None + try: + selection = self.connection.api.session.selected_containers + for item in selection: + if hasattr(item, 'add_media') and item.name != "Preview": + playlist = item + break + except Exception: + pass + + if not playlist: + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + playlist = viewed + except Exception: + pass + + if not playlist: + try: + for p in self.connection.api.session.playlists: + if p.name != "Preview": + playlist = p + break + except Exception: + pass + + if not playlist: + print("No playlist found for compare.") + return + + self.connection.api.session.set_on_screen_source(playlist) + + # 2. Add all media items to the playlist + media_uuids = [] + for path in paths: + media = self._add_media_to_playlist(playlist, path) + if media: + media_uuids.append(media.uuid) + + if len(media_uuids) < 2: + print("Could not load enough media for compare.") + return + + # 3. Select all the loaded media items + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection(media_uuids) + + # 4. Set compare mode: A/B for 2 items, Grid for 3+ + if hasattr(playlist, 'playhead'): + if len(media_uuids) == 2: + playlist.playhead.compare_mode = "A/B" + else: + playlist.playhead.compare_mode = "Grid" + + print(f"Compare mode set for {len(media_uuids)} items.") + + except Exception as e: + print(f"Compare items error: {e}") + import traceback + traceback.print_exc() + def _add_to_history(self, path): try: current_history = json.loads(self.history_attr.value()) diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index f1a2c8e7c..3a8a1f629 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -1960,6 +1960,23 @@ Rectangle { } } } + MenuItem { + text: "Compare " + root.selectedFiles.length + " Items" + visible: root.selectedFiles.length >= 2 + height: visible ? implicitHeight : 0 + onTriggered: { + var paths = [] + for (var i = 0; i < root.selectedFiles.length; i++) { + var item = visibleTreeList[root.selectedFiles[i]] + if (item && !item.isFolder) { + paths.push(item.path) + } + } + if (paths.length >= 2) { + sendCommand({"action": "compare_items", "paths": paths}) + } + } + } MenuSeparator { visible: root.selectedFiles.length > 1 height: visible ? implicitHeight : 0 diff --git a/ui/qml/xstudio/views/timeline/XsTimeline.qml b/ui/qml/xstudio/views/timeline/XsTimeline.qml index a712bbec2..d6e2e485e 100644 --- a/ui/qml/xstudio/views/timeline/XsTimeline.qml +++ b/ui/qml/xstudio/views/timeline/XsTimeline.qml @@ -1007,6 +1007,15 @@ Rectangle { componentName: "Timeline" } + XsHotkey { + context: hotkey_area_id + sequence: "Shift+Z" + name: "Fit All" + description: "Zoom timeline to fit all clips" + onActivated: fitItems() + componentName: "Timeline" + } + function jumpToNextMarker() { let m = markerModel() let current = timelinePlayhead.logicalFrame; @@ -1615,15 +1624,34 @@ Rectangle { scaleY = Math.max(0.6, scaleY - 0.2) } wheel.accepted = true - } else if(wheel.modifiers == Qt.ControlModifier) { + } else if(wheel.modifiers == Qt.ControlModifier || wheel.modifiers == Qt.AltModifier) { + let stackItem = list_view.itemAtIndex(0) + if(!stackItem) { wheel.accepted = true; return } + + // Calculate frame at cursor position before zoom + let cursorScreenX = wheel.x - trackHeaderWidth + let oldScale = scaleX + let scrollOffset = stackItem.currentPosition() * stackItem.myWidth + let frameAtCursor = stackItem.trimmedStartRole + (scrollOffset + cursorScreenX) / oldScale + + // Apply zoom let tmp = scaleX - if(wheel.angleDelta.y > 1) { - tmp += 0.2 + let zoomFactor = wheel.angleDelta.y > 1 ? 1.15 : (1.0 / 1.15) + tmp = tmp * zoomFactor + let minScale = (list_view.width - trackHeaderWidth) / theSessionData.timelineRect([timeline_items.rootIndex]).width + scaleX = Math.max(minScale, tmp) + + if(wheel.modifiers == Qt.AltModifier) { + // Keep frame at cursor: reposition scroll so frameAtCursor stays at cursorScreenX + let newMyWidth = stackItem.myWidth + let newFramePixel = (frameAtCursor - stackItem.trimmedStartRole) * scaleX + let newScrollOffset = newFramePixel - cursorScreenX + let newPosition = newScrollOffset / newMyWidth + stackItem.jumpToPosition(newPosition) } else { - tmp -= 0.2 + // Ctrl+wheel: center on playhead (original behavior) + stackItem.jumpToFrame(timelinePlayhead.logicalFrame, ListView.Center) } - scaleX = Math.max((list_view.width - trackHeaderWidth) / theSessionData.timelineRect([timeline_items.rootIndex]).width, tmp) - list_view.itemAtIndex(0).jumpToFrame(timelinePlayhead.logicalFrame, ListView.Center) wheel.accepted = true } else if(hovered != null && ["Video Track", "Audio Track","Gap","Clip"].includes(hovered.itemTypeRole)) { if(["Video Track", "Audio Track"].includes(hovered.itemTypeRole)) @@ -1989,18 +2017,35 @@ Rectangle { property bool dragTarget: false property var modelIndex: null property bool newVideoTrack: true + property string dragSource: "" + property var dragUriData: "" + property int dragItemCount: 0 onDragEntered: (mousePosition, source, data) => { - // console.log(source, data) if (source == "MediaList" && typeof data == "object" && data.length) { + dragSource = "MediaList" + dragItemCount = data.length dragTarget = true - processPosition(mousePosition.x, mousePosition.y, data) + processPosition(mousePosition.x, mousePosition.y) + } else if (source == "External URIS" || source == "External JSON") { + dragSource = source + dragUriData = data + // Count URIs: split by newline, filter empty + if (typeof data === "string") { + dragItemCount = data.split("\n").filter(function(s){ return s.trim().length > 0 }).length + } else { + dragItemCount = 1 + } + dragTarget = true + processPosition(mousePosition.x, mousePosition.y) } } onDragExited: { if(dragTarget) { dragTarget = false + dragSource = "" + dragItemCount = 0 modelIndex = null dragInsert.visible = false dragPrepend.visible = false @@ -2011,55 +2056,81 @@ Rectangle { onDragged: (mousePosition, source, data) => { if(dragTarget) - processPosition(mousePosition.x, mousePosition.y, data) + processPosition(mousePosition.x, mousePosition.y) } onDropped: (mousePosition, source, data) => { if (dragTarget) { - processPosition(mousePosition.x, mousePosition.y, data) + processPosition(mousePosition.x, mousePosition.y) if(modelIndex) { - // determine what we need to do.. - let type = modelIndex.model.get(modelIndex, "typeRole") - let new_indexes = theSessionData.moveRows( - data, - -1, // insertion row: make invalid so always inserts on the end - timeline_items.rootIndex.parent, - true - ) - if(dragReplace.visible) { - // replace clip media. - modelIndex.model.set( - modelIndex, - modelIndex.model.get(new_indexes[0],"actorUuidRole"), - "clipMediaUuidRole" - ) - } else { - let clipRow = 0; - let clipParent = null - - if(type == "Video Track" || type == "Audio Track") { - // append - clipParent = modelIndex - clipRow = modelIndex.model.rowCount(modelIndex) - } else if(type == "Stack") { - // new track, but which ? audio or video.. - if(newVideoTrack) { - clipParent = addTrack("Video Track")[0] - clipRow = 0 + + if (dragSource == "External URIS" || dragSource == "External JSON") { + // File URI drop from filesystem browser or external source. + // Use handleDropFuture which creates media + clips automatically. + let dropData = (dragSource == "External URIS") + ? {"text/uri-list": dragUriData} + : dragUriData + let targetIndex = modelIndex + let type = modelIndex.model.get(modelIndex, "typeRole") + + // If dropping on the Stack, create a new track first + if (type == "Stack") { + if (newVideoTrack) { + targetIndex = addTrack("Video Track")[0] } else { - clipParent = addTrack("Audio Track")[0] - clipRow = 0 + targetIndex = addTrack("Audio Track")[0] } - } else { - clipParent = modelIndex.parent - clipRow = modelIndex.row } - for (var c = 0; c < new_indexes.length; ++c) { - // must add to container.. - theSessionData.insertTimelineClip(c + clipRow, clipParent, new_indexes[c], "") - } + Future.promise( + theSessionData.handleDropFuture( + Qt.CopyAction, + dropData, + targetIndex) + ).then(function(quuids){ + // Media created and clips inserted by C++ + }) + + } else { + // MediaList drop — existing behaviour + let type = modelIndex.model.get(modelIndex, "typeRole") + let new_indexes = theSessionData.moveRows( + data, + -1, + timeline_items.rootIndex.parent, + true + ) + if(dragReplace.visible) { + modelIndex.model.set( + modelIndex, + modelIndex.model.get(new_indexes[0],"actorUuidRole"), + "clipMediaUuidRole" + ) + } else { + let clipRow = 0; + let clipParent = null + + if(type == "Video Track" || type == "Audio Track") { + clipParent = modelIndex + clipRow = modelIndex.model.rowCount(modelIndex) + } else if(type == "Stack") { + if(newVideoTrack) { + clipParent = addTrack("Video Track")[0] + clipRow = 0 + } else { + clipParent = addTrack("Audio Track")[0] + clipRow = 0 + } + } else { + clipParent = modelIndex.parent + clipRow = modelIndex.row + } + + for (var c = 0; c < new_indexes.length; ++c) { + theSessionData.insertTimelineClip(c + clipRow, clipParent, new_indexes[c], "") + } + } } modelIndex = null } @@ -2072,7 +2143,7 @@ Rectangle { } } - function processPosition(x, y, data) { + function processPosition(x, y) { let [item, item_type, local_x, local_y] = resolveItem(x, y) let handle = 16 let show_dragPrepend = false @@ -2113,7 +2184,7 @@ Rectangle { show_dragInsert = true modelIndex = item.modelIndex().model.index(item_row+1,0,item.modelIndex().parent) } - } else if(data.length == 1 && item_type == "Clip"){ + } else if(dragItemCount == 1 && item_type == "Clip"){ let ppos = mapFromItem(item, 0, 0) dragReplace.x = ppos.x dragReplace.y = ppos.y From 0b1d24df2068c6d948833dbb3847e1e53996fd38 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sun, 15 Mar 2026 16:55:34 -0700 Subject: [PATCH 12/18] feat(browser): add "Add to Timeline" context menu and fix drag-drop - Add "Add to Timeline" right-click menu item in filesystem browser that creates clips directly on the active sequence (or creates a new one) - Fix Playlist type checking: use isinstance(item, Playlist) instead of hasattr(item, 'add_media') to prevent Timeline/Subset crashes - Fix drag-drop URI format: use file:/// (three slashes) for Windows paths - Add text/uri-list detection in global drag handler for Drag.Internal drops - Add External source handling in timeline drag handler - Document QML rebuild requirement in CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 9 ++ .../filesystem_browser/filesystem_browser.py | 137 ++++++++++++------ .../FilesystemBrowser.1/FilesystemBrowser.qml | 25 +++- ui/qml/xstudio/views/timeline/XsTimeline.qml | 12 ++ .../drag_drop/XsGlobalDragDropHandler.qml | 6 + 5 files changed, 144 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ea8ed6daa..6e232ddb8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,15 @@ cp build/src/plugin/media_reader/ffmpeg/src/Release/media_reader_ffmpeg.dll port **WARNING: Plugins are loaded from `portable/share/xstudio/plugin/`, NOT `portable/bin/`. Deploying plugin DLLs to the wrong directory means the old plugin runs silently.** +### QML Changes ALWAYS Require Rebuild +**Even Python plugin QML files** (in `portable/share/xstudio/plugin-python/`) may require a rebuild to take effect due to Qt's QML caching. Always rebuild and redeploy after ANY QML change, whether it's in compiled `ui/qml/` or runtime-loaded plugin QML. + +Remember: delete autogen before rebuilding QML changes: +```bash +rm -rf build/src/launch/xstudio/src/xstudio_autogen/ +cmake --build build --config Release --target xstudio +``` + ### Key Build Details - Generator: Visual Studio 17 2022 - vcpkg for package management (toolchain at ../vcpkg/) diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py index d46c88679..a24ecf2fb 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py +++ b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py @@ -3,6 +3,8 @@ from xstudio.plugin import PluginBase from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid +from xstudio.api.session.playlist.playlist import Playlist +from xstudio.api.session.playlist.timeline import Timeline import os import sys import json @@ -490,6 +492,11 @@ def attribute_changed(self, attribute, role): if len(paths) >= 2: self._compare_items(paths) + elif action == "add_to_timeline": + paths = data.get("paths", []) + if paths: + self._add_to_timeline(paths) + elif action == "preview_file": file_path = data.get("path") self._preview_file(file_path) @@ -817,7 +824,7 @@ def load_file(self, path): try: selection = self.connection.api.session.selected_containers for item in selection: - if hasattr(item, 'add_media') and item.name != "Preview": + if isinstance(item, Playlist) and item.name != "Preview": valid_playlist = item self.last_used_playlist_uuid = item.uuid print(f"Targeting Selected Playlist: {item.name}") @@ -841,7 +848,7 @@ def load_file(self, path): if not valid_playlist: try: viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media') and viewed.name != "Preview": + if isinstance(viewed, Playlist) and viewed.name != "Preview": valid_playlist = viewed self.last_used_playlist_uuid = viewed.uuid print(f"Targeting Viewed Playlist: {viewed.name}") @@ -877,7 +884,25 @@ def load_file(self, path): self.preview_playlist_uuid = None playlist = valid_playlist - + + # Guard: if resolved container is not a real Playlist (e.g. Timeline, + # Subset, ContactSheet), fall back to the first actual Playlist in the + # session. Only Playlist.add_media() accepts string paths. + if playlist is not None and not isinstance(playlist, Playlist): + print(f"Container '{playlist.name}' is not a Playlist, searching for fallback...") + fallback = [p for p in self.connection.api.session.playlists + if isinstance(p, Playlist) and p.name != "Preview"] + if fallback: + playlist = fallback[0] + self.last_used_playlist_uuid = playlist.uuid + print(f"Fell back to Playlist: {playlist.name}") + else: + self.connection.api.session.create_playlist("Filesystem Import") + playlist = [p for p in self.connection.api.session.playlists + if isinstance(p, Playlist) and p.name != "Preview"][0] + self.last_used_playlist_uuid = playlist.uuid + print(f"Created fallback Playlist: {playlist.name}") + # --- Duplicate Check Logic: Local Cache --- if not hasattr(self, 'playlist_path_cache'): self.playlist_path_cache = {} # Dict[uuid_str, set(paths)] @@ -1284,70 +1309,94 @@ def _compare_with_current_media(self, path): traceback.print_exc() def _compare_items(self, paths): - """Load multiple files and set viewport to compare mode.""" + """Load multiple files into the current playlist for comparison.""" + try: + print(f"Compare items: loading {len(paths)} files") + for path in paths: + try: + self.load_file(path) + except Exception as e: + print(f"Error loading {path} for compare: {e}") + print(f"Loaded {len(paths)} files. Use the Compare button in the viewport toolbar to switch compare modes.") + except Exception as e: + print(f"Compare items error: {e}") + import traceback + traceback.print_exc() + + def _add_to_timeline(self, paths): + """Add files as clips on a Timeline.""" try: - print(f"Compare items: {len(paths)} files") + # --- Find a real Playlist --- + valid_playlist = None - # 1. Find a valid playlist - playlist = None try: - selection = self.connection.api.session.selected_containers - for item in selection: - if hasattr(item, 'add_media') and item.name != "Preview": - playlist = item + for item in self.connection.api.session.selected_containers: + if isinstance(item, Playlist) and item.name != "Preview": + valid_playlist = item break except Exception: pass - if not playlist: + if not valid_playlist: try: viewed = self.connection.api.session.viewed_container - if hasattr(viewed, 'add_media') and viewed.name != "Preview": - playlist = viewed + if isinstance(viewed, Playlist) and viewed.name != "Preview": + valid_playlist = viewed except Exception: pass - if not playlist: + if not valid_playlist: + playlists = [p for p in self.connection.api.session.playlists if p.name != "Preview"] + if playlists: + valid_playlist = playlists[0] + else: + self.connection.api.session.create_playlist("Filesystem Import") + valid_playlist = [p for p in self.connection.api.session.playlists if p.name != "Preview"][0] + + # --- Find or create a Timeline --- + timeline = None + + try: + viewed = self.connection.api.session.viewed_container + if isinstance(viewed, Timeline): + timeline = viewed + except Exception: + pass + + if not timeline: try: - for p in self.connection.api.session.playlists: - if p.name != "Preview": - playlist = p + for container in valid_playlist.containers: + if isinstance(container, Timeline): + timeline = container break except Exception: pass - if not playlist: - print("No playlist found for compare.") - return + if not timeline: + _uuid, timeline = valid_playlist.create_timeline(name="Timeline", with_tracks=True) - self.connection.api.session.set_on_screen_source(playlist) + # --- Get the first video track --- + video_tracks = timeline.video_tracks + if video_tracks: + video_track = video_tracks[0] + else: + video_track = timeline.insert_video_track() - # 2. Add all media items to the playlist - media_uuids = [] + # --- Add each file as a clip --- + added = 0 for path in paths: - media = self._add_media_to_playlist(playlist, path) - if media: - media_uuids.append(media.uuid) - - if len(media_uuids) < 2: - print("Could not load enough media for compare.") - return - - # 3. Select all the loaded media items - if hasattr(playlist, 'playhead_selection'): - playlist.playhead_selection.set_selection(media_uuids) - - # 4. Set compare mode: A/B for 2 items, Grid for 3+ - if hasattr(playlist, 'playhead'): - if len(media_uuids) == 2: - playlist.playhead.compare_mode = "A/B" - else: - playlist.playhead.compare_mode = "Grid" + try: + media = valid_playlist.add_media(path) + timeline.add_media(media) + video_track.insert_clip(media, index=-1) + added += 1 + except Exception as e: + print(f"Error adding clip {path}: {e}") - print(f"Compare mode set for {len(media_uuids)} items.") + print(f"Added {added}/{len(paths)} clip(s) to timeline.") except Exception as e: - print(f"Compare items error: {e}") + print(f"Add to timeline error: {e}") import traceback traceback.print_exc() diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml index 3a8a1f629..9246cd56d 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml +++ b/portable/share/xstudio/plugin-python/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -1647,7 +1647,7 @@ Rectangle { width: 1; height: 1 property string filePath: "" - Drag.mimeData: { "text/uri-list": "file://" + filePath } + Drag.mimeData: { "text/uri-list": "file:///" + filePath } Drag.supportedActions: Qt.CopyAction Drag.dragType: Drag.Internal Drag.keys: ["text/uri-list"] @@ -1977,6 +1977,29 @@ Rectangle { } } } + MenuItem { + text: root.selectedFiles.length > 1 + ? "Add " + root.selectedFiles.length + " to Timeline" + : "Add to Timeline" + visible: !modelData.isFolder + height: visible ? implicitHeight : 0 + onTriggered: { + var paths = [] + if (root.selectedFiles.length > 0) { + for (var i = 0; i < root.selectedFiles.length; i++) { + var item = visibleTreeList[root.selectedFiles[i]] + if (item && !item.isFolder) { + paths.push(item.path) + } + } + } else if (modelData && !modelData.isFolder) { + paths.push(modelData.path) + } + if (paths.length > 0) { + sendCommand({"action": "add_to_timeline", "paths": paths}) + } + } + } MenuSeparator { visible: root.selectedFiles.length > 1 height: visible ? implicitHeight : 0 diff --git a/ui/qml/xstudio/views/timeline/XsTimeline.qml b/ui/qml/xstudio/views/timeline/XsTimeline.qml index d6e2e485e..cb84e309c 100644 --- a/ui/qml/xstudio/views/timeline/XsTimeline.qml +++ b/ui/qml/xstudio/views/timeline/XsTimeline.qml @@ -2038,6 +2038,12 @@ Rectangle { } dragTarget = true processPosition(mousePosition.x, mousePosition.y) + } else if (source == "External") { + // Native Qt drag (e.g. filesystem browser plugin) — accept as drag target + // so that onDropped fires when the actual drop arrives with "External URIS" + dragSource = "External" + dragItemCount = 1 + dragTarget = true } } @@ -2061,6 +2067,12 @@ Rectangle { onDropped: (mousePosition, source, data) => { if (dragTarget) { + // Update source/data for external drops — during drag the source + // is "External" but the drop event has the real source and data + if (source == "External URIS" || source == "External JSON") { + dragSource = source + dragUriData = data + } processPosition(mousePosition.x, mousePosition.y) if(modelIndex) { diff --git a/ui/qml/xstudio/windows/drag_drop/XsGlobalDragDropHandler.qml b/ui/qml/xstudio/windows/drag_drop/XsGlobalDragDropHandler.qml index a2439f40a..03f54443f 100644 --- a/ui/qml/xstudio/windows/drag_drop/XsGlobalDragDropHandler.qml +++ b/ui/qml/xstudio/windows/drag_drop/XsGlobalDragDropHandler.qml @@ -95,6 +95,12 @@ DropArea { }) dragFinished(Qt.point(drop.x, drop.y), "External URIS", uris) drop.accept() + } else if (drop.keys.indexOf("text/uri-list") !== -1) { + // Internal drag with URI data (e.g. filesystem browser plugin) + // hasUrls is false for Drag.Internal, but we have URI data in mime + let uriData = drop.getDataAsString("text/uri-list") + dragFinished(Qt.point(drop.x, drop.y), "External URIS", uriData + "\n") + drop.accept() } else { // prepare drop data From 96dc8641a81f5a4fb771664636a4be06cf7f0df2 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Sun, 15 Mar 2026 17:20:28 -0700 Subject: [PATCH 13/18] fix(exr): handle multi-level hierarchical EXR channel names EXR files with hierarchical channel names (e.g. bg_wall.Combined.R, fg_rocks.Diffuse Direct.B) were causing "Unable to choose decoder routines" errors. The layer grouping split on the first dot, creating layers with 30+ channels, but the reader only supports 4 channels per layer (RGBA). Reading past the 4-element pix_type array produced garbage pixel types that OpenEXR's decoder rejected. Fix: split on the last dot (rfind) so bg_wall.Combined.R produces layer "bg_wall.Combined" with channel "R". Also add defensive caps to prevent array out-of-bounds if a layer still exceeds 4 channels. Also fix filesystem browser sequence path handling for Add to Timeline. Co-Authored-By: Claude Opus 4.6 --- .../filesystem_browser/filesystem_browser.py | 24 +++++++++++++------ .../media_reader/openexr/src/openexr.cpp | 14 ++++++++--- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py index a24ecf2fb..3b379f620 100644 --- a/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py +++ b/portable/share/xstudio/plugin-python/filesystem_browser/filesystem_browser.py @@ -1325,6 +1325,7 @@ def _compare_items(self, paths): def _add_to_timeline(self, paths): """Add files as clips on a Timeline.""" + print(f"[DEBUG _add_to_timeline] received paths: {paths}") try: # --- Find a real Playlist --- valid_playlist = None @@ -1386,7 +1387,7 @@ def _add_to_timeline(self, paths): added = 0 for path in paths: try: - media = valid_playlist.add_media(path) + media = self._add_media_to_playlist(valid_playlist, path) timeline.add_media(media) video_track.insert_clip(media, index=-1) added += 1 @@ -1562,25 +1563,34 @@ def _add_media_to_playlist(self, playlist, path): """Helper to add media handling sequences.""" import os try: + print(f"[DEBUG _add_media_to_playlist] input path: {path}") tgt_path = os.path.normpath(os.path.abspath(path)) - + # Check for sequence if fileseq_available: try: seq = fileseq.FileSequence(path) - if len(seq) > 1: + print(f"[DEBUG _add_media_to_playlist] fileseq parsed: len={len(seq)}, padding='{seq.padding()}', frameSet={seq.frameSet()}") + if len(seq) >= 1 and seq.frameSet() is not None: dirname = seq.dirname() basename = seq.basename() pad_str = seq.padding() - pad_len = len(pad_str) if pad_str else 0 + if pad_str == '#': + pad_len = 4 + else: + pad_len = len(pad_str) if pad_str else 0 brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "" frames = str(seq.frameSet()) ext = seq.extension() seq_path = f"{dirname}{basename}{brace_padding}{ext}={frames}" + print(f"[DEBUG _add_media_to_playlist] sequence path for C++: {seq_path}") return playlist.add_media(seq_path) - except: - pass - + else: + print(f"[DEBUG _add_media_to_playlist] len<=1, falling through to raw path") + except Exception as e: + print(f"[DEBUG _add_media_to_playlist] fileseq parse error: {e}") + + print(f"[DEBUG _add_media_to_playlist] sending raw path to C++: {path}") return playlist.add_media(path) except Exception as e: print(f"Add media error: {e}") diff --git a/src/plugin/media_reader/openexr/src/openexr.cpp b/src/plugin/media_reader/openexr/src/openexr.cpp index cba3a6fdf..e1d5bed6e 100644 --- a/src/plugin/media_reader/openexr/src/openexr.cpp +++ b/src/plugin/media_reader/openexr/src/openexr.cpp @@ -463,6 +463,8 @@ ImageBufPtr OpenEXRMediaReader::image(const media::AVFrameID &mptr) { exr_channels_to_load.begin(), exr_channels_to_load.end(), [&](const std::string chan_name) { + if (ii >= 4) + return; // safety: pix_type array has only 4 elements std::string chan_lower_case = to_lower(chan_name); Imf::PixelType channel_type = pix_type[ii++]; @@ -505,10 +507,11 @@ void OpenEXRMediaReader::get_channel_names_by_layer( const auto &channels = header.channels(); for (Imf::ChannelList::ConstIterator i = channels.begin(); i != channels.end(); ++i) { const std::string channel_name = i.name(); - const size_t dot_pos = channel_name.find("."); + const size_t dot_pos = channel_name.rfind("."); if (dot_pos != std::string::npos && dot_pos) { - // channel name has a dot separator - assume prefix is the 'layer' name which - // we shall assign as a separate MediaStream + // channel name has a dot separator - split on LAST dot so that + // hierarchical names like "bg_wall.Combined.R" produce layer + // "bg_wall.Combined" with channel "R" (not 30+ channels under "bg_wall") std::string layer_name = std::string(channel_name, 0, dot_pos); channel_names_by_layer[partname + layer_name].push_back(channel_name); } else { @@ -580,6 +583,11 @@ std::array OpenEXRMediaReader::pick_exr_channels_from_stream_ exr_channels_to_load = channel_names_by_layer[stream_id]; + // cap at 4 channels - the pix_type array and pixel format only support up to 4 + if (exr_channels_to_load.size() > 4) { + exr_channels_to_load.resize(4); + } + if (exr_channels_to_load.empty()) { throw std::runtime_error("Unable to match stream ID with exr part/layer names."); } From ce75b6a1522ffd7dd3a79ab894010ce0dcaa5366 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Mon, 16 Mar 2026 06:38:35 -0700 Subject: [PATCH 14/18] feat(build): add cross-platform build scripts for Windows, macOS, and Linux Shell script and batch file wrapping CMake configure/build/deploy workflow with auto-detected presets, portable deployment, and QML cache management. Co-Authored-By: Claude Opus 4.6 --- build.bat | 183 +++++++++++++++++++++++++++++++++++++++++++ build.sh | 227 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 build.bat create mode 100644 build.sh diff --git a/build.bat b/build.bat new file mode 100644 index 000000000..52f59617d --- /dev/null +++ b/build.bat @@ -0,0 +1,183 @@ +@echo off +setlocal enabledelayedexpansion +REM xSTUDIO Build Script for Windows +REM Usage: build.bat [command] [options] +REM +REM Commands: +REM configure - Run CMake configure step only +REM build - Build xstudio (runs configure if needed) +REM deploy - Copy built binaries to portable\ +REM all - configure + build + deploy (default) +REM clean - Remove build directory +REM clean-qml - Delete QML autogen cache (required after QML changes) +REM +REM Options: +REM --preset - CMake preset (default: WinRelease) +REM --config - Build type: Release|RelWithDebInfo|Debug (default: Release) +REM --target - Build target (default: xstudio) +REM --no-deploy - Skip deploy step in 'all' command + +cd /d "%~dp0" + +REM ---------- Defaults ---------- +set "BUILD_DIR=build" +set "CONFIG=Release" +set "TARGET=xstudio" +set "COMMAND=all" +set "PRESET=" +set "NO_DEPLOY=0" + +REM ---------- Parse arguments ---------- +:parse_args +if "%~1"=="" goto :end_parse + +if /i "%~1"=="configure" ( set "COMMAND=configure" & shift & goto :parse_args ) +if /i "%~1"=="build" ( set "COMMAND=build" & shift & goto :parse_args ) +if /i "%~1"=="deploy" ( set "COMMAND=deploy" & shift & goto :parse_args ) +if /i "%~1"=="all" ( set "COMMAND=all" & shift & goto :parse_args ) +if /i "%~1"=="clean" ( set "COMMAND=clean" & shift & goto :parse_args ) +if /i "%~1"=="clean-qml" ( set "COMMAND=clean-qml" & shift & goto :parse_args ) + +if /i "%~1"=="--preset" ( set "PRESET=%~2" & shift & shift & goto :parse_args ) +if /i "%~1"=="--config" ( set "CONFIG=%~2" & shift & shift & goto :parse_args ) +if /i "%~1"=="--target" ( set "TARGET=%~2" & shift & shift & goto :parse_args ) +if /i "%~1"=="--no-deploy" ( set "NO_DEPLOY=1" & shift & goto :parse_args ) + +if /i "%~1"=="-h" goto :show_help +if /i "%~1"=="--help" goto :show_help + +echo [ERROR] Unknown argument: %~1 +exit /b 1 + +:end_parse + +REM ---------- Resolve preset ---------- +if "%PRESET%"=="" ( + if /i "%CONFIG%"=="Release" set "PRESET=WinRelease" + if /i "%CONFIG%"=="RelWithDebInfo" set "PRESET=WinRelWithDebInfo" + if /i "%CONFIG%"=="Debug" set "PRESET=WinDebug" + if "%PRESET%"=="" set "PRESET=WinRelease" +) + +REM ---------- Dispatch command ---------- +if /i "%COMMAND%"=="configure" goto :do_configure +if /i "%COMMAND%"=="build" goto :do_build +if /i "%COMMAND%"=="deploy" goto :do_deploy +if /i "%COMMAND%"=="clean" goto :do_clean +if /i "%COMMAND%"=="clean-qml" goto :do_clean_qml +if /i "%COMMAND%"=="all" goto :do_all +goto :eof + +REM ========================================================== +:do_configure +echo [INFO] Configuring with preset: %PRESET% +cmake --preset %PRESET% +if errorlevel 1 ( + echo [ERROR] Configure failed + exit /b 1 +) +echo [OK] Configure complete +goto :eof + +REM ========================================================== +:do_build +if not exist "%BUILD_DIR%" ( + echo [WARN] Build directory not found, running configure first... + call :do_configure + if errorlevel 1 exit /b 1 +) +echo [INFO] Building target '%TARGET%' (%CONFIG%)... +cmake --build %BUILD_DIR% --config %CONFIG% --target %TARGET% +if errorlevel 1 ( + echo [ERROR] Build failed + exit /b 1 +) +echo [OK] Build complete +goto :eof + +REM ========================================================== +:do_deploy +echo [INFO] Deploying to portable\... + +if not exist "portable\bin" mkdir "portable\bin" +if not exist "portable\share\xstudio\plugin" mkdir "portable\share\xstudio\plugin" + +REM --- Main executable --- +if exist "%BUILD_DIR%\bin\%CONFIG%\xstudio.exe" ( + echo xstudio.exe + copy /y "%BUILD_DIR%\bin\%CONFIG%\xstudio.exe" "portable\bin\" >nul +) else ( + echo [WARN] xstudio.exe not found in build output +) + +REM --- Core libraries → portable\bin\ --- +set "CORE_PATHS=src\colour_pipeline\src src\module\src" +for %%P in (%CORE_PATHS%) do ( + if exist "%BUILD_DIR%\%%P\%CONFIG%" ( + for %%F in ("%BUILD_DIR%\%%P\%CONFIG%\*.dll") do ( + echo %%~nxF [core] + copy /y "%%F" "portable\bin\" >nul + ) + ) +) + +REM --- Plugin libraries → portable\share\xstudio\plugin\ --- +echo [INFO] Deploying plugins... +set PLUGIN_COUNT=0 + +REM Walk all plugin subdirectories for Release DLLs (skip test dirs) +for /r "%BUILD_DIR%\src\plugin" %%F in (*.dll) do ( + echo %%F | findstr /i /c:"\test\" >nul + if errorlevel 1 ( + REM Check this is from a Release (or matching config) directory + echo %%F | findstr /i /c:"\%CONFIG%\" >nul + if not errorlevel 1 ( + echo %%~nxF [plugin] + copy /y "%%F" "portable\share\xstudio\plugin\" >nul + set /a PLUGIN_COUNT+=1 + ) + ) +) + +echo [OK] Deployed %PLUGIN_COUNT% plugins +echo [OK] Deploy complete +goto :eof + +REM ========================================================== +:do_clean +echo [INFO] Removing build directory... +if exist "%BUILD_DIR%" rmdir /s /q "%BUILD_DIR%" +echo [OK] Clean complete +goto :eof + +REM ========================================================== +:do_clean_qml +echo [INFO] Cleaning QML autogen cache... +if exist "%BUILD_DIR%\src\launch\xstudio\src\xstudio_autogen" ( + rmdir /s /q "%BUILD_DIR%\src\launch\xstudio\src\xstudio_autogen" +) +echo [OK] QML cache cleaned. Rebuild required. +goto :eof + +REM ========================================================== +:do_all +call :do_configure +if errorlevel 1 exit /b 1 +call :do_build +if errorlevel 1 exit /b 1 +if "%NO_DEPLOY%"=="0" ( + call :do_deploy + if errorlevel 1 exit /b 1 +) +goto :eof + +REM ========================================================== +:show_help +for /f "tokens=1,* delims=:" %%a in ('findstr /n "^REM" "%~f0"') do ( + set "line=%%b" + if defined line ( + set "line=!line:~1!" + echo !line! + ) +) +exit /b 0 diff --git a/build.sh b/build.sh new file mode 100644 index 000000000..065897a7c --- /dev/null +++ b/build.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +# xSTUDIO Build Script for macOS / Linux +# Usage: ./build.sh [command] [options] +# +# Commands: +# configure - Run CMake configure step only +# build - Build xstudio (runs configure if needed) +# deploy - Copy built binaries to portable/ +# all - configure + build + deploy (default) +# clean - Remove build directory +# clean-qml - Delete QML autogen cache (required after QML changes) +# +# Options: +# --preset - CMake preset (default: auto-detected) +# --config - Build type: Release|RelWithDebInfo|Debug (default: Release) +# --target - Build target (default: xstudio) +# --jobs - Parallel build jobs (default: $(nproc)) +# --no-deploy - Skip deploy step in 'all' command + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# ---------- Defaults ---------- +BUILD_DIR="build" +CONFIG="Release" +TARGET="xstudio" +JOBS="" +COMMAND="all" +PRESET="" +NO_DEPLOY=false + +# ---------- Colour output ---------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +# ---------- Detect platform & preset ---------- +detect_platform() { + local os + os="$(uname -s)" + case "$os" in + Darwin) + local arch + arch="$(uname -m)" + if [[ "$arch" == "arm64" ]]; then + echo "MacOSRelease" + else + echo "MacOSIntelRelease" + fi + ;; + Linux) + echo "LinuxRelease" + ;; + *) + err "Unsupported platform: $os" + exit 1 + ;; + esac +} + +resolve_preset() { + if [[ -n "$PRESET" ]]; then + echo "$PRESET" + return + fi + + local base + base="$(detect_platform)" + + # Swap suffix based on CONFIG + case "$CONFIG" in + Release) echo "$base" ;; + RelWithDebInfo) echo "${base%Release}RelWithDebInfo" ;; + Debug) echo "${base%Release}Debug" ;; + *) echo "$base" ;; + esac +} + +# ---------- Shared library extension ---------- +lib_ext() { + case "$(uname -s)" in + Darwin) echo "dylib" ;; + *) echo "so" ;; + esac +} + +# ---------- Parse args ---------- +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + configure|build|deploy|all|clean|clean-qml) + COMMAND="$1" ;; + --preset) shift; PRESET="$1" ;; + --config) shift; CONFIG="$1" ;; + --target) shift; TARGET="$1" ;; + --jobs) shift; JOBS="$1" ;; + --no-deploy) NO_DEPLOY=true ;; + -h|--help) + sed -n '2,/^$/s/^# \?//p' "$0" + exit 0 ;; + *) + err "Unknown argument: $1" + exit 1 ;; + esac + shift + done + + # Default job count + if [[ -z "$JOBS" ]]; then + if command -v nproc &>/dev/null; then + JOBS="$(nproc)" + elif command -v sysctl &>/dev/null; then + JOBS="$(sysctl -n hw.ncpu)" + else + JOBS=4 + fi + fi +} + +# ---------- Commands ---------- +do_configure() { + local preset + preset="$(resolve_preset)" + info "Configuring with preset: ${preset}" + cmake --preset "$preset" + ok "Configure complete" +} + +do_build() { + # Configure if build dir doesn't exist + if [[ ! -d "$BUILD_DIR" ]]; then + warn "Build directory not found, running configure first..." + do_configure + fi + + info "Building target '${TARGET}' (${CONFIG}, ${JOBS} jobs)..." + cmake --build "$BUILD_DIR" --config "$CONFIG" --target "$TARGET" -j "$JOBS" + ok "Build complete" +} + +do_deploy() { + local ext + ext="$(lib_ext)" + + info "Deploying to portable/..." + + # Ensure destination dirs exist + mkdir -p portable/bin + mkdir -p portable/share/xstudio/plugin + + # --- Main executable --- + local exe_src="$BUILD_DIR/bin/xstudio" + if [[ -f "$exe_src" ]]; then + cp -v "$exe_src" portable/bin/ + elif [[ -f "$BUILD_DIR/bin/$CONFIG/xstudio" ]]; then + cp -v "$BUILD_DIR/bin/$CONFIG/xstudio" portable/bin/ + else + warn "xstudio executable not found in build output" + fi + + # --- Core libraries (non-plugin) → portable/bin/ --- + local core_libs=( + "src/colour_pipeline/src" + "src/module/src" + ) + for lib_path in "${core_libs[@]}"; do + local full="$BUILD_DIR/$lib_path" + if [[ -d "$full" ]]; then + find "$full" -maxdepth 1 -name "*.${ext}" -exec cp -v {} portable/bin/ \; + fi + done + + # --- Plugin libraries → portable/share/xstudio/plugin/ --- + info "Deploying plugins..." + local plugin_count=0 + while IFS= read -r -d '' plugin_lib; do + cp -v "$plugin_lib" portable/share/xstudio/plugin/ + ((plugin_count++)) + done < <(find "$BUILD_DIR/src/plugin" -name "*.${ext}" -not -path "*/test/*" -print0 2>/dev/null || true) + + ok "Deployed ${plugin_count} plugins" + ok "Deploy complete" +} + +do_clean() { + info "Removing build directory..." + rm -rf "$BUILD_DIR" + ok "Clean complete" +} + +do_clean_qml() { + info "Cleaning QML autogen cache..." + rm -rf "$BUILD_DIR/src/launch/xstudio/src/xstudio_autogen/" + ok "QML cache cleaned. Rebuild required." +} + +# ---------- Main ---------- +parse_args "$@" + +case "$COMMAND" in + configure) + do_configure ;; + build) + do_build ;; + deploy) + do_deploy ;; + all) + do_configure + do_build + if [[ "$NO_DEPLOY" == false ]]; then + do_deploy + fi + ;; + clean) + do_clean ;; + clean-qml) + do_clean_qml ;; +esac From 24c73ecbad77f91553dedb20ad85f967117a6363 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Mon, 16 Mar 2026 07:14:48 -0700 Subject: [PATCH 15/18] chore: remove CLAUDE.md from version control Added to .gitignore to keep project-specific AI instructions local only. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 87 ------------------------------------------------------- 1 file changed, 87 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6e232ddb8..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,87 +0,0 @@ -# xSTUDIO - Project Guide - -## What is xSTUDIO? -Professional media playback and review application for VFX/film post-production. -- C++17/20, Qt6 QML frontend, CAF (C++ Actor Framework) for concurrency -- OpenEXR, FFmpeg media readers as plugins -- OpenGL GPU-accelerated viewport rendering -- Plugin architecture for media readers, colour operations, etc. - -## Build System - -**IMPORTANT: Always build as STANDALONE.** Building non-standalone causes linking/dependency issues on Windows. - -### Windows Build (Primary) -```bash -# Configure (standalone) -cmake --preset WinRelease - -# Build -cmake --build build --config Release --target xstudio - -# The build output goes to: build/bin/Release/ -``` - -### Portable Deployment (CRITICAL) -**The user runs xSTUDIO from `portable/bin/`, NOT from `build/bin/Release/`.** -After every build, you MUST copy updated binaries to the correct portable locations: -```bash -# Main exe and core DLLs go to portable/bin/ -cp build/bin/Release/xstudio.exe portable/bin/ -cp build/src/colour_pipeline/src/Release/colour_pipeline.dll portable/bin/ -cp build/src/module/src/Release/module.dll portable/bin/ - -# PLUGINS go to portable/share/xstudio/plugin/ (NOT portable/bin/) -cp build/src/plugin/colour_pipeline/ocio/src/Release/colour_pipeline_ocio.dll portable/share/xstudio/plugin/ -cp build/src/plugin/media_reader/openexr/src/Release/media_reader_openexr.dll portable/share/xstudio/plugin/ -cp build/src/plugin/media_reader/ffmpeg/src/Release/media_reader_ffmpeg.dll portable/share/xstudio/plugin/ -# Other plugins also live in portable/share/xstudio/plugin/ -``` -**WARNING: Plugins are loaded from `portable/share/xstudio/plugin/`, NOT `portable/bin/`. -Deploying plugin DLLs to the wrong directory means the old plugin runs silently.** - -### QML Changes ALWAYS Require Rebuild -**Even Python plugin QML files** (in `portable/share/xstudio/plugin-python/`) may require a rebuild to take effect due to Qt's QML caching. Always rebuild and redeploy after ANY QML change, whether it's in compiled `ui/qml/` or runtime-loaded plugin QML. - -Remember: delete autogen before rebuilding QML changes: -```bash -rm -rf build/src/launch/xstudio/src/xstudio_autogen/ -cmake --build build --config Release --target xstudio -``` - -### Key Build Details -- Generator: Visual Studio 17 2022 -- vcpkg for package management (toolchain at ../vcpkg/) -- Qt6 at C:/Qt/6.5.3/msvc2019_64/ -- Presets: WinRelease, WinRelWithDebInfo, WinDebug - -## Architecture -- **Actor Model**: CAF-based distributed actors with message passing -- **Plugin System**: Dynamic loading for media readers, colour ops -- **Registries**: keyboard_events, media_reader_registry, plugin_manager_registry, etc. -- **Thread Pools**: OpenEXR internal (16 threads), 5 ReaderHelper actors, 4 detail readers - -## Key Directories -``` -src/plugin/media_reader/openexr/ - EXR reader plugin -src/media_reader/ - Media reader coordination -src/media_cache/ - Frame caching -src/ui/base/src/keyboard.cpp - Hotkey system -src/ui/viewport/src/keypress_monitor.cpp - Key event distribution -src/ui/qml/viewport/src/hotkey_ui.cpp - Hotkey QML model -src/playhead/ - Playback control -``` - -## Test EXR Sequences -- `L:\tdm\shots\fw\9119\comp\images\FW9119_comp_v001\exr` (81 frames, 1000-1080) - -## Working Style - -**CRITICAL: Claude acts as ORCHESTRATOR, not implementor.** -- Spin up specialized agents (via Agent tool) for all development work: coding, debugging, building, benchmarking -- This prevents context window compaction and keeps the main conversation lean -- The orchestrator reads results from agents, coordinates next steps, and communicates with the user -- Only do trivial edits (like updating CLAUDE.md) directly — everything else gets delegated - -## Issue Tracking -Uses **bd** (beads) for issue tracking. See AGENTS.md for workflow. From fad913b4704bcbf0f4cda4f04b547f2e8f61fd44 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Mon, 16 Mar 2026 10:51:37 -0700 Subject: [PATCH 16/18] bd init: initialize beads issue tracking --- .beads/.beads-credential-key | 2 + .beads/README.md | 81 +++++++++++++++++ .beads/config.yaml | 62 +++++++++++++ .beads/hooks/post-checkout | 24 +++++ .beads/hooks/post-merge | 24 +++++ .beads/hooks/pre-commit | 24 +++++ .beads/hooks/pre-push | 24 +++++ .beads/hooks/prepare-commit-msg | 24 +++++ .beads/issues.jsonl | 0 .beads/metadata.json | 7 ++ .gitignore | 1 + AGENTS.md | 153 ++++++++++++++++++++++++++++++++ 12 files changed, 426 insertions(+) create mode 100644 .beads/.beads-credential-key create mode 100644 .beads/README.md create mode 100644 .beads/config.yaml create mode 100644 .beads/hooks/post-checkout create mode 100644 .beads/hooks/post-merge create mode 100644 .beads/hooks/pre-commit create mode 100644 .beads/hooks/pre-push create mode 100644 .beads/hooks/prepare-commit-msg create mode 100644 .beads/issues.jsonl create mode 100644 .beads/metadata.json create mode 100644 AGENTS.md diff --git a/.beads/.beads-credential-key b/.beads/.beads-credential-key new file mode 100644 index 000000000..b18699582 --- /dev/null +++ b/.beads/.beads-credential-key @@ -0,0 +1,2 @@ +=NL]JȎ`{Ba +iXrL$) \ No newline at end of file diff --git a/.beads/README.md b/.beads/README.md new file mode 100644 index 000000000..50f281f03 --- /dev/null +++ b/.beads/README.md @@ -0,0 +1,81 @@ +# Beads - AI-Native Issue Tracking + +Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. + +## What is Beads? + +Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. + +**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) + +## Quick Start + +### Essential Commands + +```bash +# Create new issues +bd create "Add user authentication" + +# View all issues +bd list + +# View issue details +bd show + +# Update issue status +bd update --status in_progress +bd update --status done + +# Sync with git remote +bd sync +``` + +### Working with Issues + +Issues in Beads are: +- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code +- **AI-friendly**: CLI-first design works perfectly with AI coding agents +- **Branch-aware**: Issues can follow your branch workflow +- **Always in sync**: Auto-syncs with your commits + +## Why Beads? + +✨ **AI-Native Design** +- Built specifically for AI-assisted development workflows +- CLI-first interface works seamlessly with AI coding agents +- No context switching to web UIs + +🚀 **Developer Focused** +- Issues live in your repo, right next to your code +- Works offline, syncs when you push +- Fast, lightweight, and stays out of your way + +🔧 **Git Integration** +- Automatic sync with git commits +- Branch-aware issue tracking +- Intelligent JSONL merge resolution + +## Get Started with Beads + +Try Beads in your own projects: + +```bash +# Install Beads +curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash + +# Initialize in your repo +bd init + +# Create your first issue +bd create "Try out Beads" +``` + +## Learn More + +- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) +- **Quick Start Guide**: Run `bd quickstart` +- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) + +--- + +*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml new file mode 100644 index 000000000..f2427856e --- /dev/null +++ b/.beads/config.yaml @@ -0,0 +1,62 @@ +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: load from JSONL, no SQLite, write back after each command +# When true, bd will use .beads/issues.jsonl as the source of truth +# instead of SQLite database +# no-db: false + +# Disable daemon for RPC communication (forces direct database access) +# no-daemon: false + +# Disable auto-flush of database to JSONL after mutations +# no-auto-flush: false + +# Disable auto-import from JSONL when it's newer than database +# no-auto-import: false + +# Enable JSON output by default +# json: false + +# Default actor for audit trails (overridden by BD_ACTOR or --actor) +# actor: "" + +# Path to database (overridden by BEADS_DB or --db) +# db: "" + +# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) +# auto-start-daemon: true + +# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) +# flush-debounce: "5s" + +# Git branch for beads commits (bd sync will commit to this branch) +# IMPORTANT: Set this for team projects so all clones use the same sync branch. +# This setting persists across clones (unlike database config which is gitignored). +# Can also use BEADS_SYNC_BRANCH env var for local override. +# If not set, bd sync will require you to run 'bd config set sync.branch '. +# sync-branch: "beads-sync" + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct JSONL +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Integration settings (access with 'bd config get/set') +# These are stored in the database, not in this file: +# - jira.url +# - jira.project +# - linear.url +# - linear.api-key +# - github.org +# - github.repo diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout new file mode 100644 index 000000000..2a650b104 --- /dev/null +++ b/.beads/hooks/post-checkout @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.61.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-checkout "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-checkout "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-checkout'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge new file mode 100644 index 000000000..bd1c1750f --- /dev/null +++ b/.beads/hooks/post-merge @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.61.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run post-merge "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run post-merge "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'post-merge'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit new file mode 100644 index 000000000..624440311 --- /dev/null +++ b/.beads/hooks/pre-commit @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.61.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-commit "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-commit "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-commit'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push new file mode 100644 index 000000000..9a5ee48c2 --- /dev/null +++ b/.beads/hooks/pre-push @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.61.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run pre-push "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run pre-push "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'pre-push'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg new file mode 100644 index 000000000..431271376 --- /dev/null +++ b/.beads/hooks/prepare-commit-msg @@ -0,0 +1,24 @@ +#!/usr/bin/env sh +# --- BEGIN BEADS INTEGRATION v0.61.0 --- +# This section is managed by beads. Do not remove these markers. +if command -v bd >/dev/null 2>&1; then + export BD_GIT_HOOK=1 + _bd_timeout=${BEADS_HOOK_TIMEOUT:-30} + if command -v timeout >/dev/null 2>&1; then + timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi + else + bd hooks run prepare-commit-msg "$@" + _bd_exit=$? + fi + if [ $_bd_exit -eq 3 ]; then + echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'" + _bd_exit=0 + fi + if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi +fi +# --- END BEADS INTEGRATION v0.61.0 --- diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl new file mode 100644 index 000000000..e69de29bb diff --git a/.beads/metadata.json b/.beads/metadata.json new file mode 100644 index 000000000..a157d8b38 --- /dev/null +++ b/.beads/metadata.json @@ -0,0 +1,7 @@ +{ + "database": "dolt", + "backend": "dolt", + "dolt_mode": "server", + "dolt_database": "xstudio", + "project_id": "5e1061e6-a211-492b-be86-97480b324076" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 002f0f4cd..472ec4cee 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ python/src/xstudio/version.py /build/ xstudio_install/ **/qml/*_qml_export.h +CLAUDE.md # Dolt database files (added by bd init) .dolt/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..15de6c06b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,153 @@ +# Agent Instructions + +This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --status in_progress # Claim work +bd close # Complete work +bd sync # Sync with git +``` + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd sync + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + + +## Issue Tracking with bd (beads) + +**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. + +### Why bd? + +- Dependency-aware: Track blockers and relationships between issues +- Git-friendly: Dolt-powered version control with native sync +- Agent-optimized: JSON output, ready work detection, discovered-from links +- Prevents duplicate tracking systems and confusion + +### Quick Start + +**Check for ready work:** + +```bash +bd ready --json +``` + +**Create new issues:** + +```bash +bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json +bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json +``` + +**Claim and update:** + +```bash +bd update --claim --json +bd update bd-42 --priority 1 --json +``` + +**Complete work:** + +```bash +bd close bd-42 --reason "Completed" --json +``` + +### Issue Types + +- `bug` - Something broken +- `feature` - New functionality +- `task` - Work item (tests, docs, refactoring) +- `epic` - Large feature with subtasks +- `chore` - Maintenance (dependencies, tooling) + +### Priorities + +- `0` - Critical (security, data loss, broken builds) +- `1` - High (major features, important bugs) +- `2` - Medium (default, nice-to-have) +- `3` - Low (polish, optimization) +- `4` - Backlog (future ideas) + +### Workflow for AI Agents + +1. **Check ready work**: `bd ready` shows unblocked issues +2. **Claim your task atomically**: `bd update --claim` +3. **Work on it**: Implement, test, document +4. **Discover new work?** Create linked issue: + - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:` +5. **Complete**: `bd close --reason "Done"` + +### Auto-Sync + +bd automatically syncs via Dolt: + +- Each write auto-commits to Dolt history +- Use `bd dolt push`/`bd dolt pull` for remote sync +- No manual export/import needed! + +### Important Rules + +- ✅ Use bd for ALL task tracking +- ✅ Always use `--json` flag for programmatic use +- ✅ Link discovered work with `discovered-from` dependencies +- ✅ Check `bd ready` before asking "what should I work on?" +- ❌ Do NOT create markdown TODO lists +- ❌ Do NOT use external issue trackers +- ❌ Do NOT duplicate tracking systems + +For more details, see README.md and docs/QUICKSTART.md. + +## Landing the Plane (Session Completion) + +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. + +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **PUSH TO REMOTE** - This is MANDATORY: + ```bash + git pull --rebase + bd dolt push + git push + git status # MUST show "up to date with origin" + ``` +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds + + From 664604b82e90bd1ba8e92a8c3bc6bd973f7b9fb8 Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Tue, 17 Mar 2026 08:15:43 -0700 Subject: [PATCH 17/18] fix(session): restore saved sessions correctly on Windows Three path handling bugs in URI conversion broke session restore: - reverse_remap_file_path() was passed the original path instead of the resolved absolute path, discarding relative path resolution - Drive letter regex only matched uppercase [A-Z], missing lowercase - file URIs used host("localhost") causing parsing mismatches on deserialization; switched to host("") for standard file:///C:/ form Timeline panel also failed to populate on session load due to: - Model tree container lookup firing before async children arrived; added re-trigger in processChildren() for Session/Playlist types - viewedMediaSetChanged() only handled typeRole=="Timeline" but the restored viewed container is often a Playlist; added logic to find the first Timeline child inside the Playlist and switch to it Also updates README to document fork changes and remove the session restore issue from known issues. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 42 ++++++++++++++++++-- src/ui/qml/session/src/session_model_ui.cpp | 11 +++++ src/utility/src/helpers.cpp | 6 +-- ui/qml/xstudio/views/timeline/XsTimeline.qml | 24 +++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e6c534006..580178207 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,46 @@ -# Welcome to xSTUDIO - v1.1.0 +# xSTUDIO (fork) xSTUDIO is a media playback and review application designed for professionals working in the film and TV post production industries, particularly the Visual Effects and Feature Animation sectors. xSTUDIO is focused on providing an intuitive, easy to use interface with a high performance playback engine at its core and C++ and Python APIs for pipeline integration and customisation for total flexibility. -This codebase will build version 1.0.0 (alpha) of xstudio. There are some known issues that are currently being worked on: +This is a fork of the [original DNEG xSTUDIO](https://github.com/AcademySoftwareFoundation/xstudio) with significant enhancements for Windows usability, EXR performance, and day-to-day review workflows. + +### Known Issues (upstream) * Moderate audio distortion on playback (Windows only) -* Ueser Documentation and API Documentation is badly out-of-date. -* Saved sessions might not restore media correctly (Windows only) +* User documentation and API documentation is out-of-date + +--- + +## What's New in This Fork + +### Filesystem Browser Plugin +New Python plugin with full Windows support for browsing and loading media directly from disk. Includes hierarchical directory tree, favorites/bookmarks, multi-select, and drag-drop into the timeline. + +### Timeline Improvements +Zoom controls with zoom-to-fit, clip drag handles, and drag-drop from the browser. "Add to Timeline" context menu for quick media placement. + +### EXR Layer/AOV Selector +Toolbar dropdown for selecting EXR layers and AOVs directly in the viewport, including support for multi-level hierarchical channel names (e.g. `crypto.R`, `deep.A`). + +### Viewport Controls +Gamma and Saturation sliders in the viewport toolbar. Review mode flag for presentation workflows. Drag-drop media onto the viewport. + +### Hotkey Editor +Full hotkey customization dialog — rebind any keyboard shortcut from the UI. + +### EXR Performance +4 targeted fixes to the EXR read pipeline: optimized frame request queuing, reader actor improvements, and reduced overhead in the media cache path. + +### Session Restore Fix +Fixed saved sessions not restoring correctly on Windows. Three path handling bugs in URI conversion (wrong variable in path remapping, case-sensitive drive letters, localhost authority in file URIs) plus a timing issue where the timeline panel wouldn't populate because the model tree hadn't finished loading. + +### Windows Path Fixes +Resolved path handling bugs that broke EXR sequence loading on Windows (backslash/forward-slash normalization, drive letter handling). + +### Cross-Platform Build Scripts +`build.bat` (Windows) and `build.sh` (macOS/Linux) for one-command builds with automatic preset detection. + +--- ## Building xSTUDIO diff --git a/src/ui/qml/session/src/session_model_ui.cpp b/src/ui/qml/session/src/session_model_ui.cpp index bc4dc8362..f7fa9a371 100644 --- a/src/ui/qml/session/src/session_model_ui.cpp +++ b/src/ui/qml/session/src/session_model_ui.cpp @@ -627,6 +627,17 @@ void SessionModel::processChildren(const nlohmann::json &rj, const QModelIndex & emit dataChanged(parent_index, parent_index, roles); } + // After populating session/playlist children, re-trigger the viewport and + // current container lookup. On session restore the initial lookup fires + // before the model tree is fully built (the children arrive asynchronously), + // so timelines inside playlists are not yet findable. Re-checking here + // ensures the timeline panel picks up the active container once its node + // actually exists in the tree. + if (type == "Session" || type == "Container List" || type == "Playlist") { + updateCurrentMediaContainerIndexFromBackend(); + updateViewportCurrentMediaContainerIndexFromBackend(); + } + emit jsonChanged(); CHECK_SLOW_WATCHER_FAST() diff --git a/src/utility/src/helpers.cpp b/src/utility/src/helpers.cpp index 1985353b2..8b45988da 100644 --- a/src/utility/src/helpers.cpp +++ b/src/utility/src/helpers.cpp @@ -322,7 +322,7 @@ std::string xstudio::utility::uri_to_posix_path(const caf::uri &uri) { #ifdef _WIN32 static const std::regex drive_letter_with_unwanted_leading_fwd_slash( - R"(^\/[A-Z]\:)", std::regex::optimize); + R"(^\/[A-Za-z]\:)", std::regex::optimize); std::cmatch m; if (std::regex_search(path.c_str(), m, drive_letter_with_unwanted_leading_fwd_slash)) { // Remove the leading / @@ -572,7 +572,7 @@ caf::uri xstudio::utility::posix_path_to_uri(const std::string &path, const bool #endif } - p = reverse_remap_file_path(path); + p = reverse_remap_file_path(p); #ifdef _WIN32 // Normalise Windows backslashes to forward slashes for a valid file URI. @@ -588,7 +588,7 @@ caf::uri xstudio::utility::posix_path_to_uri(const std::string &path, const bool if (not p.empty() && p[0] != '/') return caf::uri_builder().scheme("file").path(p).make(); - auto result = caf::uri_builder().scheme("file").host("localhost").path(p).make(); + auto result = caf::uri_builder().scheme("file").host("").path(p).make(); return result; diff --git a/ui/qml/xstudio/views/timeline/XsTimeline.qml b/ui/qml/xstudio/views/timeline/XsTimeline.qml index cb84e309c..52bd9d20a 100644 --- a/ui/qml/xstudio/views/timeline/XsTimeline.qml +++ b/ui/qml/xstudio/views/timeline/XsTimeline.qml @@ -306,6 +306,30 @@ Rectangle { initTimeline() }}(), 50); + } else if (viewedMediaSetProperties.index.valid && viewedMediaSetProperties.values.typeRole == "Playlist") { + // When restoring a session, the viewed container may be a Playlist + // rather than a Timeline. Find the first Timeline child inside the + // Playlist's Container List (row 2) and initialise from it. + let containerListIndex = theSessionData.index(2, 0, viewedMediaSetProperties.index) + let childCount = theSessionData.rowCount(containerListIndex) + for (let i = 0; i < childCount; i++) { + let childIndex = theSessionData.index(i, 0, containerListIndex) + if (theSessionData.get(childIndex, "typeRole") == "Timeline") { + // Switch the viewed container to this Timeline so the + // playhead and viewport are correctly attached. + theSessionData.viewportCurrentMediaContainerIndex = childIndex + return + } + } + // No timeline children found (or not yet loaded). If the container + // list is empty the data may still be arriving asynchronously, so + // retry after a short delay. + if (childCount == 0 && !timeline_items.rootIndex.valid) { + callbackTimer.setTimeout(function() { return function() { + viewedMediaSetChanged() + }}(), 250); + } + } else if (!timeline_items.rootIndex.valid) { // if the user has selected something that is not a timeline (playlist, // subset etc.), we do not update our index here (unless the timeline From a0f1498503f5effd687009e6e3557a0c66a090ea Mon Sep 17 00:00:00 2001 From: Robert Nederhorst Date: Tue, 17 Mar 2026 11:22:26 -0700 Subject: [PATCH 18/18] feat(utility): add printf-style frame pattern support (%04d) Add support for printf-style frame sequence patterns (e.g. %04d, %06d) in parse_cli_posix_path(), complementing the existing hash (####) and fmt ({:04d}) pattern support. This is the standard format used by ShotGrid, Nuke, Houdini, and most VFX production tracking tools. Changes: - helpers.cpp: Add regex matching for %0Nd patterns, converting to {:0Nd} internally. Also handle Nuke-style space-separated frame ranges ("file.%04d.exr 1000-1080") by normalizing to equals syntax. - frame_list.cpp: Add defensive %0Nd -> {:0Nd} conversion in frame_groups_from_sequence_spec() for paths that bypass the main parser. All existing patterns continue to work unchanged. New supported inputs: file.%04d.exr=1000-1080 (printf + equals range) file.%04d.exr 1000-1080 (printf + space range, Nuke style) file.1234.%04d.exr (printf with prefix frame number) /full/path/shot.%06d.exr (variable padding width) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utility/src/frame_list.cpp | 8 +++++++ src/utility/src/helpers.cpp | 42 ++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/utility/src/frame_list.cpp b/src/utility/src/frame_list.cpp index 584860a2d..5069cad0e 100644 --- a/src/utility/src/frame_list.cpp +++ b/src/utility/src/frame_list.cpp @@ -259,6 +259,14 @@ xstudio::utility::frame_groups_from_sequence_spec(const caf::uri &from_path) { try { std::string path = uri_to_posix_path(from_path); + + // Convert any printf-style format specs (%04d etc.) to fmt format ({:04d}) + // in case they survive into this path without prior conversion. + { + const std::regex printf_re("%0(\\d+)d"); + path = std::regex_replace(path, printf_re, "{:0$1d}"); + } + const std::regex spec_re("\\{[^}]+\\}"); const std::regex path_re("^" + std::regex_replace(path, spec_re, "([0-9-]+)") + "$"); #ifdef _WIN32 diff --git a/src/utility/src/helpers.cpp b/src/utility/src/helpers.cpp index 8b45988da..c0090d9b3 100644 --- a/src/utility/src/helpers.cpp +++ b/src/utility/src/helpers.cpp @@ -484,13 +484,36 @@ caf::uri xstudio::utility::parse_cli_posix_path( const std::regex xstudio_prefix_shake( R"(^(.+\.)([-0-9x,]+)([#@]+)(\..+)$)", std::regex::optimize); + // Printf-style pattern: file.%04d.exr + const std::regex xstudio_printf( + R"(^(.+\.)(%0\d+d)(\..+?)(=([-0-9x,]+))?$)", std::regex::optimize); + + // Prefix + printf pattern: file.1234.%04d.exr + const std::regex xstudio_prefix_printf( + R"(^(.+\.)([-0-9x,]+)\.(%0\d+d)(\..+)$)", std::regex::optimize); + + // Convert printf format to fmt format: %04d -> {:04d} + auto printf_to_fmt = [](const std::string &printf_spec) -> std::string { + return "{:" + printf_spec.substr(1, printf_spec.size() - 2) + "d}"; + }; + + // Nuke-style space-separated frame range: "file.%04d.exr 1000-1080" + // Convert to equals syntax: "file.%04d.exr=1000-1080" + static const std::regex space_range_re( + R"(^(.+\.\S+)\s+([-0-9x,]+)$)", std::regex::optimize); + std::string normalized_path = path; + std::smatch space_m; + if (std::regex_match(normalized_path, space_m, space_range_re)) { + normalized_path = space_m[1].str() + "=" + space_m[2].str(); + } + #ifdef _WIN32 - std::string abspath = path; + std::string abspath = normalized_path; if (abspath[0] == '\\') { abspath.erase(abspath.begin()); } #else - const std::string abspath = fs::absolute(path); + const std::string abspath = fs::absolute(normalized_path); #endif @@ -500,6 +523,9 @@ caf::uri xstudio::utility::parse_cli_posix_path( } else if (std::regex_match(abspath.c_str(), m, xstudio_prefix_spec)) { uri = posix_path_to_uri(m[1].str() + m[3].str()); frame_list = FrameList(m[2].str()); + } else if (std::regex_match(abspath.c_str(), m, xstudio_prefix_printf)) { + uri = posix_path_to_uri(m[1].str() + printf_to_fmt(m[3].str()) + m[4].str()); + frame_list = FrameList(m[2].str()); } else if (std::regex_match(abspath.c_str(), m, xstudio_prefix_shake)) { size_t pad_c = 0; if (m[3].str() == "#") { @@ -523,6 +549,18 @@ caf::uri xstudio::utility::parse_cli_posix_path( throw std::runtime_error("No frames specified."); } + } else if (std::regex_match(abspath.c_str(), m, xstudio_printf)) { + uri = posix_path_to_uri(m[1].str() + printf_to_fmt(m[2].str()) + m[3].str()); + + if (not m[5].str().empty()) { + frame_list = FrameList(m[5].str()); + } else if (scan) { + frame_list = FrameList(uri); + } + + if (frame_list.empty()) { + throw std::runtime_error("No frames specified."); + } } else if (std::regex_match(abspath.c_str(), m, xstudio_shake)) { size_t pad_c = 0; if (m[2].str() == "#") {