跳转至

Robust Playblast Tool for Maya 2024

# -*- 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()