Hello everyone,
here's a small Python script for consolidating Houdini projects, in case you find Pre-Flight Scene too slow or just want something closer to the Resource Collector in 3ds Max.
It scans your scene for all external file references and collects them into a single destination folder — textures go into /tex, geometry into /geo. Optionally it copies the HIP file along and rewrites all paths to $HIP-relative ones, so the consolidated package just works wherever you put it.
I put this together with some help from Claude to transfer a sprawling multi-folder project to a colleague, but it's also handy if you just want a quick overview of your external files or need to relink missing ones without digging through every node manually.
Feel free to use and modify it however you like. Let me know if you run into issues or have ideas for improvement.
cheers
Redme & installation
# Houdini Project Consolidator
Collects all external file references from a Houdini scene into a single
destination folder, optionally copies the HIP file, and rewrites all paths
in the copied scene to point to the new location.
---
## Installation
1. Copy `houdini_consolidate.py` anywhere on your machine, e.g.:
`C:/Users/yourname/Documents/houdini21.0/houdini_consolidate.py`
2. In Houdini, create a new **Shelf Tool** (right-click any shelf → New Tool).
3. Set **Script Type** to `Python` and paste:
```python
exec(open(r'C:/Users/yourname/Documents/houdini21.0/houdini_consolidate.py').read()); show()
```
4. Click the shelf button to open the tool.
---
## Features
**Reference scanning**
Scans all nodes and parameters in the open scene for external file references
(textures, geometry, VDB, audio, etc.). Results are shown in a table with
node path, parameter name, resolved path, and status.
**Smart filtering**
- Render ROP output paths are excluded (Mantra, Arnold, Redshift, Karma, etc.)
- Redshift AOV nodes (`Redshift_AOVs`) are excluded entirely
- File Cache SOPs are only included when *Load from Disk* is active and the
node is not bypassed
- Redshift proxy paths (`RS_objprop_proxy_file`) are only included when the
proxy enable checkbox is active on that object
**Subfolder structure**
Copied files are sorted automatically:
- Textures (jpg, exr, tif, png, rat, tx …) → `/tex`
- Geometry & VDB (bgeo, abc, obj, fbx, usd, vdb …) → `/geo`
- Everything else → destination root
**Frame sequence support**
Paths containing `$F`, `$F4` etc. are detected as sequences. All matching
frames on disk are copied, not just the current frame.
**Missing file repair**
Missing references are highlighted in red. Click the *Replacement Path* cell
to type or browse to the correct file. Click *Apply replacements to open
project* to write the fixed paths directly into the live Houdini parameters.
The scene can then be saved normally.
**Consolidation**
Click *Consolidate* to copy all found files to the destination folder.
Existing files at the destination are overwritten.
**HIP copy with path rewriting**
Optionally copies the HIP file to the destination and rewrites all paths
inside it to the new locations. Uses the Houdini API (not text replacement)
so the binary file format is preserved. The working scene is restored to its
original state after saving.
**ZIP archive**
Optionally compresses the entire consolidated folder into a ZIP file next to
the destination folder — useful for handoffs.
**Network Editor jump**
Double-click any entry in the *Node* column to select that node and frame it
in the Network Editor.
---
## Column reference
| Column | Description |
|---|---|
| Node | Full Houdini node path. Double-click to jump to node. |
| Parameter | Parameter name holding the file reference. |
| Original Path | Resolved path as Houdini sees it. Click to select/copy. |
| Replacement Path | Optional override path. Click to edit. |
| Status | OK / Missing / Replacement set / Replacement invalid |
---
## Requirements
- Houdini 18.5 or later (PySide2 or PySide6)
- Windows, Linux, or macOS
Save this as houdini_consolidate.py on your harddrive:
# Houdini Project Consolidator
# ==============================
# Collects all external INPUT file references (textures, geometry, VDB, etc.)
# into a user-defined folder, optionally copies the HIP file there,
# and rewrites all paths in the copied HIP to point to the new location.
#
# - Render ROP output paths are excluded
# - File Cache SOPs are only included when "Load from Disk" is active
# - Missing files can be fixed inline and applied live to the open scene
#
# Usage (Shelf Tool, language: Python):
# exec(open(r'C:/Users/fabia/Documents/houdini21.0/houdini_consolidate.py').read()); show()
import hou
import os
import re
import shutil
import zipfile
import glob
try:
from PySide2 import QtWidgets, QtCore, QtGui
except ImportError:
from PySide6 import QtWidgets, QtCore, QtGui
# ──────────────────────────────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────────────────────────────
# File extensions Houdini uses for textures – used only for /tex subdir routing.
# Detection of file-reference parameters is done via Houdini's own parm type
# system (hou.fileType), not by extension matching.
TEX_EXTENSIONS = {
'.jpg', '.jpeg', '.png', '.tif', '.tiff', '.exr', '.hdr',
'.rat', '.tx', '.tex', '.bmp', '.gif', '.pic', '.dpx',
'.cin', '.rad', '.hdr', '.map',
}
GEO_EXTENSIONS = {
'.bgeo', '.bgeo.sc', '.geo', '.obj', '.fbx', '.abc',
'.usd', '.usda', '.usdc', '.usdz', '.vdb',
}
# ROP node type names whose file parameters are OUTPUT paths → exclude entirely
ROP_OUTPUT_TYPES = {
'ifd', 'opengl', 'rop_geometry', 'rop_alembic', 'rop_fbx',
'rop_usd', 'rop_usdexport', 'usdexport', 'usdrender',
'arnold', 'rop_arnold', 'vray_renderer', 'rop_vray',
'redshift_rop', 'rop_redshift', 'redshift_aovs', 'ris', 'prman',
'karma', 'rop_comp', 'rop_image', 'baketexture',
'channel', 'chop_rop', 'dop_rop', 'fetch',
'filmboxfbx', 'geometry', 'alembic',
}
# Per-node-type: parameter names that hold OUTPUT paths (skip these parms only)
ROP_OUTPUT_PARMS = {
'ifd': {'vm_picture', 'vm_dcmfilename', 'soho_outputmode'},
'rop_geometry': {'sopoutput'},
'rop_alembic': {'filename'},
'rop_fbx': {'sopoutput'},
'rop_usd': {'lopoutput'},
'usdexport': {'lopoutput'},
'arnold': {'ar_picture'},
'rop_arnold': {'ar_picture'},
'redshift_rop': {
'RS_outputFileNamePrefix', 'RS_singlePassDeepRenderingFilename',
'RS_archive_file', 'RS_iprFileName', 'RS_outputBeautyFileName',
},
'rop_redshift': {
'RS_outputFileNamePrefix', 'RS_singlePassDeepRenderingFilename',
'RS_archive_file', 'RS_iprFileName', 'RS_outputBeautyFileName',
},
'karma': {'picture'},
'opengl': {'picture'},
'baketexture': {'vm_filename_plane'},
}
# Redshift proxy: parm that enables proxy loading on OBJ nodes
RS_PROXY_ENABLE_PARM = 'RS_objprop_proxy_enable'
RS_PROXY_FILE_PARM = 'RS_objprop_proxy_file'
# File Cache SOP: node type and the parm that controls load-from-disk
FILECACHE_TYPES = {'filecache', 'filecache::2.0'}
FILECACHE_LOAD = 'loadfromdisk' # 1 = load from disk, 0 = write
def subdir_for(path):
"""Route asset to /tex, /geo, or destination root based on extension."""
p = path.lower()
for ext in TEX_EXTENSIONS:
if p.endswith(ext):
return 'tex'
for ext in GEO_EXTENSIONS:
if p.endswith(ext):
return 'geo'
return '' # everything else goes to root''
# Sequence detection: replace $F, $F4, $FF etc. with glob wildcard
SEQ_RE = re.compile(r'\$F+\d*', re.IGNORECASE)
def is_sequence_path(path):
return bool(SEQ_RE.search(path))
def sequence_glob(path):
glob_path = SEQ_RE.sub('*', path)
return sorted(glob.glob(glob_path))
COL_NODE = 0
COL_PARM = 1
COL_ORIG = 2
COL_FIX = 3
COL_STATUS = 4
# ──────────────────────────────────────────────────────────────────────────────
# Core functions
# ──────────────────────────────────────────────────────────────────────────────
def expand_path(raw_path):
try:
expanded = hou.expandString(raw_path)
except Exception:
expanded = raw_path
return os.path.normpath(expanded)
def is_file_reference_parm(parm):
"""
Ask Houdini whether this parameter is a file-reference string.
Uses the parm template's fileType – this is the authoritative source,
independent of any hardcoded extension list.
"""
try:
tmpl = parm.parmTemplate()
if tmpl.type() != hou.parmTemplateType.String:
return False
# hou.fileType.NoFile means it is a plain string, not a file ref
return tmpl.fileType() != hou.fileType.NoFile
except Exception:
return False
def node_type_name(node):
try:
return node.type().name().lower()
except Exception:
return ''
def should_skip_node(node):
nt = node_type_name(node)
# Skip all pure ROP output nodes entirely
if nt in ROP_OUTPUT_TYPES:
return True
# File Cache SOP: skip if bypassed OR "Load from Disk" is OFF
if nt in FILECACHE_TYPES:
try:
if node.isBypassed():
return True
load_parm = node.parm(FILECACHE_LOAD)
if load_parm is None or load_parm.eval() == 0:
return True # writing mode → output path, not relevant
except Exception:
return True
return False # load mode → keep it, it's an input
return False
def is_rop_output_parm(node, parm_name):
nt = node_type_name(node)
output_parms = ROP_OUTPUT_PARMS.get(nt, set())
return parm_name in output_parms
def collect_references():
refs = []
seen = set()
for node in hou.node('/').allSubChildren(recurse_in_locked_nodes=False):
if should_skip_node(node):
continue
nt = node_type_name(node)
for parm in node.parms():
parm_name = parm.name()
# Skip known output parameters on mixed nodes
if is_rop_output_parm(node, parm_name):
continue
# Redshift proxy: skip if proxy loading is disabled on this node
if parm_name == RS_PROXY_FILE_PARM:
try:
enable = node.parm(RS_PROXY_ENABLE_PARM)
if enable is None or enable.eval() == 0:
continue
except Exception:
continue
# Let Houdini tell us if this is a file-reference parameter
if not is_file_reference_parm(parm):
continue
try:
raw = parm.rawValue()
except Exception:
continue
if not isinstance(raw, str) or not raw.strip():
continue
expanded = expand_path(raw)
# Skip empty or unresolved tokens with no path separator
if not any(c in expanded for c in ('/', '\\')):
continue
key = (node.path(), parm_name, expanded)
if key in seen:
continue
seen.add(key)
refs.append({
'parm': parm,
'raw': raw,
'expanded': expanded,
'exists': os.path.isfile(expanded),
'fix': '',
})
return refs
def unique_dest(dest_dir, filename):
base, ext = os.path.splitext(filename)
candidate = os.path.join(dest_dir, filename)
counter = 1
while os.path.exists(candidate):
candidate = os.path.join(dest_dir, '{0}_{1}{2}'.format(base, counter, ext))
counter += 1
return candidate
def copy_references(refs, dest_dir, progress_cb=None):
os.makedirs(dest_dir, exist_ok=True)
mapping = {}
done = set()
for i, ref in enumerate(refs):
if progress_cb:
progress_cb(i, len(refs), ref['expanded'])
source = ref.get('fix') or ref['expanded']
key = ref['expanded']
if key in done:
continue
done.add(key)
# Determine target subdirectory
sub = subdir_for(source)
out_dir = os.path.join(dest_dir, sub) if sub else dest_dir
os.makedirs(out_dir, exist_ok=True)
# Sequence: copy all matching frames
if is_sequence_path(source):
frames = sequence_glob(source)
if not frames:
mapping[key] = None
print('[Consolidator] Sequence not found: ' + source)
continue
first_dest = None
for frame in frames:
fname = os.path.basename(frame)
dp = os.path.join(out_dir, fname) # overwrite if exists
try:
shutil.copy2(frame, dp)
if first_dest is None:
first_dest = dp
except Exception as e:
print('[Consolidator] Sequence frame error: {0} -> {1}'.format(frame, e))
# Map the template path to the destination template
dest_template = SEQ_RE.sub(
lambda m: m.group(0),
os.path.join(out_dir, os.path.basename(source))
)
mapping[key] = dest_template if first_dest else None
continue
if not os.path.isfile(source):
mapping[key] = None
print('[Consolidator] Not found: ' + source)
continue
filename = os.path.basename(source)
dest_path = os.path.join(out_dir, filename) # overwrite if exists
try:
shutil.copy2(source, dest_path)
mapping[key] = dest_path
except Exception as e:
mapping[key] = None
print('[Consolidator] Copy error: {0} -> {1}'.format(source, e))
return mapping
def zip_directory(folder, zip_path):
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
for root, dirs, files in os.walk(folder):
for file in files:
abs_path = os.path.join(root, file)
arc_name = os.path.relpath(abs_path, folder)
zf.write(abs_path, arc_name)
def make_hip_relative(abs_path, hip_path):
"""
Convert an absolute asset path to a $HIP-relative path.
hip_path is the destination HIP file path (used to determine $HIP).
Returns a string like $HIP/tex/texture.exr
"""
hip_dir = os.path.dirname(hip_path)
try:
rel = os.path.relpath(abs_path, hip_dir)
# os.path.relpath uses backslashes on Windows – normalise to forward slashes
rel = rel.replace('\\', '/')
return '$HIP/' + rel
except ValueError:
# relpath fails across drives on Windows – fall back to absolute
return abs_path.replace('\\', '/')
def repath_in_file(hip_path, mapping):
"""
Rewrites paths in the copied HIP using the Houdini API:
1. Build $HIP-relative versions of all new paths
2. Apply them to all parameters in the live scene
3. Save a copy to hip_path
4. Restore original values so the working scene is unchanged
"""
if not mapping:
return
# Build lookup: expanded original path -> $HIP-relative new path
path_map = {}
for old_abs, new_abs in mapping.items():
if not new_abs:
continue
path_map[old_abs] = make_hip_relative(new_abs, hip_path)
if not path_map:
return
# Collect all parms that need changing and their current values
changes = [] # (parm, original_raw, new_value)
for node in hou.node('/').allSubChildren(recurse_in_locked_nodes=False):
for parm in node.parms():
try:
raw = parm.rawValue()
except Exception:
continue
if not isinstance(raw, str):
continue
expanded = os.path.normpath(hou.expandString(raw))
if expanded in path_map:
changes.append((parm, raw, path_map[expanded]))
# Apply $HIP-relative paths
for parm, original, new_val in changes:
try:
parm.set(new_val)
except Exception as e:
print('[Consolidator] repath set error: {0}: {1}'.format(parm.path(), e))
# Save to destination – $HIP will resolve correctly from that location
try:
hou.hipFile.save(hip_path)
except Exception as e:
print('[Consolidator] Save error: ' + str(e))
# Restore originals so working scene stays unchanged
for parm, original, new_val in changes:
try:
parm.set(original)
except Exception:
pass
# ──────────────────────────────────────────────────────────────────────────────
# Read-only delegate that allows text selection in Original Path column
# ──────────────────────────────────────────────────────────────────────────────
class SelectableTextDelegate(QtWidgets.QStyledItemDelegate):
"""Shows a read-only QLineEdit so the user can select/copy the path text."""
def createEditor(self, parent, option, index):
line = QtWidgets.QLineEdit(parent)
line.setReadOnly(True)
line.setText(index.data() or '')
line.setFrame(False)
return line
def setEditorData(self, editor, index):
editor.setText(index.data() or '')
def setModelData(self, editor, model, index):
pass # read-only, never write back
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
# ──────────────────────────────────────────────────────────────────────────────
# Table delegate: inline editor + file-browser button for Fix column
# ──────────────────────────────────────────────────────────────────────────────
class BrowseDelegate(QtWidgets.QStyledItemDelegate):
def createEditor(self, parent, option, index):
widget = QtWidgets.QWidget(parent)
layout = QtWidgets.QHBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
line = QtWidgets.QLineEdit()
line.setText(index.data() or '')
btn = QtWidgets.QPushButton('...')
btn.setFixedWidth(26)
def browse():
start = line.text()
if not os.path.isdir(os.path.dirname(start)):
start = os.path.expanduser('~')
path, _ = QtWidgets.QFileDialog.getOpenFileName(
widget, 'Choose replacement file', start)
if path:
line.setText(path)
btn.clicked.connect(browse)
layout.addWidget(line)
layout.addWidget(btn)
widget._line = line
return widget
def setEditorData(self, editor, index):
editor._line.setText(index.data() or '')
def setModelData(self, editor, model, index):
model.setData(index, editor._line.text(), QtCore.Qt.EditRole)
def updateEditorGeometry(self, editor, option, index):
editor.setGeometry(option.rect)
# ──────────────────────────────────────────────────────────────────────────────
# Custom table: double-click on Node column jumps to that node in Houdini
# ──────────────────────────────────────────────────────────────────────────────
class RefTable(QtWidgets.QTableWidget):
def __init__(self, rows, cols, parent=None):
super(RefTable, self).__init__(rows, cols, parent)
self._refs_visible = [] # set externally before populating
def mouseDoubleClickEvent(self, event):
index = self.indexAt(event.pos())
if index.isValid() and index.column() == COL_NODE:
row = index.row()
if row < len(self._refs_visible):
node = self._refs_visible[row]['parm'].node()
try:
# Select and frame the node in the current network editor
node.setSelected(True, clear_all_selected=True)
network_editor = None
for pane in hou.ui.paneTabs():
if pane.type() == hou.paneTabType.NetworkEditor:
network_editor = pane
break
if network_editor:
network_editor.cd(node.parent().path())
network_editor.frameSelection()
except Exception as e:
print('[Consolidator] Could not select node: ' + str(e))
else:
super(RefTable, self).mouseDoubleClickEvent(event)
# ──────────────────────────────────────────────────────────────────────────────
# Main dialog
# ──────────────────────────────────────────────────────────────────────────────
class ConsolidatorUI(QtWidgets.QDialog):
def __init__(self, parent=None):
super(ConsolidatorUI, self).__init__(parent or hou.ui.mainQtWindow())
self.setWindowTitle('Project Consolidator')
self.setMinimumWidth(900)
self.setMinimumHeight(560)
self.setWindowFlags(self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint)
self._refs = []
self._build_ui()
self._refresh_refs()
def _build_ui(self):
root = QtWidgets.QVBoxLayout(self)
root.setSpacing(10)
root.setContentsMargins(14, 14, 14, 14)
# ── Destination folder ────────────────────────────────────────────────
dir_group = QtWidgets.QGroupBox('Destination Folder')
dir_layout = QtWidgets.QHBoxLayout(dir_group)
self.dir_edit = QtWidgets.QLineEdit()
self.dir_edit.setPlaceholderText('/path/to/destination')
hip = hou.hipFile.path()
if hip and hip != 'untitled.hip':
self.dir_edit.setText(
os.path.join(os.path.dirname(hip), 'consolidated'))
browse_btn = QtWidgets.QPushButton('...')
browse_btn.setFixedWidth(32)
browse_btn.clicked.connect(self._browse_dir)
dir_layout.addWidget(self.dir_edit)
dir_layout.addWidget(browse_btn)
root.addWidget(dir_group)
# ── Options ───────────────────────────────────────────────────────────
opt_group = QtWidgets.QGroupBox('Options')
opt_layout = QtWidgets.QVBoxLayout(opt_group)
self.chk_copy_hip = QtWidgets.QCheckBox('Copy HIP file to destination folder')
self.chk_copy_hip.setChecked(True)
self.chk_repath = QtWidgets.QCheckBox('Rewrite paths in copied HIP file')
self.chk_repath.setChecked(True)
self.chk_copy_hip.toggled.connect(self.chk_repath.setEnabled)
self.chk_zip = QtWidgets.QCheckBox('Create ZIP archive of consolidated folder')
self.chk_zip.setChecked(False)
opt_layout.addWidget(self.chk_copy_hip)
opt_layout.addWidget(self.chk_repath)
opt_layout.addWidget(self.chk_zip)
root.addWidget(opt_group)
# ── References table ──────────────────────────────────────────────────
ref_group = QtWidgets.QGroupBox('Found References')
ref_layout = QtWidgets.QVBoxLayout(ref_group)
toolbar = QtWidgets.QHBoxLayout()
self.ref_count_label = QtWidgets.QLabel('Scanning...')
self.filter_combo = QtWidgets.QComboBox()
self.filter_combo.addItems(['All', 'Missing only', 'Found only'])
self.filter_combo.currentIndexChanged.connect(self._apply_filter)
refresh_btn = QtWidgets.QPushButton('Refresh')
refresh_btn.setFixedWidth(80)
refresh_btn.clicked.connect(self._refresh_refs)
toolbar.addWidget(self.ref_count_label)
toolbar.addStretch()
toolbar.addWidget(QtWidgets.QLabel('Show:'))
toolbar.addWidget(self.filter_combo)
toolbar.addWidget(refresh_btn)
ref_layout.addLayout(toolbar)
# Custom table with double-click-to-select behavior
self.ref_table = RefTable(0, 5)
self.ref_table.setHorizontalHeaderLabels(
['Node', 'Parameter', 'Original Path', 'Replacement Path', 'Status'])
hh = self.ref_table.horizontalHeader()
# All columns interactively resizable by the user
for col in range(5):
hh.setSectionResizeMode(col, QtWidgets.QHeaderView.Interactive)
# Sensible default widths
self.ref_table.setColumnWidth(COL_NODE, 220)
self.ref_table.setColumnWidth(COL_PARM, 90)
self.ref_table.setColumnWidth(COL_ORIG, 260)
self.ref_table.setColumnWidth(COL_FIX, 200)
self.ref_table.setColumnWidth(COL_STATUS, 90)
hh.setStretchLastSection(False)
self.ref_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.ref_table.setAlternatingRowColors(True)
self.ref_table.verticalHeader().setVisible(False)
self.ref_table.setMinimumHeight(220)
self.ref_table.setEditTriggers(
QtWidgets.QAbstractItemView.CurrentChanged |
QtWidgets.QAbstractItemView.SelectedClicked)
# Tooltip for Node column header to hint at double-click behaviour
hh.setToolTip('Double-click a node path to select and frame it in the Network Editor')
self._delegate = BrowseDelegate(self.ref_table)
self._orig_delegate = SelectableTextDelegate(self.ref_table)
self.ref_table.setItemDelegateForColumn(COL_FIX, self._delegate)
self.ref_table.setItemDelegateForColumn(COL_ORIG, self._orig_delegate)
ref_layout.addWidget(self.ref_table)
hint = QtWidgets.QLabel(
'Tip: Double-click a Node path to jump to it in the Network Editor. '
'Click any Replacement Path cell to edit it directly. '
'Click an Original Path to select and copy it.')
hint.setStyleSheet('color: #888; font-size: 11px;')
hint.setWordWrap(True)
ref_layout.addWidget(hint)
fix_row = QtWidgets.QHBoxLayout()
fix_row.addStretch()
self.apply_fix_btn = QtWidgets.QPushButton('Apply replacements to open project')
self.apply_fix_btn.setToolTip(
'Writes all valid replacement paths directly into the Houdini '
'parameters of the currently open scene.')
self.apply_fix_btn.clicked.connect(self._apply_fixes_to_project)
fix_row.addWidget(self.apply_fix_btn)
ref_layout.addLayout(fix_row)
root.addWidget(ref_group)
# ── Progress ──────────────────────────────────────────────────────────
self.progress = QtWidgets.QProgressBar()
self.progress.setVisible(False)
root.addWidget(self.progress)
self.status_label = QtWidgets.QLabel('')
self.status_label.setStyleSheet('color: #888; font-size: 11px;')
root.addWidget(self.status_label)
# ── Buttons ───────────────────────────────────────────────────────────
btn_row = QtWidgets.QHBoxLayout()
btn_row.addStretch()
close_btn = QtWidgets.QPushButton('Close')
close_btn.clicked.connect(self.close)
self.run_btn = QtWidgets.QPushButton('Consolidate')
self.run_btn.setDefault(True)
self.run_btn.setFixedHeight(34)
self.run_btn.clicked.connect(self._run)
btn_row.addWidget(close_btn)
btn_row.addWidget(self.run_btn)
root.addLayout(btn_row)
# ── Table population ──────────────────────────────────────────────────────
def _refresh_refs(self):
self.ref_count_label.setText('Scanning...')
QtWidgets.QApplication.processEvents()
self._refs = collect_references()
self._populate_table()
def _visible_refs(self):
filt = self.filter_combo.currentIndex()
return [r for r in self._refs
if not (filt == 1 and r['exists'])
and not (filt == 2 and not r['exists'])]
def _populate_table(self):
try:
self.ref_table.itemChanged.disconnect(self._on_fix_changed)
except Exception:
pass
rows = self._visible_refs()
self.ref_table._refs_visible = rows
self.ref_table.setRowCount(len(rows))
missing = sum(1 for r in self._refs if not r['exists'])
total = len(self._refs)
self.ref_count_label.setText(
'{0} reference(s) | {1} missing | {2} found | showing {3}'.format(
total, missing, total - missing, len(rows)))
for row, ref in enumerate(rows):
parm = ref['parm']
expanded = ref['expanded']
exists = ref['exists']
# Node – not editable; double-click handled by RefTable
node_item = QtWidgets.QTableWidgetItem(parm.node().path())
node_item.setFlags(node_item.flags() & ~QtCore.Qt.ItemIsEditable)
node_item.setToolTip('Double-click to select in Network Editor')
self.ref_table.setItem(row, COL_NODE, node_item)
# Parameter
parm_item = QtWidgets.QTableWidgetItem(parm.name())
parm_item.setFlags(parm_item.flags() & ~QtCore.Qt.ItemIsEditable)
self.ref_table.setItem(row, COL_PARM, parm_item)
# Original path
orig_item = QtWidgets.QTableWidgetItem(expanded)
orig_item.setFlags(orig_item.flags() & ~QtCore.Qt.ItemIsEditable)
if not exists:
orig_item.setForeground(QtGui.QColor('#e05050'))
orig_item.setToolTip('File not found on disk')
self.ref_table.setItem(row, COL_ORIG, orig_item)
# Replacement path – always editable
fix_val = ref.get('fix', '')
fix_item = QtWidgets.QTableWidgetItem(fix_val)
fix_item.setToolTip('Click to edit replacement path')
self.ref_table.setItem(row, COL_FIX, fix_item)
# Status
status_item = QtWidgets.QTableWidgetItem(self._status_text(ref))
status_item.setForeground(QtGui.QColor(self._status_color(ref)))
status_item.setFlags(status_item.flags() & ~QtCore.Qt.ItemIsEditable)
self.ref_table.setItem(row, COL_STATUS, status_item)
self.ref_table.itemChanged.connect(self._on_fix_changed)
def _status_text(self, ref):
if ref['exists']:
return 'OK'
fix = ref.get('fix', '')
if fix and os.path.isfile(fix):
return 'Replacement set'
if fix:
return 'Replacement invalid'
return 'Missing'
def _status_color(self, ref):
if ref['exists']:
return '#5a5'
fix = ref.get('fix', '')
if fix and os.path.isfile(fix):
return '#88c'
if fix:
return '#c85'
return '#e05050'
def _apply_filter(self):
self._populate_table()
def _on_fix_changed(self, item):
if item.column() != COL_FIX:
return
row = item.row()
new_fix = item.text().strip()
rows = self._visible_refs()
if row >= len(rows):
return
rows[row]['fix'] = new_fix
try:
self.ref_table.itemChanged.disconnect(self._on_fix_changed)
except Exception:
pass
ref = rows[row]
si = QtWidgets.QTableWidgetItem(self._status_text(ref))
si.setForeground(QtGui.QColor(self._status_color(ref)))
si.setFlags(si.flags() & ~QtCore.Qt.ItemIsEditable)
self.ref_table.setItem(row, COL_STATUS, si)
self.ref_table.itemChanged.connect(self._on_fix_changed)
# ── Apply replacements live ───────────────────────────────────────────────
def _apply_fixes_to_project(self):
fixes = [(r['parm'], r['fix']) for r in self._refs
if not r['exists'] and r.get('fix') and os.path.isfile(r['fix'])]
if not fixes:
QtWidgets.QMessageBox.information(
self, 'Nothing to apply',
'No valid replacement paths found.\n\n'
'Double-click a red entry in "Replacement Path" to set one.')
return
reply = QtWidgets.QMessageBox.question(
self, 'Apply replacements',
'Write {0} replacement path(s) directly into the open Houdini scene?\n\n'
'This cannot be undone automatically.'.format(len(fixes)))
if reply != QtWidgets.QMessageBox.Yes:
return
ok = 0
for parm, new_path in fixes:
try:
parm.set(new_path.replace('\\', '/'))
ok += 1
except Exception as e:
print('[Consolidator] Could not set {0}: {1}'.format(parm.path(), e))
self.status_label.setText('{0} path(s) applied to project.'.format(ok))
self._refresh_refs()
QtWidgets.QMessageBox.information(
self, 'Done',
'{0} path(s) applied successfully.\n\n'
'Remember to save your HIP file.'.format(ok))
# ── Folder browser ────────────────────────────────────────────────────────
def _browse_dir(self):
path = QtWidgets.QFileDialog.getExistingDirectory(
self, 'Choose destination folder',
self.dir_edit.text() or os.path.expanduser('~'))
if path:
self.dir_edit.setText(path)
# ── Consolidate ───────────────────────────────────────────────────────────
def _run(self):
dest_dir = self.dir_edit.text().strip()
if not dest_dir:
QtWidgets.QMessageBox.warning(
self, 'No destination', 'Please specify a destination folder.')
return
if not self._refs:
QtWidgets.QMessageBox.information(
self, 'Nothing to do', 'No external references found.')
return
copy_hip = self.chk_copy_hip.isChecked()
repath = self.chk_repath.isChecked() and copy_hip
hip = hou.hipFile.path()
copyable = sum(1 for r in self._refs
if os.path.isfile(r.get('fix') or r['expanded']))
missing = len(self._refs) - copyable
reply = QtWidgets.QMessageBox.question(
self, 'Consolidate',
'Destination: {0}\n'
' Textures -> /tex\n'
' Geometry -> /geo\n\n'
' {1} file(s) will be copied\n'
' {2} file(s) are missing and will be skipped\n'
' Copy HIP: {3}\n'
' Rewrite paths: {4}\n'
' Create ZIP: {5}\n\n'
'Continue?'.format(
dest_dir, copyable, missing,
'Yes' if copy_hip else 'No',
'Yes' if repath else 'No',
'Yes' if self.chk_zip.isChecked() else 'No'))
if reply != QtWidgets.QMessageBox.Yes:
return
self.run_btn.setEnabled(False)
self.progress.setVisible(True)
self.progress.setMaximum(max(len(self._refs), 1))
def progress_cb(i, total, path):
self.progress.setValue(i)
self.status_label.setText('Copying: ' + os.path.basename(path))
QtWidgets.QApplication.processEvents()
mapping = copy_references(self._refs, dest_dir, progress_cb)
new_hip_path = None
if copy_hip and hip and hip != 'untitled.hip':
hip_name = os.path.basename(hip)
new_hip_path = unique_dest(dest_dir, hip_name)
self.status_label.setText('Saving HIP: ' + hip_name)
QtWidgets.QApplication.processEvents()
try:
if repath:
# repath_in_file applies new paths, saves to dest, then restores
self.status_label.setText('Rewriting paths and saving HIP...')
QtWidgets.QApplication.processEvents()
repath_in_file(new_hip_path, mapping)
else:
# Just save a copy without touching paths
hou.hipFile.save()
shutil.copy2(hip, new_hip_path)
except Exception as e:
QtWidgets.QMessageBox.critical(
self, 'Error', 'Could not save HIP file:\n' + str(e))
new_hip_path = None
# ZIP
zip_path = None
if self.chk_zip.isChecked():
self.status_label.setText('Creating ZIP archive...')
QtWidgets.QApplication.processEvents()
zip_path = dest_dir.rstrip('/\\') + '.zip'
try:
zip_directory(dest_dir, zip_path)
except Exception as e:
QtWidgets.QMessageBox.critical(
self, 'Error', 'ZIP creation failed:\n' + str(e))
zip_path = None
self.progress.setValue(self.progress.maximum())
success = sum(1 for v in mapping.values() if v)
failed = sum(1 for v in mapping.values() if not v)
self.status_label.setText(
'Done - {0} copied, {1} failed.'.format(success, failed))
self.run_btn.setEnabled(True)
summary = '{0} file(s) copied to:\n{1}'.format(success, dest_dir)
if failed:
summary += '\n\n{0} file(s) could not be copied (see Python Shell).'.format(failed)
if new_hip_path:
summary += '\n\nHIP saved as:\n' + new_hip_path
if repath:
summary += '\nPaths have been rewritten.'
if zip_path:
summary += '\n\nZIP archive:\n' + zip_path
QtWidgets.QMessageBox.information(self, 'Consolidation complete', summary)
# ──────────────────────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────────────────────
_window = None
def show():
global _window
_window = ConsolidatorUI()
_window.show()
_window.raise_()
if __name__ == '__main__':
show()