From 29cd82f2654a35cd13b655c1b446f37b9a67fa39 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Thu, 5 Mar 2026 14:00:13 -0500 Subject: [PATCH 01/22] Add default fallback icon for sources in session manager Signed-off-by: chenl1 --- .../session_manager/fallback_thumbnail.png | Bin 0 -> 773 bytes .../session_manager/session_manager.mu.in | 27 ++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/plugins/rv-packages/session_manager/fallback_thumbnail.png diff --git a/src/plugins/rv-packages/session_manager/fallback_thumbnail.png b/src/plugins/rv-packages/session_manager/fallback_thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..e6591d42aec02f0586f1a71aab943a6ed55454a0 GIT binary patch literal 773 zcmeAS@N?(olHy`uVBq!ia0vp^DL`z&!2~2*&YbFJU|`zi>Eakt!T9E8;>Je?0tX78 z?Q3S_X=oH?kT}4=!NA-O+0KY|LxgdwfElr? z-+y0d2rX7TE!NHI+ZH)ZOe`oxNUvr>`(egS4+|{h`ZaU5M{8v+YMvVI6yey_vg>YM z&Et;0b@e-A{FV#1#(x+0o$HsrH|{d8^-~Yl)oQL$woVgbHs9>2`jl-oHz=S_WkXib zl1s*{0-RANt@})0vWtlp?b%MWYTbTIr(s zyGDOa!bKg?sL0}}5l=!Q!}MmPUV9z1By^_liz%F?d*dvx@<^}QzH~w2)1wnrkN&J# zwm_0CV!p>l2UUh!Ic73bouyw3Yu-DpFW2CqeJw_0%H!lUGd>n=|6lX1RdTPHf8oSe z)s=RizAOo{4B2=iukz@k$X6RfdwoQ^JJYsC+1|@F%}$EGx@5-$EwSs&t9S7&P!rc| zTh)71b@jxFB8RsfIpuvyNZVS|c#X``oDD`(jSid&_mb}BWuC_Oeyz9U>dF;%yzYwy z^5N&mf{tlhu5OH`<9{$&>l-PX3+ zjtlB@7i)>>Delym@-b(h|M3+vomyhMpZUDaxm#FS)U|*?eyZY23tyn48yHw;JuNzE z5--B4-FYy Date: Tue, 10 Mar 2026 09:30:00 -0400 Subject: [PATCH 02/22] Add thumbnail icon that can be scrubed to source and inputs Signed-off-by: chenl1 --- .../session_manager/session_manager.mu.in | 157 +++++++++++++++--- 1 file changed, 135 insertions(+), 22 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 4ee1e3536..22d666c5d 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -601,6 +601,73 @@ documentation: """ QStandardItemModel with modified drag and drop mime types. """; +class: FilmstripWidget : QLabel +{ + QImage _strip; + int _frameWidth; // width of each frame in the filmstrip (240 px) + bool _loaded; + QTimer _leaveTimer; + + method: FilmstripWidget (FilmstripWidget; QWidget parent) + { + QLabel.QLabel(this, parent); + _frameWidth = 240; + _loaded = false; + setScaledContents(true); + setMouseTracking(true); + _leaveTimer = QTimer(this); + _leaveTimer.setInterval(100); + connect(_leaveTimer, QTimer.timeout, checkMouseLeft); + } + + method: checkMouseLeft (void;) + { + if (!underMouse()) + { + showFrame(0); + _leaveTimer.stop(); + } + } + + method: showFrame (void; int fx) + { + let frame = _strip.copy(QRect(fx, 0, _frameWidth, _strip.height())); + setPixmap(QPixmap.fromImage(frame, Qt.AutoColor)); + } + + method: loadStrip (void; string path, QPixmap fallback) + { + let img = QImage(path, nil); + if (!img.isNull()) + { + _strip = img; + _loaded = true; + showFrame(0); + } + else + { + setPixmap(fallback); + } + } + + method: mouseMoveEvent (void; QMouseEvent event) + { + if (_loaded) + { + let mx = event.position().toPoint().x(), + nat_w = _strip.width(), + prop_x = float(mx) / float(width()), + fx = int(prop_x * float(nat_w) / float(_frameWidth) + 0.5) * _frameWidth, + clamped = if fx > nat_w - _frameWidth then nat_w - _frameWidth + else if fx < 0 then 0 + else fx; + showFrame(clamped); + if (!_leaveTimer.isActive()) _leaveTimer.start(); + } + QWidget.mouseMoveEvent(this, event); + } +} + class: NodeModel : QStandardItemModel { method: NodeModel (NodeModel; QObject parent) @@ -1366,15 +1433,25 @@ class: SessionManagerMode : MinorMode for_index (i; connections) { - let innode = connections[i], - item = QStandardItem(iconForNode(innode), uiName(innode)), - vindex = indexOfItem(vnodes, innode); + let innode = connections[i], + isSource = nodeType(innode) == "RVSourceGroup", + item = QStandardItem(iconForNode(innode), uiName(innode)), + vindex = indexOfItem(vnodes, innode); item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled); item.setData(QVariant(innode), Qt.UserRole + 2); item.setEditable(false); - + + if (isSource) + { + item.setText(""); + item.setSizeHint(QSize(-1, 55)); + } + _inputsModel.appendRow(item); + + if (isSource) + _inputsView.setIndexWidget(_inputsModel.indexFromItem(item), makeSourceRowWidget(innode)); } _inputOrderLock = false; @@ -1663,6 +1740,59 @@ class: SessionManagerMode : MinorMode item; } + method: makeSourceRowWidget (QWidget; string node) + { + let widget = QWidget(nil, 0), + layout = QHBoxLayout(widget); + widget.setStyleSheet("background: transparent;"); + layout.setContentsMargins(8, 0, 8, 0); + layout.setSpacing(5); + + // Filmstrip thumbnail + let thumb = FilmstripWidget(widget); + thumb.setFixedSize(QSize(80, 45)); + thumb.loadStrip(auxFilePath("filmstrip.jpg"), + _fallbackSourceIcon.pixmap(QSize(80, 45))); + layout.addWidget(thumb); + + // Text column + let textWidget = QWidget(widget), + textLayout = QVBoxLayout(textWidget); + textWidget.setStyleSheet("background: transparent;"); + textLayout.setSpacing(3); + + let nameLabel = QLabel(uiName(node), textWidget); + nameLabel.setStyleSheet("color: #d0d0d0; background: transparent;"); + textLayout.addWidget(nameLabel); + + string meta = ""; + try + { + let sn = sourceNodeOfGroup(node), + mprop = getStringProperty(sn + ".media.movie"); + if (!mprop.empty()) + { + let parts = io.path.basename(mprop.front()).split("."); + if (parts.size() > 1) meta = parts.back(); + } + } + catch (exception exc) + { + print("WARNING: Could not get media type info for %s - %s\n" % (uiName(node), exc)); + } + + let metaLabel = QLabel(if meta == "" then "—" else meta, textWidget); + metaLabel.setStyleSheet("color: #888888; background: transparent; font-size: 9px;"); + metaLabel.setSizePolicy(5, 5); // QSizePolicy::Preferred + metaLabel.setMinimumWidth(1); // absolute minimum overrides minimumSizeHint, allows shrinking + textLayout.addWidget(metaLabel); + textLayout.addStretch(1); + + layout.addWidget(textWidget, 1); + + widget; + } + method: newNodeRow (void; QStandardItem parentItem, string node, @@ -1697,24 +1827,7 @@ class: SessionManagerMode : MinorMode { item.setText(""); item.setSizeHint(QSize(-1, 55)); - - let widget = QWidget(nil, 0), - layout = QHBoxLayout(widget); - widget.setStyleSheet("background: transparent;"); - layout.setContentsMargins(8, 0, 8, 0); - layout.setSpacing(10); - - let thumbLabel = QLabel(widget); - thumbLabel.setPixmap(_fallbackSourceIcon.pixmap(QSize(80, 40), - QIcon.Normal, QIcon.Off)); - layout.addWidget(thumbLabel); - - let nameLabel = QLabel(uiName(node), widget); - nameLabel.setStyleSheet("color: white; background: transparent;"); - layout.addWidget(nameLabel); - layout.addStretch(1); - - _viewTreeView.setIndexWidget(_viewModel.indexFromItem(item), widget); + _viewTreeView.setIndexWidget(_viewModel.indexFromItem(item), makeSourceRowWidget(node)); } // From ae27ad798383d38a4216a02e504801002302153c Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 10 Mar 2026 11:55:44 -0400 Subject: [PATCH 03/22] Refactor away some dead code Signed-off-by: chenl1 --- .../rv-packages/session_manager/session_manager.mu.in | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 22d666c5d..5bc0b2885 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -1428,15 +1428,13 @@ class: SessionManagerMode : MinorMode _inputOrderLock = true; _inputsModel.clear(); - let connections = nodeInputs(node), - vnodes = viewNodes(); + let connections = nodeInputs(node); for_index (i; connections) { let innode = connections[i], isSource = nodeType(innode) == "RVSourceGroup", - item = QStandardItem(iconForNode(innode), uiName(innode)), - vindex = indexOfItem(vnodes, innode); + item = QStandardItem(iconForNode(innode), uiName(innode)); item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled); item.setData(QVariant(innode), Qt.UserRole + 2); From 8254572cd667528b8a6a383d3d48e48bdc190347 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 10 Mar 2026 17:27:42 -0400 Subject: [PATCH 04/22] Remove unwanted code Signed-off-by: chenl1 --- src/plugins/rv-packages/session_manager/session_manager.mu.in | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 5bc0b2885..dd2bdc984 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -1781,8 +1781,6 @@ class: SessionManagerMode : MinorMode let metaLabel = QLabel(if meta == "" then "—" else meta, textWidget); metaLabel.setStyleSheet("color: #888888; background: transparent; font-size: 9px;"); - metaLabel.setSizePolicy(5, 5); // QSizePolicy::Preferred - metaLabel.setMinimumWidth(1); // absolute minimum overrides minimumSizeHint, allows shrinking textLayout.addWidget(metaLabel); textLayout.addStretch(1); From e0d4d3070c6bdbbc0d8bc166a17387d0af63c32e Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 17 Mar 2026 13:12:10 -0400 Subject: [PATCH 05/22] Add thumbnail class and event sending for python plugin Signed-off-by: chenl1 --- .../session_manager/session_manager.mu.in | 154 +++++++++++++----- 1 file changed, 113 insertions(+), 41 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index dd2bdc984..5eb885534 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -601,12 +601,31 @@ documentation: """ QStandardItemModel with modified drag and drop mime types. """; +// Displays a static thumbnail image. Falls back to a placeholder pixmap if none loaded. +class: ThumbnailWidget : QLabel +{ + method: ThumbnailWidget (ThumbnailWidget; QWidget parent) + { + QLabel.QLabel(this, parent); + setScaledContents(true); + } + + method: setFallback (void; QPixmap px) { setPixmap(px); } + + method: load (void; string path) + { + let px = QPixmap.fromImage(QImage(path, ""), Qt.AutoColor); + if (!px.isNull()) setPixmap(px); + } +} + +// Displays a scrubable filmstrip that will show the corresponding frame +// based on mouse position class: FilmstripWidget : QLabel { QImage _strip; int _frameWidth; // width of each frame in the filmstrip (240 px) bool _loaded; - QTimer _leaveTimer; method: FilmstripWidget (FilmstripWidget; QWidget parent) { @@ -615,56 +634,84 @@ class: FilmstripWidget : QLabel _loaded = false; setScaledContents(true); setMouseTracking(true); - _leaveTimer = QTimer(this); - _leaveTimer.setInterval(100); - connect(_leaveTimer, QTimer.timeout, checkMouseLeft); } - method: checkMouseLeft (void;) + method: showFrameAtX (void; int mouseX) { - if (!underMouse()) - { - showFrame(0); - _leaveTimer.stop(); - } - } - - method: showFrame (void; int fx) - { - let frame = _strip.copy(QRect(fx, 0, _frameWidth, _strip.height())); + if (!_loaded) return; + let nat_w = _strip.width(), + prop_x = float(mouseX) / float(width()), + fx = int(prop_x * float(nat_w) / float(_frameWidth) + 0.5) * _frameWidth, + clamped = if fx > nat_w - _frameWidth then nat_w - _frameWidth + else if fx < 0 then 0 + else fx; + let frame = _strip.copy(QRect(clamped, 0, _frameWidth, _strip.height())); setPixmap(QPixmap.fromImage(frame, Qt.AutoColor)); } - method: loadStrip (void; string path, QPixmap fallback) + method: isLoaded (bool;) { _loaded; } + + method: load (void; string path) { - let img = QImage(path, nil); + let img = QImage(path, ""); if (!img.isNull()) { _strip = img; _loaded = true; - showFrame(0); - } - else - { - setPixmap(fallback); } } method: mouseMoveEvent (void; QMouseEvent event) { - if (_loaded) + showFrameAtX(event.position().toPoint().x()); + QWidget.mouseMoveEvent(this, event); + } +} + +// Stacks a FilmstripWidget and ThumbnailWidget. Shows thumbnail by default; +// on hover enter shows the filmstrip scrubbed to the cursor position. +class: SourcePreviewWidget : QWidget +{ + FilmstripWidget _filmstrip; + ThumbnailWidget _thumbnail; + + method: SourcePreviewWidget (SourcePreviewWidget; QWidget parent) + { + QWidget.QWidget(this, parent); + setAttribute(Qt.WA_Hover, true); + + _thumbnail = ThumbnailWidget(this); + _thumbnail.setGeometry(QRect(0, 0, 80, 45)); + _thumbnail.show(); + + _filmstrip = FilmstripWidget(this); + _filmstrip.setGeometry(QRect(0, 0, 80, 45)); + _filmstrip.hide(); + } + + method: setFallback (void; QPixmap px) { _thumbnail.setFallback(px); } + method: loadStrip (void; string path) { _filmstrip.load(path); } + method: loadThumbnail (void; string path) { _thumbnail.load(path); } + + method: event (bool; QEvent e) + { + if (e.type() == QEvent.HoverEnter) { - let mx = event.position().toPoint().x(), - nat_w = _strip.width(), - prop_x = float(mx) / float(width()), - fx = int(prop_x * float(nat_w) / float(_frameWidth) + 0.5) * _frameWidth, - clamped = if fx > nat_w - _frameWidth then nat_w - _frameWidth - else if fx < 0 then 0 - else fx; - showFrame(clamped); - if (!_leaveTimer.isActive()) _leaveTimer.start(); + if (_filmstrip.isLoaded()) + { + _filmstrip.showFrameAtX(mapFromGlobal(QCursor.pos()).x()); + _filmstrip.show(); + _thumbnail.hide(); + } + return true; } - QWidget.mouseMoveEvent(this, event); + else if (e.type() == QEvent.HoverLeave) + { + _filmstrip.hide(); + _thumbnail.show(); + return true; + } + return QWidget.event(this, e); } } @@ -1094,8 +1141,8 @@ class: SessionManagerMode : MinorMode method: colorAdjustedIcon (QIcon; string rpath, bool invertSense) { let bg = QApplication.palette().color(QPalette.Active, @MU_QT_QPALETTE_COLORROLE@), - icon0 = QImage(regex.replace("48x48", rpath, "out"), nil), - icon1 = QImage(rpath, nil), + icon0 = QImage(regex.replace("48x48", rpath, "out"), ""), + icon1 = QImage(rpath, ""), swap = invertSense != _darkUI, qimage = if swap then icon0 else icon1; @@ -1746,12 +1793,37 @@ class: SessionManagerMode : MinorMode layout.setContentsMargins(8, 0, 8, 0); layout.setSpacing(5); - // Filmstrip thumbnail - let thumb = FilmstripWidget(widget); - thumb.setFixedSize(QSize(80, 45)); - thumb.loadStrip(auxFilePath("filmstrip.jpg"), - _fallbackSourceIcon.pixmap(QSize(80, 45))); - layout.addWidget(thumb); + // Source preview: stacks thumbnail (default) and filmstrip (on hover) + let preview = SourcePreviewWidget(widget); + preview.setFixedSize(QSize(80, 45)); + preview.setFallback(_fallbackSourceIcon.pixmap(QSize(80, 45))); + + try + { + let sn = sourceNodeOfGroup(node); + + // Fire events that can get picked up by Python plugins used to + // download filmstrips and thumbnails + sendInternalEvent("fetch-version-filmstrip", sn); + sendInternalEvent("fetch-version-thumbnail", sn); + + // Load synchronously from disk if files are already cached + // (sendInternalEvent is synchronous so files may already be there) + let trackingInfo = getStringProperty(sn + ".tracking.info"); + for_index (i; trackingInfo) + { + if (trackingInfo[i] == "id" && i + 1 < trackingInfo.size()) + { + let base = QDir.tempPath() + "/rv_thumbnails/" + trackingInfo[i+1]; + if (io.path.exists(base + "_filmstrip.jpg")) preview.loadStrip(base + "_filmstrip.jpg"); + if (io.path.exists(base + "_thumbnail.jpg")) preview.loadThumbnail(base + "_thumbnail.jpg"); + break; + } + } + } + catch (...) { ; } + + layout.addWidget(preview); // Text column let textWidget = QWidget(widget), From 4fb114a501385c396d7aed3e28ba736cc3041a01 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 17 Mar 2026 13:40:50 -0400 Subject: [PATCH 06/22] Remove unnecessary changes Signed-off-by: chenl1 --- src/plugins/rv-packages/session_manager/session_manager.mu.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 5eb885534..8e393eb43 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -1141,8 +1141,8 @@ class: SessionManagerMode : MinorMode method: colorAdjustedIcon (QIcon; string rpath, bool invertSense) { let bg = QApplication.palette().color(QPalette.Active, @MU_QT_QPALETTE_COLORROLE@), - icon0 = QImage(regex.replace("48x48", rpath, "out"), ""), - icon1 = QImage(rpath, ""), + icon0 = QImage(regex.replace("48x48", rpath, "out"), nil), + icon1 = QImage(rpath, nil), swap = invertSense != _darkUI, qimage = if swap then icon0 else icon1; From 7f88375aa997d6325715a81f9b938aa5e3a9555c Mon Sep 17 00:00:00 2001 From: chenl1 Date: Thu, 19 Mar 2026 12:05:51 -0400 Subject: [PATCH 07/22] Remove SG related and cache handling code Signed-off-by: chenl1 --- .../session_manager/session_manager.mu.in | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 8e393eb43..4aed51b6e 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -20,6 +20,10 @@ ViewSubComponent := 2; LayerSubComponent := 3; ChannelSubComponent := 4; +FILMSTRIP_FRAME_WIDTH := 240; +PREVIEW_WIDTH := 80; +PREVIEW_HEIGHT := 45; + \: itemNode (string; QStandardItem item) { let d = item.data(Qt.UserRole + 2); @@ -624,13 +628,13 @@ class: ThumbnailWidget : QLabel class: FilmstripWidget : QLabel { QImage _strip; - int _frameWidth; // width of each frame in the filmstrip (240 px) + int _frameWidth; bool _loaded; method: FilmstripWidget (FilmstripWidget; QWidget parent) { QLabel.QLabel(this, parent); - _frameWidth = 240; + _frameWidth = FILMSTRIP_FRAME_WIDTH; _loaded = false; setScaledContents(true); setMouseTracking(true); @@ -681,11 +685,11 @@ class: SourcePreviewWidget : QWidget setAttribute(Qt.WA_Hover, true); _thumbnail = ThumbnailWidget(this); - _thumbnail.setGeometry(QRect(0, 0, 80, 45)); + _thumbnail.setGeometry(QRect(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT)); _thumbnail.show(); _filmstrip = FilmstripWidget(this); - _filmstrip.setGeometry(QRect(0, 0, 80, 45)); + _filmstrip.setGeometry(QRect(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT)); _filmstrip.hide(); } @@ -1795,31 +1799,21 @@ class: SessionManagerMode : MinorMode // Source preview: stacks thumbnail (default) and filmstrip (on hover) let preview = SourcePreviewWidget(widget); - preview.setFixedSize(QSize(80, 45)); - preview.setFallback(_fallbackSourceIcon.pixmap(QSize(80, 45))); + preview.setFixedSize(QSize(PREVIEW_WIDTH, PREVIEW_HEIGHT)); + preview.setFallback(_fallbackSourceIcon.pixmap(QSize(PREVIEW_WIDTH, PREVIEW_HEIGHT))); try { let sn = sourceNodeOfGroup(node); - // Fire events that can get picked up by Python plugins used to - // download filmstrips and thumbnails - sendInternalEvent("fetch-version-filmstrip", sn); - sendInternalEvent("fetch-version-thumbnail", sn); + // Fire events that fetch filmstrip/thumbnail and return file paths + let filmstripPath = sendInternalEvent("session_manager-get-filmstrip-path", sn); + let thumbnailPath = sendInternalEvent("session_manager-get-thumbnail-path", sn); - // Load synchronously from disk if files are already cached - // (sendInternalEvent is synchronous so files may already be there) - let trackingInfo = getStringProperty(sn + ".tracking.info"); - for_index (i; trackingInfo) - { - if (trackingInfo[i] == "id" && i + 1 < trackingInfo.size()) - { - let base = QDir.tempPath() + "/rv_thumbnails/" + trackingInfo[i+1]; - if (io.path.exists(base + "_filmstrip.jpg")) preview.loadStrip(base + "_filmstrip.jpg"); - if (io.path.exists(base + "_thumbnail.jpg")) preview.loadThumbnail(base + "_thumbnail.jpg"); - break; - } - } + if (filmstripPath != "" && io.path.exists(filmstripPath)) + preview.loadStrip(filmstripPath); + if (thumbnailPath != "" && io.path.exists(thumbnailPath)) + preview.loadThumbnail(thumbnailPath); } catch (...) { ; } From 477921c12787dd7fdac9ba17ea0e19da5a1cc5d1 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Fri, 20 Mar 2026 15:02:02 -0400 Subject: [PATCH 08/22] Refactor code with better variable names and design choices and move style to qss files Signed-off-by: chenl1 --- src/lib/app/RvCommon/rv_linux_dark.qss | 33 +++++- src/lib/app/RvCommon/rv_mac_dark.qss | 33 +++++- .../session_manager/session_manager.mu.in | 105 +++++++++--------- 3 files changed, 119 insertions(+), 52 deletions(-) diff --git a/src/lib/app/RvCommon/rv_linux_dark.qss b/src/lib/app/RvCommon/rv_linux_dark.qss index 9da2c11b0..2bf0dbeee 100644 --- a/src/lib/app/RvCommon/rv_linux_dark.qss +++ b/src/lib/app/RvCommon/rv_linux_dark.qss @@ -1134,7 +1134,38 @@ QWidget::sessionManager QScrollBar::handle:horizontal { QWidget::sessionManager QScrollBar::handle:vertical { min-height: 20px; } - + +QWidget#sessionManager QWidget#sourceRowWidget { + background: transparent; +} + +QWidget#sessionManager QWidget#sourceTextWidget { + background: transparent; +} + +QWidget#sessionManager QLabel#sourceNameLabel { + color: rgb(208, 208, 208); + background: transparent; +} + +QWidget#sessionManager QLabel#sourceMetaLabel { + color: rgb(136, 136, 136); + background: transparent; + font-size: %2pt; +} + +QWidget#sessionManager QListView#inputsViewList::item:hover { + background-color: rgb(7, 75, 120); +} + +QWidget#sessionManager QListView#inputsViewList::item:selected { + background-color: rgb(7, 75, 120); +} + +QWidget#sessionManager QListView#inputsViewList::item:selected:hover { + background-color: rgb(7, 75, 120); +} + QAbstractItemView#uiTreeWidget QWidget { font-size: %2pt; } diff --git a/src/lib/app/RvCommon/rv_mac_dark.qss b/src/lib/app/RvCommon/rv_mac_dark.qss index e690cd99f..af9d00091 100644 --- a/src/lib/app/RvCommon/rv_mac_dark.qss +++ b/src/lib/app/RvCommon/rv_mac_dark.qss @@ -1100,7 +1100,38 @@ QWidget::sessionManager QScrollBar::handle:horizontal { QWidget::sessionManager QScrollBar::handle:vertical { min-height: 20px; } - + +QWidget#sessionManager QWidget#sourceRowWidget { + background: transparent; +} + +QWidget#sessionManager QWidget#sourceTextWidget { + background: transparent; +} + +QWidget#sessionManager QLabel#sourceNameLabel { + color: rgb(208, 208, 208); + background: transparent; +} + +QWidget#sessionManager QLabel#sourceMetaLabel { + color: rgb(136, 136, 136); + background: transparent; + font-size: %2pt; +} + +QWidget#sessionManager QListView#inputsViewList::item:hover { + background-color: rgb(7, 75, 120); +} + +QWidget#sessionManager QListView#inputsViewList::item:selected { + background-color: rgb(7, 75, 120); +} + +QWidget#sessionManager QListView#inputsViewList::item:selected:hover { + background-color: rgb(7, 75, 120); +} + QAbstractItemView#uiTreeWidget QWidget { } diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 4aed51b6e..afcf7ad07 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -23,6 +23,11 @@ ChannelSubComponent := 4; FILMSTRIP_FRAME_WIDTH := 240; PREVIEW_WIDTH := 80; PREVIEW_HEIGHT := 45; +SOURCE_ROW_HEIGHT := 55; +SOURCE_ROW_MARGIN := 8; +SOURCE_ROW_SPACING := 5; +SOURCE_TEXT_SPACING := 3; +TREE_VIEW_INDENTATION := 10; \: itemNode (string; QStandardItem item) { @@ -614,12 +619,12 @@ class: ThumbnailWidget : QLabel setScaledContents(true); } - method: setFallback (void; QPixmap px) { setPixmap(px); } + method: setFallback (void; QPixmap pixmap) { setPixmap(pixmap); } method: load (void; string path) { - let px = QPixmap.fromImage(QImage(path, ""), Qt.AutoColor); - if (!px.isNull()) setPixmap(px); + let pixmap = QPixmap.fromImage(QImage(path, ""), Qt.AutoColor); + if (!pixmap.isNull()) setPixmap(pixmap); } } @@ -642,14 +647,14 @@ class: FilmstripWidget : QLabel method: showFrameAtX (void; int mouseX) { - if (!_loaded) return; - let nat_w = _strip.width(), - prop_x = float(mouseX) / float(width()), - fx = int(prop_x * float(nat_w) / float(_frameWidth) + 0.5) * _frameWidth, - clamped = if fx > nat_w - _frameWidth then nat_w - _frameWidth - else if fx < 0 then 0 - else fx; - let frame = _strip.copy(QRect(clamped, 0, _frameWidth, _strip.height())); + if (!_loaded || width() <= 0) return; + let nativeWidth = _strip.width(), + proportionX = float(mouseX) / float(width()), + frameX = int(proportionX * float(nativeWidth) / float(_frameWidth) + 0.5) * _frameWidth, + clampedX = if frameX > nativeWidth - _frameWidth then nativeWidth - _frameWidth + else if frameX < 0 then 0 + else frameX; + let frame = _strip.copy(QRect(clampedX, 0, _frameWidth, _strip.height())); setPixmap(QPixmap.fromImage(frame, Qt.AutoColor)); } @@ -657,10 +662,10 @@ class: FilmstripWidget : QLabel method: load (void; string path) { - let img = QImage(path, ""); - if (!img.isNull()) + let filmstripImage = QImage(path, ""); + if (!filmstripImage.isNull()) { - _strip = img; + _strip = filmstripImage; _loaded = true; } } @@ -693,13 +698,13 @@ class: SourcePreviewWidget : QWidget _filmstrip.hide(); } - method: setFallback (void; QPixmap px) { _thumbnail.setFallback(px); } + method: setFallback (void; QPixmap pixmap) { _thumbnail.setFallback(pixmap); } method: loadStrip (void; string path) { _filmstrip.load(path); } method: loadThumbnail (void; string path) { _thumbnail.load(path); } - method: event (bool; QEvent e) + method: event (bool; QEvent event) { - if (e.type() == QEvent.HoverEnter) + if (event.type() == QEvent.HoverEnter) { if (_filmstrip.isLoaded()) { @@ -709,13 +714,13 @@ class: SourcePreviewWidget : QWidget } return true; } - else if (e.type() == QEvent.HoverLeave) + else if (event.type() == QEvent.HoverLeave) { _filmstrip.hide(); _thumbnail.show(); return true; } - return QWidget.event(this, e); + return QWidget.event(this, event); } } @@ -1494,7 +1499,7 @@ class: SessionManagerMode : MinorMode if (isSource) { item.setText(""); - item.setSizeHint(QSize(-1, 55)); + item.setSizeHint(QSize(-1, SOURCE_ROW_HEIGHT)); } _inputsModel.appendRow(item); @@ -1793,60 +1798,60 @@ class: SessionManagerMode : MinorMode { let widget = QWidget(nil, 0), layout = QHBoxLayout(widget); - widget.setStyleSheet("background: transparent;"); - layout.setContentsMargins(8, 0, 8, 0); - layout.setSpacing(5); + widget.setObjectName("sourceRowWidget"); + layout.setContentsMargins(SOURCE_ROW_MARGIN, 0, SOURCE_ROW_MARGIN, 0); + layout.setSpacing(SOURCE_ROW_SPACING); // Source preview: stacks thumbnail (default) and filmstrip (on hover) let preview = SourcePreviewWidget(widget); preview.setFixedSize(QSize(PREVIEW_WIDTH, PREVIEW_HEIGHT)); preview.setFallback(_fallbackSourceIcon.pixmap(QSize(PREVIEW_WIDTH, PREVIEW_HEIGHT))); - try + string meta = ""; + string sourceNode = nil; + try { sourceNode = sourceNodeOfGroup(node); } + catch (exception error) { - let sn = sourceNodeOfGroup(node); + print("WARNING: Could not get source node for %s - %s\n" % (uiName(node), error)); + } + if (sourceNode neq nil) + { // Fire events that fetch filmstrip/thumbnail and return file paths - let filmstripPath = sendInternalEvent("session_manager-get-filmstrip-path", sn); - let thumbnailPath = sendInternalEvent("session_manager-get-thumbnail-path", sn); + let filmstripPath = sendInternalEvent("session-manager-get-filmstrip-path", sourceNode); + let thumbnailPath = sendInternalEvent("session-manager-get-thumbnail-path", sourceNode); if (filmstripPath != "" && io.path.exists(filmstripPath)) preview.loadStrip(filmstripPath); if (thumbnailPath != "" && io.path.exists(thumbnailPath)) preview.loadThumbnail(thumbnailPath); + + let mediaPropertyPath = sourceNode + ".media.movie"; + if (propertyExists(mediaPropertyPath)) + { + let movieProperty = getStringProperty(mediaPropertyPath); + if (!movieProperty.empty()) + { + let parts = io.path.basename(movieProperty.front()).split("."); + if (parts.size() > 1) meta = parts.back(); + } + } } - catch (...) { ; } layout.addWidget(preview); // Text column let textWidget = QWidget(widget), textLayout = QVBoxLayout(textWidget); - textWidget.setStyleSheet("background: transparent;"); - textLayout.setSpacing(3); + textWidget.setObjectName("sourceTextWidget"); + textLayout.setSpacing(SOURCE_TEXT_SPACING); let nameLabel = QLabel(uiName(node), textWidget); - nameLabel.setStyleSheet("color: #d0d0d0; background: transparent;"); + nameLabel.setObjectName("sourceNameLabel"); textLayout.addWidget(nameLabel); - string meta = ""; - try - { - let sn = sourceNodeOfGroup(node), - mprop = getStringProperty(sn + ".media.movie"); - if (!mprop.empty()) - { - let parts = io.path.basename(mprop.front()).split("."); - if (parts.size() > 1) meta = parts.back(); - } - } - catch (exception exc) - { - print("WARNING: Could not get media type info for %s - %s\n" % (uiName(node), exc)); - } - let metaLabel = QLabel(if meta == "" then "—" else meta, textWidget); - metaLabel.setStyleSheet("color: #888888; background: transparent; font-size: 9px;"); + metaLabel.setObjectName("sourceMetaLabel"); textLayout.addWidget(metaLabel); textLayout.addStretch(1); @@ -1888,7 +1893,7 @@ class: SessionManagerMode : MinorMode if (source) { item.setText(""); - item.setSizeHint(QSize(-1, 55)); + item.setSizeHint(QSize(-1, SOURCE_ROW_HEIGHT)); _viewTreeView.setIndexWidget(_viewModel.indexFromItem(item), makeSourceRowWidget(node)); } @@ -3129,7 +3134,7 @@ class: SessionManagerMode : MinorMode _viewTreeView.setDragDropMode(QAbstractItemView.DragDrop); _viewTreeView.setDefaultDropAction(Qt.MoveAction); _viewTreeView.setExpandsOnDoubleClick(false); - _viewTreeView.setIndentation(10); + _viewTreeView.setIndentation(TREE_VIEW_INDENTATION); _inputsView.setModel(_inputsModel); _inputsView.setDragEnabled(true); From e91b67cbb616e094e48773aa527cec67a7290ec2 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Fri, 20 Mar 2026 15:41:53 -0400 Subject: [PATCH 09/22] Rename variables so they are associated to SourcePreviewWidget Signed-off-by: chenl1 --- .../session_manager/session_manager.mu.in | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index afcf7ad07..29b78b629 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -21,8 +21,8 @@ LayerSubComponent := 3; ChannelSubComponent := 4; FILMSTRIP_FRAME_WIDTH := 240; -PREVIEW_WIDTH := 80; -PREVIEW_HEIGHT := 45; +SOURCE_PREVIEW_WIDTH := 80; +SOURCE_PREVIEW_HEIGHT := 45; SOURCE_ROW_HEIGHT := 55; SOURCE_ROW_MARGIN := 8; SOURCE_ROW_SPACING := 5; @@ -690,11 +690,11 @@ class: SourcePreviewWidget : QWidget setAttribute(Qt.WA_Hover, true); _thumbnail = ThumbnailWidget(this); - _thumbnail.setGeometry(QRect(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT)); + _thumbnail.setGeometry(QRect(0, 0, SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT)); _thumbnail.show(); _filmstrip = FilmstripWidget(this); - _filmstrip.setGeometry(QRect(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT)); + _filmstrip.setGeometry(QRect(0, 0, SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT)); _filmstrip.hide(); } @@ -1804,8 +1804,8 @@ class: SessionManagerMode : MinorMode // Source preview: stacks thumbnail (default) and filmstrip (on hover) let preview = SourcePreviewWidget(widget); - preview.setFixedSize(QSize(PREVIEW_WIDTH, PREVIEW_HEIGHT)); - preview.setFallback(_fallbackSourceIcon.pixmap(QSize(PREVIEW_WIDTH, PREVIEW_HEIGHT))); + preview.setFixedSize(QSize(SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT)); + preview.setFallback(_fallbackSourceIcon.pixmap(QSize(SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT))); string meta = ""; string sourceNode = nil; From 055195c5ffc117e20171fa344949df8e20d4fc6f Mon Sep 17 00:00:00 2001 From: chenl1 Date: Mon, 30 Mar 2026 11:31:43 -0400 Subject: [PATCH 10/22] Add async preview generation for local media Signed-off-by: chenl1 --- .../rv-packages/session_manager/PACKAGE | 2 + .../session_manager/local_thumbnail_gen.py | 409 ++++++++++++++++++ .../session_manager/session_manager.mu.in | 17 +- 3 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 src/plugins/rv-packages/session_manager/local_thumbnail_gen.py diff --git a/src/plugins/rv-packages/session_manager/PACKAGE b/src/plugins/rv-packages/session_manager/PACKAGE index 63ffde9c5..b0bae673f 100644 --- a/src/plugins/rv-packages/session_manager/PACKAGE +++ b/src/plugins/rv-packages/session_manager/PACKAGE @@ -36,6 +36,8 @@ modes: load: delay - file: transform_manip load: delay + - file: local_thumbnail_gen + load: immediate description: | diff --git a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py new file mode 100644 index 000000000..1fe52614e --- /dev/null +++ b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py @@ -0,0 +1,409 @@ +import hashlib +import logging +import math +import os +import shutil +import subprocess +import tempfile +import textwrap +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any + +try: + from PySide2 import QtCore +except ImportError: + from PySide6 import QtCore + +from rv import commands, rvtypes + +logger = logging.getLogger(__name__) + +the_mode = None + +FRAME_WIDTH = 240 +MAX_FILMSTRIP_FRAMES = 25 +MAX_WORKERS = 4 +UI_REFRESH_DELAY_MS = 100 + + +class _SignalBridge(QtCore.QObject): + """Bridges background threads to the main thread via Qt signals.""" + + finished = QtCore.Signal(str, str, str) # cache_key, thumbnail_path ("" if failed), filmstrip_path ("" if failed) + + +class LocalThumbnailGen(rvtypes.MinorMode): + """ + Generates filmstrip and thumbnail previews locally via rvio for any media + source that does not have their own custom thumbnail or filmstrip. + + Sync cache lookup events return cached paths and trigger generation if needed. + Generation runs in background threads. When threads complete, a coalescing + timer triggers a UI refresh so the session manager picks up the new paths. + """ + + def __init__(self) -> None: + rvtypes.MinorMode.__init__(self) + self._cache: dict[str, dict[str, Path | None]] = {} + self._cache_dir = Path(tempfile.gettempdir()) / "rv_thumbnails" + self._cache_dir.mkdir(parents=True, exist_ok=True) + self._in_flight: set[str] = set() + self._pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) + + self._bridge = _SignalBridge() + self._bridge.finished.connect(self._on_generation_done, QtCore.Qt.QueuedConnection) + + self._refresh_timer = QtCore.QTimer() + self._refresh_timer.setSingleShot(True) + self._refresh_timer.setInterval(UI_REFRESH_DELAY_MS) + self._refresh_timer.timeout.connect(self._trigger_ui_refresh) + + # The last parameter is the priority of the plugin. Having it at 10 means it will be run last + # letting custom plugins of higher priority run first and consume the event before the local plugin runs. + self.init("LocalThumbnailGen", self.global_bindings(), None, None, None, 10) + + def global_bindings(self) -> list[tuple[str, Any, str]]: + return [ + ( + "session-manager-get-filmstrip-path", + self._on_get_filmstrip_path, + "Return cached local filmstrip path, start generation if needed", + ), + ( + "session-manager-get-thumbnail-path", + self._on_get_thumbnail_path, + "Return cached local thumbnail path, start generation if needed", + ), + ( + "before-session-deletion", + self._on_session_deletion, + "Delete all cached local filmstrips and thumbnails on RV close", + ), + ] + + def _get_cached_path(self, event: Any, path_key: str) -> None: + event.reject() + source_node = event.contents() + media_path = self._get_media_path(source_node) + if not media_path: + event.setReturnContent("") + return + + cache_key = self._cache_key(media_path) + cached = self._cache.get(cache_key, {}) + path = cached.get(path_key) + + if path: + event.setReturnContent(str(path)) + return + + if cache_key not in self._in_flight: + self._start_generation(source_node, cache_key, media_path) + + event.setReturnContent("") + + def _on_get_filmstrip_path(self, event: Any) -> None: + self._get_cached_path(event, "filmstrip_path") + + def _on_get_thumbnail_path(self, event: Any) -> None: + self._get_cached_path(event, "thumbnail_path") + + def _start_generation(self, source_node: str, cache_key: str, media_path: str) -> None: + rvio_bin = self._get_rvio_bin() + if not rvio_bin: + return + + source_info = self._get_source_info(source_node) + if not source_info: + return + + self._in_flight.add(cache_key) + + start_frame, end_frame, width, height = source_info + + self._pool.submit( + self._generate_source, + cache_key, + rvio_bin, + media_path, + start_frame, + end_frame, + width, + height, + ) + + def _cache_key(self, media_path: str) -> str: + return hashlib.sha256(media_path.encode()).hexdigest()[:16] + + def _get_rvio_bin(self) -> str | None: + rvio = os.getenv("RV_APP_RVIO") + if not rvio: + logger.warning("RV_APP_RVIO not set") + return None + return rvio + + def _get_media_path(self, source_node: str) -> str | None: + try: + return commands.getStringProperty(f"{source_node}.media.movie")[0] + except Exception as e: + logger.warning(f"Could not get media path: {e}") + return None + + def _get_source_info(self, source_node: str) -> tuple[int, int, int, int] | None: + try: + info = commands.sourceMediaInfo(source_node) + return ( + info.get("startFrame", 1), + info.get("endFrame", info.get("startFrame", 1)), + info.get("width", 1920), + info.get("height", 1080), + ) + except Exception as e: + logger.warning(f"Could not get source info: {e}") + return None + + def _pick_frames(self, start_frame: int, end_frame: int) -> list[int]: + n_frames = end_frame - start_frame + 1 + if n_frames < MAX_FILMSTRIP_FRAMES: + return list(range(start_frame, end_frame + 1)) + incr = (n_frames - 1) / (MAX_FILMSTRIP_FRAMES - 1.0) + return [int(math.floor(start_frame + i * incr)) for i in range(MAX_FILMSTRIP_FRAMES)] + + def _write_filmstrip_session( + self, session_path: Path, media_path: str, frames: list[int], width: int, height: int + ) -> tuple[int, int]: + n = len(frames) + output_width = FRAME_WIDTH * n + aspect_ratio = float(height) / (n * width) if (n * width) > 0 else 1.0 + output_height = int(math.floor(output_width * aspect_ratio)) + + source_group_names = [f'"sourceGroup{frame:06d}"' for frame in frames] + lhs = " ".join(source_group_names) + rhs = " ".join('"defaultLayout"' for _ in frames) + top_nodes = f'"defaultLayout" {lhs}' + + with open(session_path, "w") as f: + f.write( + textwrap.dedent(f"""\ + GTOa (3) + + rv : RVSession (2) + {{ + session + {{ + string viewNode = "defaultLayout" + int marks = [ ] + int[2] range = [ [ 1 4 ] ] + int[2] region = [ [ 1 4 ] ] + float fps = 24 + int realtime = 0 + int inc = 1 + int currentFrame = 1 + int version = 1 + int background = 0 + }} + }} + + connections : connection (1) + {{ + evaluation + {{ + string lhs = [ {lhs} ] + string rhs = [ {rhs} ] + }} + + top + {{ + string nodes = [ {top_nodes} ] + }} + }} + + defaultLayout : RVLayoutGroup (1) + {{ + ui + {{ + string name = "Default Layout" + }} + + layout + {{ + string mode = "row" + }} + + timing + {{ + int retimeInputs = 1 + }} + + session + {{ + float fps = 24 + int marks = [ ] + int frame = 1 + }} + }} + + defaultLayout_stack : RVStack (1) + {{ + output + {{ + float fps = 24 + int size = [ {output_width} {output_height} ] + int autoSize = 0 + string chosenAudioInput = ".all." + }} + + mode + {{ + int useCutInfo = 1 + int alignStartFrames = 0 + int strictFrameRanges = 0 + }} + + composite + {{ + string type = "over" + }} + }} + + """) + ) + + for frame in frames: + f.write( + textwrap.dedent(f"""\ + sourceGroup{frame:06d} : RVSourceGroup (1) + {{ + ui + {{ + string name = "{frame}" + }} + }} + + sourceGroup{frame:06d}_source : RVFileSource (1) + {{ + media + {{ + string movie = "{media_path}" + }} + + group + {{ + float fps = 24 + float volume = 1 + float audioOffset = 0 + int rangeOffset = 0 + int noMovieAudio = 0 + float balance = 0 + float crossover = 0 + }} + + cut + {{ + int in = {frame} + int out = {frame} + }} + }} + + """) + ) + + return output_width, output_height + + def _generate_source( + self, + cache_key: str, + rvio_bin: str, + media_path: str, + start_frame: int, + end_frame: int, + width: int, + height: int, + ) -> None: + """Runs both rvio commands sequentially in a worker thread.""" + thumbnail_path = self._cache_dir / f"{cache_key}_thumbnail.jpg" + filmstrip_path = self._cache_dir / f"{cache_key}_filmstrip.jpg" + + try: + subprocess.run( + [rvio_bin, media_path, "-t", str((start_frame + end_frame) // 2), "-o", str(thumbnail_path)], + capture_output=True, + timeout=120, + ) + except Exception as e: + logger.error(f"Thumbnail generation failed: {e}") + + session_path = self._cache_dir / f"filmstrip_{cache_key}.rv" + try: + output_width, output_height = self._write_filmstrip_session( + session_path, media_path, self._pick_frames(start_frame, end_frame), width, height + ) + subprocess.run( + [ + rvio_bin, + str(session_path), + "-resize", + str(FRAME_WIDTH), + str(output_height), + "-outres", + str(output_width), + str(output_height), + "-o", + str(filmstrip_path), + ], + capture_output=True, + timeout=120, + ) + except Exception as e: + logger.error(f"Filmstrip generation failed: {e}") + finally: + try: + session_path.unlink(missing_ok=True) + except Exception as e: + logger.warning(f"Failed to delete session file {session_path}: {e}") + + self._bridge.finished.emit( + cache_key, + str(thumbnail_path) if thumbnail_path.exists() else "", + str(filmstrip_path) if filmstrip_path.exists() else "", + ) + + def _on_generation_done(self, cache_key: str, thumbnail_path: str, filmstrip_path: str) -> None: + """Called on the main thread when both rvio jobs complete for a source.""" + entry: dict[str, Path | None] = {} + if thumbnail_path: + entry["thumbnail_path"] = Path(thumbnail_path) + if filmstrip_path: + entry["filmstrip_path"] = Path(filmstrip_path) + + self._cache[cache_key] = entry + self._in_flight.discard(cache_key) + self._refresh_timer.start() + + def _trigger_ui_refresh(self) -> None: + """Called by the coalescing timer. Tells the session manager to rebuild.""" + commands.sendInternalEvent("local-preview-generation-complete", "") + + def _on_session_deletion(self, event: Any) -> None: + event.reject() + self._refresh_timer.stop() + self._pool.shutdown(wait=False, cancel_futures=True) + self._in_flight.clear() + + if self._cache_dir.exists(): + try: + shutil.rmtree(self._cache_dir) + except Exception as e: + logger.warning(f"Failed to delete cache directory {self._cache_dir}: {e}") + self._cache.clear() + + +def createMode() -> LocalThumbnailGen: + global the_mode + the_mode = LocalThumbnailGen() + return the_mode + + +def theMode() -> LocalThumbnailGen | None: + return the_mode diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 29b78b629..b83343461 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -1802,7 +1802,6 @@ class: SessionManagerMode : MinorMode layout.setContentsMargins(SOURCE_ROW_MARGIN, 0, SOURCE_ROW_MARGIN, 0); layout.setSpacing(SOURCE_ROW_SPACING); - // Source preview: stacks thumbnail (default) and filmstrip (on hover) let preview = SourcePreviewWidget(widget); preview.setFixedSize(QSize(SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT)); preview.setFallback(_fallbackSourceIcon.pixmap(QSize(SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT))); @@ -1817,12 +1816,16 @@ class: SessionManagerMode : MinorMode if (sourceNode neq nil) { - // Fire events that fetch filmstrip/thumbnail and return file paths + // Fetch filmstrip/thumbnail paths. The local plugin has a lower priority of + // 10 for ordering. This means any custom plugin of higher priority will be used first. + // This allows users to override the local plugin with a custom plugin by making sure + // the ordering is less than 10 and using event.accept() to prevent the local plugin from running. let filmstripPath = sendInternalEvent("session-manager-get-filmstrip-path", sourceNode); let thumbnailPath = sendInternalEvent("session-manager-get-thumbnail-path", sourceNode); if (filmstripPath != "" && io.path.exists(filmstripPath)) preview.loadStrip(filmstripPath); + if (thumbnailPath != "" && io.path.exists(thumbnailPath)) preview.loadThumbnail(thumbnailPath); @@ -2155,6 +2158,12 @@ class: SessionManagerMode : MinorMode event.reject(); } + method: onLocalPreviewGenerationComplete (void; Event event) + { + event.reject(); + if (!_lazyUpdateTimer.isActive()) _lazyUpdateTimer.start(0); + } + method: beforeProgressiveLoading (void; Event event) { event.reject(); @@ -3041,6 +3050,7 @@ class: SessionManagerMode : MinorMode _editors = QTreeWidgetItem[](); _quitting = false; _disableUpdates = false; + _progressiveLoadingInProgress = (loadTotal() != 0); init(name, @@ -3058,7 +3068,8 @@ class: SessionManagerMode : MinorMode ("key-down--@", showRows, "show'em"), ("before-session-deletion", enterQuittingState, "Store quitting before session goes away"), ("view-edit-mode-activated", viewEditModeActivated, "Per-view edit mode activated, load UI"), - ("event-category-state-changed", onCategoryStateChanged, "Category state changed") + ("event-category-state-changed", onCategoryStateChanged, "Category state changed"), + ("local-preview-generation-complete", onLocalPreviewGenerationComplete, "Refresh UI after local preview generation") ], nil, nil); From 10b37154002c4702973f258f9d64f057428ee014 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Mon, 30 Mar 2026 13:08:54 -0400 Subject: [PATCH 11/22] Update on ready event name and remove unnecessary handler Signed-off-by: chenl1 --- .../rv-packages/session_manager/local_thumbnail_gen.py | 2 +- .../rv-packages/session_manager/session_manager.mu.in | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py index 1fe52614e..e2fb846aa 100644 --- a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py +++ b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py @@ -383,7 +383,7 @@ def _on_generation_done(self, cache_key: str, thumbnail_path: str, filmstrip_pat def _trigger_ui_refresh(self) -> None: """Called by the coalescing timer. Tells the session manager to rebuild.""" - commands.sendInternalEvent("local-preview-generation-complete", "") + commands.sendInternalEvent("session-manager-preview-available", "") def _on_session_deletion(self, event: Any) -> None: event.reject() diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index b83343461..bcd62f1ba 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -2158,12 +2158,6 @@ class: SessionManagerMode : MinorMode event.reject(); } - method: onLocalPreviewGenerationComplete (void; Event event) - { - event.reject(); - if (!_lazyUpdateTimer.isActive()) _lazyUpdateTimer.start(0); - } - method: beforeProgressiveLoading (void; Event event) { event.reject(); @@ -3069,7 +3063,7 @@ class: SessionManagerMode : MinorMode ("before-session-deletion", enterQuittingState, "Store quitting before session goes away"), ("view-edit-mode-activated", viewEditModeActivated, "Per-view edit mode activated, load UI"), ("event-category-state-changed", onCategoryStateChanged, "Category state changed"), - ("local-preview-generation-complete", onLocalPreviewGenerationComplete, "Refresh UI after local preview generation") + ("session-manager-preview-available", updateTreeEvent, "Refresh UI after preview is available") ], nil, nil); From fecfa65ba3ad91bdb235870bf3b5d861e3814a06 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 31 Mar 2026 12:47:35 -0400 Subject: [PATCH 12/22] Seperate thumbnail and filmstrip generation to be in 2 threads Signed-off-by: chenl1 --- .../session_manager/local_thumbnail_gen.py | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py index e2fb846aa..2f36dd1be 100644 --- a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py +++ b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py @@ -30,7 +30,7 @@ class _SignalBridge(QtCore.QObject): """Bridges background threads to the main thread via Qt signals.""" - finished = QtCore.Signal(str, str, str) # cache_key, thumbnail_path ("" if failed), filmstrip_path ("" if failed) + finished = QtCore.Signal(str, str, str) # cache_key, path_key, output_path ("" if failed) class LocalThumbnailGen(rvtypes.MinorMode): @@ -123,7 +123,15 @@ def _start_generation(self, source_node: str, cache_key: str, media_path: str) - start_frame, end_frame, width, height = source_info self._pool.submit( - self._generate_source, + self._generate_thumbnail, + cache_key, + rvio_bin, + media_path, + (start_frame + end_frame) // 2, + ) + + self._pool.submit( + self._generate_filmstrip, cache_key, rvio_bin, media_path, @@ -311,7 +319,25 @@ def _write_filmstrip_session( return output_width, output_height - def _generate_source( + def _generate_thumbnail(self, cache_key: str, rvio_bin: str, media_path: str, mid_frame: int) -> None: + """Runs rvio to generate a single-frame thumbnail in a worker thread.""" + output_path = self._cache_dir / f"{cache_key}_thumbnail.jpg" + try: + subprocess.run( + [rvio_bin, media_path, "-t", str(mid_frame), "-o", str(output_path)], + capture_output=True, + timeout=120, + ) + except Exception as e: + logger.error(f"Thumbnail generation failed: {e}") + + self._bridge.finished.emit( + cache_key, + "thumbnail_path", + str(output_path) if output_path.exists() else "", + ) + + def _generate_filmstrip( self, cache_key: str, rvio_bin: str, @@ -321,19 +347,8 @@ def _generate_source( width: int, height: int, ) -> None: - """Runs both rvio commands sequentially in a worker thread.""" - thumbnail_path = self._cache_dir / f"{cache_key}_thumbnail.jpg" - filmstrip_path = self._cache_dir / f"{cache_key}_filmstrip.jpg" - - try: - subprocess.run( - [rvio_bin, media_path, "-t", str((start_frame + end_frame) // 2), "-o", str(thumbnail_path)], - capture_output=True, - timeout=120, - ) - except Exception as e: - logger.error(f"Thumbnail generation failed: {e}") - + """Runs rvio to generate a filmstrip image in a worker thread.""" + output_path = self._cache_dir / f"{cache_key}_filmstrip.jpg" session_path = self._cache_dir / f"filmstrip_{cache_key}.rv" try: output_width, output_height = self._write_filmstrip_session( @@ -350,7 +365,7 @@ def _generate_source( str(output_width), str(output_height), "-o", - str(filmstrip_path), + str(output_path), ], capture_output=True, timeout=120, @@ -365,20 +380,19 @@ def _generate_source( self._bridge.finished.emit( cache_key, - str(thumbnail_path) if thumbnail_path.exists() else "", - str(filmstrip_path) if filmstrip_path.exists() else "", + "filmstrip_path", + str(output_path) if output_path.exists() else "", ) - def _on_generation_done(self, cache_key: str, thumbnail_path: str, filmstrip_path: str) -> None: - """Called on the main thread when both rvio jobs complete for a source.""" - entry: dict[str, Path | None] = {} - if thumbnail_path: - entry["thumbnail_path"] = Path(thumbnail_path) - if filmstrip_path: - entry["filmstrip_path"] = Path(filmstrip_path) + def _on_generation_done(self, cache_key: str, path_key: str, output_path: str) -> None: + """Called on the main thread when a single rvio job completes.""" + if output_path: + self._cache.setdefault(cache_key, {})[path_key] = Path(output_path) + + cached = self._cache.get(cache_key, {}) + if "thumbnail_path" in cached and "filmstrip_path" in cached: + self._in_flight.discard(cache_key) - self._cache[cache_key] = entry - self._in_flight.discard(cache_key) self._refresh_timer.start() def _trigger_ui_refresh(self) -> None: From 574b07583ff9dac293bcd5537bc7cdead722c908 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 31 Mar 2026 13:47:16 -0400 Subject: [PATCH 13/22] Make each event only generate their corresponding preview Signed-off-by: chenl1 --- .../session_manager/local_thumbnail_gen.py | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py index 2f36dd1be..04e4de0c8 100644 --- a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py +++ b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py @@ -98,8 +98,9 @@ def _get_cached_path(self, event: Any, path_key: str) -> None: event.setReturnContent(str(path)) return - if cache_key not in self._in_flight: - self._start_generation(source_node, cache_key, media_path) + flight_key = f"{cache_key}_{path_key}" + if flight_key not in self._in_flight: + self._start_generation(source_node, cache_key, media_path, path_key) event.setReturnContent("") @@ -109,7 +110,7 @@ def _on_get_filmstrip_path(self, event: Any) -> None: def _on_get_thumbnail_path(self, event: Any) -> None: self._get_cached_path(event, "thumbnail_path") - def _start_generation(self, source_node: str, cache_key: str, media_path: str) -> None: + def _start_generation(self, source_node: str, cache_key: str, media_path: str, path_key: str) -> None: rvio_bin = self._get_rvio_bin() if not rvio_bin: return @@ -118,28 +119,29 @@ def _start_generation(self, source_node: str, cache_key: str, media_path: str) - if not source_info: return - self._in_flight.add(cache_key) + self._in_flight.add(f"{cache_key}_{path_key}") start_frame, end_frame, width, height = source_info - self._pool.submit( - self._generate_thumbnail, - cache_key, - rvio_bin, - media_path, - (start_frame + end_frame) // 2, - ) - - self._pool.submit( - self._generate_filmstrip, - cache_key, - rvio_bin, - media_path, - start_frame, - end_frame, - width, - height, - ) + if path_key == "thumbnail_path": + self._pool.submit( + self._generate_thumbnail, + cache_key, + rvio_bin, + media_path, + (start_frame + end_frame) // 2, + ) + else: + self._pool.submit( + self._generate_filmstrip, + cache_key, + rvio_bin, + media_path, + start_frame, + end_frame, + width, + height, + ) def _cache_key(self, media_path: str) -> str: return hashlib.sha256(media_path.encode()).hexdigest()[:16] @@ -389,10 +391,7 @@ def _on_generation_done(self, cache_key: str, path_key: str, output_path: str) - if output_path: self._cache.setdefault(cache_key, {})[path_key] = Path(output_path) - cached = self._cache.get(cache_key, {}) - if "thumbnail_path" in cached and "filmstrip_path" in cached: - self._in_flight.discard(cache_key) - + self._in_flight.discard(f"{cache_key}_{path_key}") self._refresh_timer.start() def _trigger_ui_refresh(self) -> None: From b07614b742d14b11533fee720da75af6c6286425 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 31 Mar 2026 14:16:29 -0400 Subject: [PATCH 14/22] Update session manager package version Signed-off-by: chenl1 --- src/plugins/rv-packages/session_manager/PACKAGE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/rv-packages/session_manager/PACKAGE b/src/plugins/rv-packages/session_manager/PACKAGE index b0bae673f..97ff21d0d 100644 --- a/src/plugins/rv-packages/session_manager/PACKAGE +++ b/src/plugins/rv-packages/session_manager/PACKAGE @@ -1,7 +1,7 @@ package: Session Manager author: Autodesk, Inc. organization: Autodesk, Inc. -version: 1.9 +version: 1.10 rv: 3.12.15 openrv: 1.0.0 requires: '' From 19de5fcf46b4917871a025fd0bdf7e0436c18449 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Thu, 2 Apr 2026 10:17:31 -0400 Subject: [PATCH 15/22] Add ordering 100 to mode_manager to make sure it runs last Signed-off-by: chenl1 --- src/lib/app/mu_rvui/mode_manager.mu | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/app/mu_rvui/mode_manager.mu b/src/lib/app/mu_rvui/mode_manager.mu index 5b7363aa7..7661ce247 100644 --- a/src/lib/app/mu_rvui/mode_manager.mu +++ b/src/lib/app/mu_rvui/mode_manager.mu @@ -787,7 +787,8 @@ class: ModeManagerMode : MinorMode ("before-graph-view-change", viewChange(,false), "Deactivate mode when view changes"), ("after-graph-view-change", viewChange(,true), "Activate mode when view changes")], nil, - "zzzzzz"); + "zzzzzz", + 100); try { From ff381613665af7dc53acb83f2542da0db2e2db56 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Thu, 2 Apr 2026 13:07:34 -0400 Subject: [PATCH 16/22] Add comment explaining mode_manager sortKey and ordering Signed-off-by: chenl1 --- src/lib/app/mu_rvui/mode_manager.mu | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/app/mu_rvui/mode_manager.mu b/src/lib/app/mu_rvui/mode_manager.mu index 7661ce247..001e736c1 100644 --- a/src/lib/app/mu_rvui/mode_manager.mu +++ b/src/lib/app/mu_rvui/mode_manager.mu @@ -779,6 +779,11 @@ class: ModeManagerMode : MinorMode _otherLoads = commandLineFlag("ModeManagerLoad", "").split(","); _rejectLoads = commandLineFlag("ModeManagerReject", "").split(","); + // "zzzzzz" is the sortKey and 100 is the ordering (ordering takes priority over sortKey) + // Their current value is chosen because we want this mode to run last + // as it deactivates all other modes. Therefore, when creating a new mode, + // make sure that its ordering and/or sortKey is lower than mode_manager so your + // before-session-deletion (and similar) handlers run before deactivateAll tears modes down. init("ModeManager", nil, [("state-initialized", load, "Load installed modes"), From 9dccdbfb12d43fffd92446601b17951d485b1e98 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Tue, 7 Apr 2026 12:15:23 -0400 Subject: [PATCH 17/22] Add env and ui to turn off preview in session manager Signed-off-by: chenl1 --- .../session_manager/session_manager.mu.in | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index bcd62f1ba..7da4abfaa 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -1106,6 +1106,7 @@ class: SessionManagerMode : MinorMode QIcon _fallbackSourceIcon; bool _inputOrderLock; bool _disableUpdates; + bool _previewsEnabled; bool _progressiveLoadingInProgress; QTimer _lazySetInputsTimer; QTimer _lazyUpdateTimer; @@ -1496,7 +1497,7 @@ class: SessionManagerMode : MinorMode item.setData(QVariant(innode), Qt.UserRole + 2); item.setEditable(false); - if (isSource) + if (isSource && _previewsEnabled) { item.setText(""); item.setSizeHint(QSize(-1, SOURCE_ROW_HEIGHT)); @@ -1504,7 +1505,7 @@ class: SessionManagerMode : MinorMode _inputsModel.appendRow(item); - if (isSource) + if (isSource && _previewsEnabled) _inputsView.setIndexWidget(_inputsModel.indexFromItem(item), makeSourceRowWidget(innode)); } @@ -1893,7 +1894,7 @@ class: SessionManagerMode : MinorMode if (node == viewNode()) head(tail(statusItems)).setText("\u2714"); addRow(parentItem, item : statusItems); - if (source) + if (source && _previewsEnabled) { item.setText(""); item.setSizeHint(QSize(-1, SOURCE_ROW_HEIGHT)); @@ -3037,6 +3038,14 @@ class: SessionManagerMode : MinorMode writeSetting("Tools", "show_session_manager", Bool(show)); } + method: togglePreviews (void; bool checked) + { + _previewsEnabled = checked; + use SettingsValue; + writeSetting("SessionManager", "previewsEnabled", Bool(checked)); + updateTree(); + } + method: SessionManagerMode (SessionManagerMode; string name) { _darkUI = true; @@ -3045,6 +3054,18 @@ class: SessionManagerMode : MinorMode _quitting = false; _disableUpdates = false; + let previewsEnv = system.getenv("SESSION_MANAGER_PREVIEWS", nil); + if (previewsEnv neq nil && previewsEnv == "DISABLE") + { + _previewsEnabled = false; + } + else + { + use SettingsValue; + let Bool enabled = readSetting("SessionManager", "previewsEnabled", Bool(true)); + _previewsEnabled = bool(enabled); + } + _progressiveLoadingInProgress = (loadTotal() != 0); init(name, @@ -3336,6 +3357,17 @@ class: SessionManagerMode : MinorMode configGroup.addAction(a); } + let _ = configMenu.addSeparator(), + previewToggle = configMenu.addAction("Show Source Previews"); + + previewToggle.setCheckable(true); + previewToggle.setChecked(_previewsEnabled); + + if (previewsEnv neq nil && previewsEnv == "DISABLE") + { + previewToggle.setEnabled(false); + } + _configButton.setMenu(configMenu); try @@ -3387,6 +3419,7 @@ class: SessionManagerMode : MinorMode connect(configAlwaysOn, QAction.triggered, configSlot(,"yes",true)); connect(configNeverOn, QAction.triggered, configSlot(,"no",false)); connect(configLastOn, QAction.triggered, configSlot(,"last",true)); + connect(previewToggle, QAction.toggled, togglePreviews); connect(_viewModel, QStandardItemModel.itemChanged, viewItemChanged); connect(_viewTreeView, QTreeView.expanded, setItemExpandedState(,1)); connect(_viewTreeView, QTreeView.collapsed, setItemExpandedState(,0)); From 1e460a75e9e847fcb67a9d544e8461d668819ce1 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Wed, 8 Apr 2026 10:24:28 -0400 Subject: [PATCH 18/22] Rename env to RV_SESSION_MANAGER_USE_THUMBNAILS for previews Signed-off-by: chenl1 --- src/plugins/rv-packages/session_manager/session_manager.mu.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 7da4abfaa..13ba68efd 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -3054,7 +3054,7 @@ class: SessionManagerMode : MinorMode _quitting = false; _disableUpdates = false; - let previewsEnv = system.getenv("SESSION_MANAGER_PREVIEWS", nil); + let previewsEnv = system.getenv("RV_SESSION_MANAGER_USE_THUMBNAILS", nil); if (previewsEnv neq nil && previewsEnv == "DISABLE") { _previewsEnabled = false; From eaa6c8acb28f5909c4a72d7ae88dc3661844796b Mon Sep 17 00:00:00 2001 From: chenl1 Date: Wed, 8 Apr 2026 11:29:36 -0400 Subject: [PATCH 19/22] Make the disable condition for previews to be 0 instead of DISABLE Signed-off-by: chenl1 --- src/plugins/rv-packages/session_manager/session_manager.mu.in | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 13ba68efd..8182ff15e 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -3055,7 +3055,7 @@ class: SessionManagerMode : MinorMode _disableUpdates = false; let previewsEnv = system.getenv("RV_SESSION_MANAGER_USE_THUMBNAILS", nil); - if (previewsEnv neq nil && previewsEnv == "DISABLE") + if (previewsEnv neq nil && previewsEnv == "0") { _previewsEnabled = false; } @@ -3363,7 +3363,7 @@ class: SessionManagerMode : MinorMode previewToggle.setCheckable(true); previewToggle.setChecked(_previewsEnabled); - if (previewsEnv neq nil && previewsEnv == "DISABLE") + if (previewsEnv neq nil && previewsEnv == "0") { previewToggle.setEnabled(false); } From fb890ec3caf576a85faf6d60f5802acb23458f39 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Wed, 8 Apr 2026 17:59:02 -0400 Subject: [PATCH 20/22] Isolate local cache dir with PID suffix Signed-off-by: chenl1 --- src/plugins/rv-packages/session_manager/local_thumbnail_gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py index 04e4de0c8..35a35c4da 100644 --- a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py +++ b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py @@ -46,7 +46,7 @@ class LocalThumbnailGen(rvtypes.MinorMode): def __init__(self) -> None: rvtypes.MinorMode.__init__(self) self._cache: dict[str, dict[str, Path | None]] = {} - self._cache_dir = Path(tempfile.gettempdir()) / "rv_thumbnails" + self._cache_dir = Path(tempfile.gettempdir()) / f"rv_thumbnails_{os.getpid()}" self._cache_dir.mkdir(parents=True, exist_ok=True) self._in_flight: set[str] = set() self._pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) From 19213b581de9255ad30d4da673505cef4b12af70 Mon Sep 17 00:00:00 2001 From: chenl1 Date: Wed, 8 Apr 2026 18:37:44 -0400 Subject: [PATCH 21/22] Make mouse movement CY2023 compatible Signed-off-by: chenl1 --- src/plugins/rv-packages/session_manager/session_manager.mu.in | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/rv-packages/session_manager/session_manager.mu.in b/src/plugins/rv-packages/session_manager/session_manager.mu.in index 8182ff15e..1c00217c7 100755 --- a/src/plugins/rv-packages/session_manager/session_manager.mu.in +++ b/src/plugins/rv-packages/session_manager/session_manager.mu.in @@ -672,7 +672,7 @@ class: FilmstripWidget : QLabel method: mouseMoveEvent (void; QMouseEvent event) { - showFrameAtX(event.position().toPoint().x()); + showFrameAtX(@MU_QT_QDRAGMOVEEVENT_POSITION@.x()); QWidget.mouseMoveEvent(this, event); } } From 35465ac2612e2e8fac1a02f494b696d786f59d5e Mon Sep 17 00:00:00 2001 From: chenl1 Date: Mon, 13 Apr 2026 12:43:28 -0400 Subject: [PATCH 22/22] Add way to store previews instead of being volatile and make them reusable Signed-off-by: chenl1 --- .../session_manager/local_thumbnail_gen.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py index 35a35c4da..6d33cb860 100644 --- a/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py +++ b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py @@ -48,6 +48,7 @@ def __init__(self) -> None: self._cache: dict[str, dict[str, Path | None]] = {} self._cache_dir = Path(tempfile.gettempdir()) / f"rv_thumbnails_{os.getpid()}" self._cache_dir.mkdir(parents=True, exist_ok=True) + self._persistent = bool(os.getenv("RV_PERSISTENT_PREVIEW_CACHE")) self._in_flight: set[str] = set() self._pool = ThreadPoolExecutor(max_workers=MAX_WORKERS) @@ -98,6 +99,13 @@ def _get_cached_path(self, event: Any, path_key: str) -> None: event.setReturnContent(str(path)) return + if self._persistent: + persistent_path = self._persistent_preview_path(media_path, cache_key, path_key) + if persistent_path.exists(): + self._cache.setdefault(cache_key, {})[path_key] = persistent_path + event.setReturnContent(str(persistent_path)) + return + flight_key = f"{cache_key}_{path_key}" if flight_key not in self._in_flight: self._start_generation(source_node, cache_key, media_path, path_key) @@ -146,6 +154,22 @@ def _start_generation(self, source_node: str, cache_key: str, media_path: str, p def _cache_key(self, media_path: str) -> str: return hashlib.sha256(media_path.encode()).hexdigest()[:16] + def _persistent_preview_path(self, media_path: str, cache_key: str, path_key: str) -> Path: + type_name = path_key.replace("_path", "") + return Path(media_path).parent / "rv_previews" / f"{cache_key}_{type_name}.jpg" + + def _resolve_output_path(self, media_path: str, cache_key: str, path_key: str) -> Path: + """Try persistent storage alongside source, fall back to temp dir.""" + if self._persistent: + try: + persistent_path = self._persistent_preview_path(media_path, cache_key, path_key) + persistent_path.parent.mkdir(parents=True, exist_ok=True) + return persistent_path + except OSError: + logger.warning("Cannot create persistent cache dir, falling back to temp dir") + type_name = path_key.replace("_path", "") + return self._cache_dir / f"{cache_key}_{type_name}.jpg" + def _get_rvio_bin(self) -> str | None: rvio = os.getenv("RV_APP_RVIO") if not rvio: @@ -323,7 +347,7 @@ def _write_filmstrip_session( def _generate_thumbnail(self, cache_key: str, rvio_bin: str, media_path: str, mid_frame: int) -> None: """Runs rvio to generate a single-frame thumbnail in a worker thread.""" - output_path = self._cache_dir / f"{cache_key}_thumbnail.jpg" + output_path = self._resolve_output_path(media_path, cache_key, "thumbnail_path") try: subprocess.run( [rvio_bin, media_path, "-t", str(mid_frame), "-o", str(output_path)], @@ -350,7 +374,7 @@ def _generate_filmstrip( height: int, ) -> None: """Runs rvio to generate a filmstrip image in a worker thread.""" - output_path = self._cache_dir / f"{cache_key}_filmstrip.jpg" + output_path = self._resolve_output_path(media_path, cache_key, "filmstrip_path") session_path = self._cache_dir / f"filmstrip_{cache_key}.rv" try: output_width, output_height = self._write_filmstrip_session(