# -*- coding: utf-8 -*-
"""
Robust Playblast Tool for Maya 2024
Author: fangyuan (Optimized for PySide2)
"""
import os
import uuid
import shutil
import tempfile
import subprocess
import traceback
# 1. 明确使用 Maya 2024 默认的 PySide2
from PySide2 import QtWidgets, QtCore
import maya.cmds as cmds
import ffmpeg
class ViewportStateContext(object):
"""
环境上下文管理器:
进入时隐藏控制器、网格等杂质,退出时100%还原视口状态
"""
def __init__(self, panel):
self.panel = panel
self.old_states = {}
def __enter__(self):
if not self.panel or not cmds.modelEditor(self.panel, exists=True):
return self
# 记录原本的视口显隐状态
self.old_states = {
'nc': cmds.modelEditor(self.panel, query=True, nurbsCurves=True),
'gr': cmds.modelEditor(self.panel, query=True, grid=True),
'da': cmds.modelEditor(self.panel, query=True, displayAppearance=True),
'dl': cmds.modelEditor(self.panel, query=True, displayLights=True)
}
# 强制干净的渲染视口:隐藏曲线和网格,开启材质实体显示
cmds.modelEditor(self.panel, edit=True,
nurbsCurves=False,
grid=False,
displayAppearance='smoothShaded',
displayLights='default')
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if not self.panel or not cmds.modelEditor(self.panel, exists=True):
return
# 无条件还原初始状态
try:
cmds.modelEditor(self.panel, edit=True,
nurbsCurves=self.old_states.get('nc', True),
grid=self.old_states.get('gr', True),
displayAppearance=self.old_states.get('da', 'smoothShaded'),
displayLights=self.old_states.get('dl', 'default'))
except Exception as e:
print("[Playblast] Failed to restore viewport: {}".format(e))
class SimplePlayblastUI(QtWidgets.QWidget):
def __init__(self, parent=None):
super(SimplePlayblastUI, self).__init__(parent)
# 尝试通过 Maya 内置方式安全依附主窗口,避免面板乱飘
try:
from maya.app.general.mayaMixin import MayaQWidgetDockableMixin
# 也可以简单通过 MQtUtil 获取主窗口
import maya.OpenMayaUI as omui
from shiboken2 import wrapInstance
main_win_ptr = omui.MQtUtil.mainWindow()
main_win = wrapInstance(int(main_win_ptr), QtWidgets.QMainWindow)
if main_win:
self.setParent(main_win)
self.setWindowFlags(QtCore.Qt.Window)
except Exception:
pass
self._init_ui()
self._init_data()
def _init_ui(self):
self.setWindowTitle("Simple Playblast Suite (PySide2)")
self.setMinimumWidth(400)
layout = QtWidgets.QVBoxLayout(self)
layout.setSpacing(10)
# 路径选择区域
path_layout = QtWidgets.QHBoxLayout()
self.path_input = QtWidgets.QLineEdit()
self.path_input.setPlaceholderText("Select output .mov path...")
self.browse_btn = QtWidgets.QPushButton("Browse")
path_layout.addWidget(self.path_input)
path_layout.addWidget(self.browse_btn)
layout.addLayout(path_layout)
# 帧范围区域
info_layout = QtWidgets.QHBoxLayout()
self.start_frame = QtWidgets.QSpinBox()
self.start_frame.setRange(-9999, 99999)
self.end_frame = QtWidgets.QSpinBox()
self.end_frame.setRange(-9999, 99999)
info_layout.addWidget(QtWidgets.QLabel("Range:"))
info_layout.addWidget(self.start_frame)
info_layout.addWidget(QtWidgets.QLabel("to"))
info_layout.addWidget(self.end_frame)
layout.addLayout(info_layout)
# 底部状态及执行按钮
self.status_lbl = QtWidgets.QLabel("Ready")
self.status_lbl.setStyleSheet("color: #bbb;")
self.run_btn = QtWidgets.QPushButton("Execute Playblast")
self.run_btn.setMinimumHeight(35)
layout.addWidget(self.status_lbl)
layout.addWidget(self.run_btn)
# 信号绑定
self.browse_btn.clicked.connect(self._on_browse)
self.run_btn.clicked.connect(self._on_execute)
def _init_data(self):
# 自动获取 Maya 当前的时间轴范围
self.start_frame.setValue(int(cmds.playbackOptions(query=True, minTime=True)))
self.end_frame.setValue(int(cmds.playbackOptions(query=True, maxTime=True)))
# 统一生成更安全的跨平台桌面路径
desktop = os.path.join(os.path.expanduser("~"), "Desktop").replace("\\", "/")
scene_name = os.path.basename(cmds.file(query=True, sceneName=True) or "untitled")
scene_base = os.path.splitext(scene_name)[0]
self.path_input.setText("{}/{}.mov".format(desktop, scene_base))
def _on_browse(self):
preset = self.path_input.text()
path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Video", preset, "MOV Video (*.mov)")
if path:
self.path_input.setText(path.replace("\\", "/"))
def _on_execute(self):
output_mov = self.path_input.text()
if not output_mov:
self.status_lbl.setText("Error: Please specify output path.")
return
self.status_lbl.setText("Processing...")
QtWidgets.QApplication.processEvents()
# 1. 跨平台安全临时目录
base_tmp = tempfile.gettempdir().replace('\\', '/')
sandbox_dir = "{}/maya_pb_{}".format(base_tmp, uuid.uuid4().hex)
os.makedirs(sandbox_dir)
temp_img_pattern = "{}/frame.%04d.jpg".format(sandbox_dir)
temp_img_output = "{}/frame".format(sandbox_dir)
# 2. 获取当前处于激活状态的 Viewport Panel
active_panel = cmds.getPanel(withFocus=True)
if not active_panel or not cmds.modelEditor(active_panel, exists=True):
panels = cmds.getPanel(type='modelPanel') or []
active_panel = panels[0] if panels else None
fps = 24
time_unit = cmds.currentUnit(query=True, time=True)
fps_map = {'game': 15, 'film': 24, 'pal': 25, 'ntsc': 30, 'show': 48, 'palf': 50, 'ntscf': 60}
current_fps = fps_map.get(time_unit, fps)
# 3. 修正:FFmpeg H264 要求宽高必须是2的倍数(偶数)
w = int(cmds.getAttr('defaultResolution.width'))
h = int(cmds.getAttr('defaultResolution.height'))
if w % 2 != 0: w += 1
if h % 2 != 0: h += 1
try:
# 4. 拦截视口状态,开始拍屏
with ViewportStateContext(active_panel):
old_fmt = cmds.getAttr('defaultRenderGlobals.imageFormat')
cmds.setAttr('defaultRenderGlobals.imageFormat', 8) # 强制转储为 JPEG 序列
cmds.playblast(
startTime=self.start_frame.value(),
endTime=self.end_frame.value(),
filename=temp_img_output,
widthHeight=[w, h],
percent=100,
compression='jpg',
format='image',
viewer=False,
fp=4
)
cmds.setAttr('defaultRenderGlobals.imageFormat', old_fmt)
# 5. 调用 FFmpeg 转码为最终的 H264 视频
# 提示:请确保用户机器上的 Python 环境或指定路径下确实有 ffmpeg.exe
ffmpeg_bin = os.path.join(os.path.dirname(ffmpeg.__file__), 'bin/ffmpeg.exe').replace("\\", '/')
if not os.path.exists(ffmpeg_bin):
# 如果 pip 安装的 ffmpeg-python 里没带 bin,尝试调用系统环境变量里的 ffmpeg
ffmpeg_bin = 'ffmpeg'
cmd = [
ffmpeg_bin, '-y',
'-framerate', str(current_fps),
'-start_number', str(self.start_frame.value()),
'-i', temp_img_pattern,
'-vcodec', 'libx264',
'-pix_fmt', 'yuv420p',
'-crf', '20',
output_mov
]
# 仅在 Windows 下隐藏黑框
startupinfo = None
if os.name == 'nt':
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
startupinfo.wShowWindow = subprocess.SW_HIDE
process = subprocess.Popen(cmd, startupinfo=startupinfo)
exit_code = process.wait()
if exit_code == 0:
self.status_lbl.setText("Playblast success!")
# 跨平台安全打开文件夹方式
output_dir = os.path.dirname(output_mov)
if os.name == 'nt':
os.startfile(output_dir)
else:
subprocess.Popen(['open', output_dir] if sys.platform == 'darwin' else ['xdg-open', output_dir])
else:
self.status_lbl.setText("FFmpeg compilation failed.")
except Exception as err:
traceback.print_exc()
self.status_lbl.setText("Error: {}".format(err))
finally:
# 6. 强行粉碎和清空沙盒临时目录
if os.path.exists(sandbox_dir):
shutil.rmtree(sandbox_dir)
def main():
global simple_pb_win
try:
simple_pb_win.close()
simple_pb_win.deleteLater()
except NameError:
pass
simple_pb_win = SimplePlayblastUI()
simple_pb_win.show()
if __name__ == '__main__':
main()