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/lib/app/mu_rvui/mode_manager.mu b/src/lib/app/mu_rvui/mode_manager.mu index 5b7363aa7..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"), @@ -787,7 +792,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 { diff --git a/src/plugins/rv-packages/session_manager/PACKAGE b/src/plugins/rv-packages/session_manager/PACKAGE index 63ffde9c5..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: '' @@ -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/fallback_thumbnail.png b/src/plugins/rv-packages/session_manager/fallback_thumbnail.png new file mode 100644 index 000000000..e6591d42a Binary files /dev/null and b/src/plugins/rv-packages/session_manager/fallback_thumbnail.png differ 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..6d33cb860 --- /dev/null +++ b/src/plugins/rv-packages/session_manager/local_thumbnail_gen.py @@ -0,0 +1,446 @@ +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, path_key, output_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()) / 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) + + 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 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) + + 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, path_key: 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(f"{cache_key}_{path_key}") + + start_frame, end_frame, width, height = source_info + + 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] + + 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: + 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_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._resolve_output_path(media_path, cache_key, "thumbnail_path") + 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, + media_path: str, + start_frame: int, + end_frame: int, + width: int, + height: int, + ) -> None: + """Runs rvio to generate a filmstrip image in a worker thread.""" + 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( + 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(output_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, + "filmstrip_path", + str(output_path) if output_path.exists() else "", + ) + + 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) + + self._in_flight.discard(f"{cache_key}_{path_key}") + self._refresh_timer.start() + + def _trigger_ui_refresh(self) -> None: + """Called by the coalescing timer. Tells the session manager to rebuild.""" + commands.sendInternalEvent("session-manager-preview-available", "") + + 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 f660c1fa1..1c00217c7 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,15 @@ ViewSubComponent := 2; LayerSubComponent := 3; ChannelSubComponent := 4; +FILMSTRIP_FRAME_WIDTH := 240; +SOURCE_PREVIEW_WIDTH := 80; +SOURCE_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) { let d = item.data(Qt.UserRole + 2); @@ -601,6 +610,120 @@ 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 pixmap) { setPixmap(pixmap); } + + method: load (void; string path) + { + let pixmap = QPixmap.fromImage(QImage(path, ""), Qt.AutoColor); + if (!pixmap.isNull()) setPixmap(pixmap); + } +} + +// Displays a scrubable filmstrip that will show the corresponding frame +// based on mouse position +class: FilmstripWidget : QLabel +{ + QImage _strip; + int _frameWidth; + bool _loaded; + + method: FilmstripWidget (FilmstripWidget; QWidget parent) + { + QLabel.QLabel(this, parent); + _frameWidth = FILMSTRIP_FRAME_WIDTH; + _loaded = false; + setScaledContents(true); + setMouseTracking(true); + } + + method: showFrameAtX (void; int mouseX) + { + 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)); + } + + method: isLoaded (bool;) { _loaded; } + + method: load (void; string path) + { + let filmstripImage = QImage(path, ""); + if (!filmstripImage.isNull()) + { + _strip = filmstripImage; + _loaded = true; + } + } + + method: mouseMoveEvent (void; QMouseEvent event) + { + showFrameAtX(@MU_QT_QDRAGMOVEEVENT_POSITION@.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, SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT)); + _thumbnail.show(); + + _filmstrip = FilmstripWidget(this); + _filmstrip.setGeometry(QRect(0, 0, SOURCE_PREVIEW_WIDTH, SOURCE_PREVIEW_HEIGHT)); + _filmstrip.hide(); + } + + 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 event) + { + if (event.type() == QEvent.HoverEnter) + { + if (_filmstrip.isLoaded()) + { + _filmstrip.showFrameAtX(mapFromGlobal(QCursor.pos()).x()); + _filmstrip.show(); + _thumbnail.hide(); + } + return true; + } + else if (event.type() == QEvent.HoverLeave) + { + _filmstrip.hide(); + _thumbnail.show(); + return true; + } + return QWidget.event(this, event); + } +} + class: NodeModel : QStandardItemModel { method: NodeModel (NodeModel; QObject parent) @@ -980,8 +1103,10 @@ class: SessionManagerMode : MinorMode QIcon _layerIcon; QIcon _channelIcon; QIcon _videoIcon; + QIcon _fallbackSourceIcon; bool _inputOrderLock; bool _disableUpdates; + bool _previewsEnabled; bool _progressiveLoadingInProgress; QTimer _lazySetInputsTimer; QTimer _lazyUpdateTimer; @@ -1360,20 +1485,28 @@ 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], - item = QStandardItem(iconForNode(innode), uiName(innode)), - vindex = indexOfItem(vnodes, innode); + let innode = connections[i], + isSource = nodeType(innode) == "RVSourceGroup", + item = QStandardItem(iconForNode(innode), uiName(innode)); item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled); item.setData(QVariant(innode), Qt.UserRole + 2); item.setEditable(false); - + + if (isSource && _previewsEnabled) + { + item.setText(""); + item.setSizeHint(QSize(-1, SOURCE_ROW_HEIGHT)); + } + _inputsModel.appendRow(item); + + if (isSource && _previewsEnabled) + _inputsView.setIndexWidget(_inputsModel.indexFromItem(item), makeSourceRowWidget(innode)); } _inputOrderLock = false; @@ -1662,6 +1795,75 @@ class: SessionManagerMode : MinorMode item; } + method: makeSourceRowWidget (QWidget; string node) + { + let widget = QWidget(nil, 0), + layout = QHBoxLayout(widget); + widget.setObjectName("sourceRowWidget"); + layout.setContentsMargins(SOURCE_ROW_MARGIN, 0, SOURCE_ROW_MARGIN, 0); + layout.setSpacing(SOURCE_ROW_SPACING); + + let preview = SourcePreviewWidget(widget); + 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; + try { sourceNode = sourceNodeOfGroup(node); } + catch (exception error) + { + print("WARNING: Could not get source node for %s - %s\n" % (uiName(node), error)); + } + + if (sourceNode neq nil) + { + // 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); + + 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(); + } + } + } + + layout.addWidget(preview); + + // Text column + let textWidget = QWidget(widget), + textLayout = QVBoxLayout(textWidget); + textWidget.setObjectName("sourceTextWidget"); + textLayout.setSpacing(SOURCE_TEXT_SPACING); + + let nameLabel = QLabel(uiName(node), textWidget); + nameLabel.setObjectName("sourceNameLabel"); + textLayout.addWidget(nameLabel); + + let metaLabel = QLabel(if meta == "" then "—" else meta, textWidget); + metaLabel.setObjectName("sourceMetaLabel"); + textLayout.addWidget(metaLabel); + textLayout.addStretch(1); + + layout.addWidget(textWidget, 1); + + widget; + } + method: newNodeRow (void; QStandardItem parentItem, string node, @@ -1692,6 +1894,13 @@ class: SessionManagerMode : MinorMode if (node == viewNode()) head(tail(statusItems)).setText("\u2714"); addRow(parentItem, item : statusItems); + if (source && _previewsEnabled) + { + item.setText(""); + item.setSizeHint(QSize(-1, SOURCE_ROW_HEIGHT)); + _viewTreeView.setIndexWidget(_viewModel.indexFromItem(item), makeSourceRowWidget(node)); + } + // // Tabs in tooltips make win32 Qt crash. // @@ -2829,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; @@ -2836,6 +3053,19 @@ class: SessionManagerMode : MinorMode _editors = QTreeWidgetItem[](); _quitting = false; _disableUpdates = false; + + let previewsEnv = system.getenv("RV_SESSION_MANAGER_USE_THUMBNAILS", nil); + if (previewsEnv neq nil && previewsEnv == "0") + { + _previewsEnabled = false; + } + else + { + use SettingsValue; + let Bool enabled = readSetting("SessionManager", "previewsEnabled", Bool(true)); + _previewsEnabled = bool(enabled); + } + _progressiveLoadingInProgress = (loadTotal() != 0); init(name, @@ -2853,7 +3083,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"), + ("session-manager-preview-available", updateTreeEvent, "Refresh UI after preview is available") ], nil, nil); @@ -2929,6 +3160,7 @@ class: SessionManagerMode : MinorMode _viewTreeView.setDragDropMode(QAbstractItemView.DragDrop); _viewTreeView.setDefaultDropAction(Qt.MoveAction); _viewTreeView.setExpandsOnDoubleClick(false); + _viewTreeView.setIndentation(TREE_VIEW_INDENTATION); _inputsView.setModel(_inputsModel); _inputsView.setDragEnabled(true); @@ -2986,6 +3218,7 @@ class: SessionManagerMode : MinorMode _channelIcon = auxIcon("channel.png", true); _layerIcon = auxIcon("layer.png", true); _unknownTypeIcon = auxIcon("new_48x48.png", true); + _fallbackSourceIcon = QIcon(auxFilePath("fallback_thumbnail.png")); _addButton.setDefaultAction(addAction); _deleteButton.setDefaultAction(deleteAction); @@ -3124,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 == "0") + { + previewToggle.setEnabled(false); + } + _configButton.setMenu(configMenu); try @@ -3175,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));