diff --git a/Bin/Config/Lang/cn/nmNum_cn.qm b/Bin/Config/Lang/cn/nmNum_cn.qm index 22eb17ab..b3ea695d 100644 Binary files a/Bin/Config/Lang/cn/nmNum_cn.qm and b/Bin/Config/Lang/cn/nmNum_cn.qm differ diff --git a/Bin/Config/Lang/cn/nmNum_cn.ts b/Bin/Config/Lang/cn/nmNum_cn.ts index 326c447e..f48a2b14 100644 --- a/Bin/Config/Lang/cn/nmNum_cn.ts +++ b/Bin/Config/Lang/cn/nmNum_cn.ts @@ -2884,20 +2884,41 @@ Reason: %1 nmWxPebiResultAnimationExportDlg Export Animation导出动画 + Frame Settings帧设置 Frame rate帧率 FPS FPS - Start frame起始帧 - End frame结束帧 - Sampling interval采样间隔 - Resolution分辨率 - Current view当前视口 - Custom自定义 - Output size输出尺寸 + Start time起始时间 + End time结束时间 + Frame step帧步长 + Output Size输出尺寸 + Current image size当前画面尺寸 + Custom size自定义尺寸 + Output File输出文件 + File format文件格式 + Save location保存位置 + Browse...浏览... + Save Result Animation保存结果动画 + MP4 Video (*.mp4)MP4 视频 (*.mp4) + AVI Video (*.avi)AVI 视频 (*.avi) + QuickTime Video (*.mov)QuickTime 视频 (*.mov) + MPEG Video (*.mpg)MPEG 视频 (*.mpg) + WebM Video (*.webm)WebM 视频 (*.webm) + Matroska Video (*.mkv)Matroska 视频 (*.mkv) + Windows Media Video (*.wmv)Windows Media 视频 (*.wmv) + Flash Video (*.flv)Flash 视频 (*.flv) + Export Information导出信息 Output frames输出帧数 + %1 frames%1 帧 Estimated duration预计时长 %1 h%1 h %1 s%1 秒 The animation export settings are invalid.动画导出参数无效。 + The selected video format is unsupported.不支持所选视频格式。 + Please select an output file.请选择输出文件。 + The selected video file extension does not match the selected format.所选视频文件的扩展名与文件格式不一致。 + The selected output path is a directory.所选输出路径是文件夹。 + The selected output directory does not exist.所选输出目录不存在。 + The selected file already exists. Replace it?所选文件已存在,是否替换? nmWxPebiResultControlBar @@ -2959,17 +2980,6 @@ Reason: %1 Export Animation导出动画 Another result animation export is already in progress.已有结果动画正在导出。 Unable to read the result animation timeline.无法读取结果动画时间轴。 - Save Result Animation保存结果动画 - MP4 Video (*.mp4)MP4 视频 (*.mp4) - AVI Video (*.avi)AVI 视频 (*.avi) - QuickTime Video (*.mov)QuickTime 视频 (*.mov) - MPEG Video (*.mpg)MPEG 视频 (*.mpg) - WebM Video (*.webm)WebM 视频 (*.webm) - Matroska Video (*.mkv)Matroska 视频 (*.mkv) - Windows Media Video (*.wmv)Windows Media 视频 (*.wmv) - Flash Video (*.flv)Flash 视频 (*.flv) - The selected video file extension is unsupported.不支持所选视频文件扩展名。 - The selected file already exists. Replace it?所选文件已存在,是否替换? The displayed result changed before export started.开始导出前,当前显示结果已发生变化。 Unable to freeze the current result display.无法冻结当前结果显示状态。 Exporting result animation...正在导出结果动画... diff --git a/Include/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.h b/Include/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.h index 068285f4..7aa0fec9 100644 --- a/Include/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.h +++ b/Include/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.h @@ -6,13 +6,17 @@ #include #include +#include #include +class QButtonGroup; class QComboBox; class QLabel; +class QLineEdit; +class QRadioButton; class QSpinBox; -/** @brief 编辑当前动态结果的动画导出参数,不选择目标文件。 */ +/** @brief 编辑当前动态结果的动画导出参数和目标视频文件。 */ class NM_SUB_WXS_EXPORT nmWxPebiResultAnimationExportDlg : public QDialog { @@ -32,17 +36,23 @@ public: /** @brief 返回用户确认后的参数;仅在对话框Accepted后使用。 */ nmPebiResultAnimationExportSettings getSettings() const; + /** @brief 返回用户确认后的绝对目标文件路径;仅在对话框Accepted后使用。 */ + QString getTargetFilePath() const; private slots: /** @brief 保证结束帧不早于起始帧,并刷新采样上限和摘要。 */ void onFrameRangeChanged(); - /** @brief 应用当前视口或固定分辨率,并切换自定义输入可用状态。 */ - void onResolutionPresetChanged(int nIndex); + /** @brief 切换输出尺寸模式并刷新自定义输入可用状态和摘要。 */ + void onResolutionPresetChanged(int nPresetId); /** @brief 在输入完成后把自定义宽高向上规范为偶数。 */ void normalizeCustomDimensions(); /** @brief 按当前参数刷新输出帧数和预计时长。 */ void updateSummary(); - /** @brief 规范化并严格校验参数,成功后接受对话框。 */ + /** @brief 按稳定扩展名同步已选路径,并清除旧错误。 */ + void onVideoFormatChanged(int nIndex); + /** @brief 使用当前格式打开不负责覆盖确认的系统保存窗口。 */ + void browseTargetFile(); + /** @brief 规范化并严格校验参数和目标文件,成功后接受对话框。 */ void acceptSettings(); private: @@ -65,17 +75,37 @@ private: void createUi(); /** @brief 将小时值及其零基帧下标填入起止帧下拉框。 */ void populateFrameItems(); + /** @brief 填入正式支持的视频格式及其稳定扩展名。 */ + void populateVideoFormats(); /** @brief 从控件收集一份不改变界面状态的规范化参数。 */ nmPebiResultAnimationExportSettings collectSettings() const; + /** @brief 返回当前尺寸模式对应的最终偶数输出尺寸。 */ + QSize getSelectedOutputSize() const; + /** @brief 返回当前格式下拉项保存的不带点扩展名。 */ + QString getSelectedVideoExtension() const; + /** @brief 返回当前格式对应的单一系统文件过滤器。 */ + QString getSelectedVideoFilter() const; + /** @brief 按当前格式追加或替换路径扩展名。 */ + QString normalizeTargetFilePath(const QString& sPath) const; + /** @brief 返回用户目录下与当前格式匹配的建议文件路径。 */ + QString getSuggestedTargetFilePath() const; + /** @brief 更新只读路径框,并同步完整路径Tooltip。 */ + void setTargetFilePath(const QString& sPath); + /** @brief 显示一条阻止导出的对话框内错误。 */ + void showError(const QString& sError); + /** @brief 清空并隐藏旧错误。 */ + void clearError(); /** @brief 将像素尺寸限制到2~8192并向上修正为偶数。 */ static int normalizeEvenDimension(int nValue); /** @brief 各零基帧下标对应的时间,单位为小时。 */ QVector m_vecFrameTimesHours; - /** @brief 打开对话框时冻结的QVTK视口像素尺寸。 */ - QSize m_oCurrentViewportSize; + /** @brief 打开对话框时完成回退和偶数规范化的QVTK视口像素尺寸。 */ + QSize m_oEffectiveCurrentViewportSize; /** @brief 用户确认后保存的规范化导出参数。 */ nmPebiResultAnimationExportSettings m_oAcceptedSettings; + /** @brief 用户确认覆盖后保存的绝对目标视频路径。 */ + QString m_sAcceptedTargetFilePath; /** @brief 目标视频帧率输入框。 */ QSpinBox* m_pFpsSpinBox; /** @brief 导出闭区间的起始帧下拉框。 */ @@ -84,12 +114,24 @@ private: QComboBox* m_pEndFrameCombo; /** @brief 原始结果帧采样间隔输入框。 */ QSpinBox* m_pSamplingIntervalSpinBox; - /** @brief 当前视口、固定尺寸和自定义尺寸的选择框。 */ - QComboBox* m_pResolutionCombo; + /** @brief 管理四种互斥输出尺寸模式及其稳定枚举ID。 */ + QButtonGroup* m_pResolutionButtonGroup; + /** @brief 使用冻结后的有效当前画面尺寸。 */ + QRadioButton* m_pCurrentSizeRadioButton; + /** @brief 使用固定1280x720输出尺寸。 */ + QRadioButton* m_p720pRadioButton; + /** @brief 使用固定1920x1080输出尺寸。 */ + QRadioButton* m_p1080pRadioButton; + /** @brief 使用用户输入并规范化后的自定义尺寸。 */ + QRadioButton* m_pCustomSizeRadioButton; /** @brief 输出宽度输入框,界面最终显示偶数像素值。 */ QSpinBox* m_pWidthSpinBox; /** @brief 输出高度输入框,界面最终显示偶数像素值。 */ QSpinBox* m_pHeightSpinBox; + /** @brief 显示正式视频格式并以itemData保存稳定扩展名。 */ + QComboBox* m_pVideoFormatCombo; + /** @brief 显示浏览窗口选定的只读目标文件路径。 */ + QLineEdit* m_pTargetPathEdit; /** @brief 按当前帧范围和采样间隔计算的输出帧数。 */ QLabel* m_pOutputFrameCountLabel; /** @brief 按输出帧数和帧率计算的预计视频时长。 */ diff --git a/Src/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.cpp b/Src/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.cpp index c0fa047f..ad9ad104 100644 --- a/Src/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.cpp +++ b/Src/nmNum/nmSubWxs/nmWxPebiResultAnimationExportDlg.cpp @@ -1,13 +1,24 @@ #include "nmWxPebiResultAnimationExportDlg.h" +#include "nmFFmpegVideoWriter.h" +#include "ZxBaseUtil.h" + +#include #include #include #include +#include +#include +#include #include -#include +#include #include #include +#include +#include #include +#include +#include #include #include #include @@ -21,6 +32,22 @@ QString exportDialogIconPath(const QString& sFileName) "/Res/Icon/" + sFileName; } +QLabel* createRightAlignedLabel(const QString& sText, QWidget* pParent) +{ + QLabel* pLabel = new QLabel(sText, pParent); + pLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + return pLabel; +} + +QString formatPixelSize(int nWidth, int nHeight) +{ + QString sText = QString::number(nWidth); + sText.append(' '); + sText.append(QChar(0x00D7)); + sText.append(QString::fromLatin1(" %1 px").arg(nHeight)); + return sText; +} + } nmWxPebiResultAnimationExportDlg::nmWxPebiResultAnimationExportDlg( @@ -29,21 +56,38 @@ nmWxPebiResultAnimationExportDlg::nmWxPebiResultAnimationExportDlg( QWidget* pParent) : QDialog(pParent), m_vecFrameTimesHours(vecFrameTimesHours), - m_oCurrentViewportSize(oCurrentViewportSize), m_pFpsSpinBox(nullptr), m_pStartFrameCombo(nullptr), m_pEndFrameCombo(nullptr), m_pSamplingIntervalSpinBox(nullptr), - m_pResolutionCombo(nullptr), + m_pResolutionButtonGroup(nullptr), + m_pCurrentSizeRadioButton(nullptr), + m_p720pRadioButton(nullptr), + m_p1080pRadioButton(nullptr), + m_pCustomSizeRadioButton(nullptr), m_pWidthSpinBox(nullptr), m_pHeightSpinBox(nullptr), + m_pVideoFormatCombo(nullptr), + m_pTargetPathEdit(nullptr), m_pOutputFrameCountLabel(nullptr), m_pEstimatedDurationLabel(nullptr), m_pErrorLabel(nullptr) { + int nCurrentWidth = oCurrentViewportSize.width(); + int nCurrentHeight = oCurrentViewportSize.height(); + if(nCurrentWidth <= 0 || nCurrentHeight <= 0) { + nCurrentWidth = 1280; + nCurrentHeight = 720; + } + m_oEffectiveCurrentViewportSize = QSize( + normalizeEvenDimension(nCurrentWidth), + normalizeEvenDimension(nCurrentHeight)); + createUi(); populateFrameItems(); - onResolutionPresetChanged(m_pResolutionCombo->currentIndex()); + populateVideoFormats(); + setTargetFilePath(getSuggestedTargetFilePath()); + onResolutionPresetChanged(Resolution_CurrentView); onFrameRangeChanged(); } @@ -53,6 +97,11 @@ nmWxPebiResultAnimationExportDlg::getSettings() const return m_oAcceptedSettings; } +QString nmWxPebiResultAnimationExportDlg::getTargetFilePath() const +{ + return m_sAcceptedTargetFilePath; +} + void nmWxPebiResultAnimationExportDlg::onFrameRangeChanged() { const int nStartIndex = m_pStartFrameCombo->currentIndex(); @@ -71,31 +120,9 @@ void nmWxPebiResultAnimationExportDlg::onFrameRangeChanged() } void nmWxPebiResultAnimationExportDlg::onResolutionPresetChanged( - int nIndex) + int nPresetId) { - Q_UNUSED(nIndex); - const ResolutionPreset ePreset = static_cast( - m_pResolutionCombo->itemData( - m_pResolutionCombo->currentIndex()).toInt()); - int nWidth = m_oCurrentViewportSize.width(); - int nHeight = m_oCurrentViewportSize.height(); - if(ePreset == Resolution_720p) { - nWidth = 1280; - nHeight = 720; - } else if(ePreset == Resolution_1080p) { - nWidth = 1920; - nHeight = 1080; - } - if(nWidth <= 0 || nHeight <= 0) { - nWidth = 1280; - nHeight = 720; - } - - const bool bCustom = ePreset == Resolution_Custom; - if(!bCustom) { - m_pWidthSpinBox->setValue(normalizeEvenDimension(nWidth)); - m_pHeightSpinBox->setValue(normalizeEvenDimension(nHeight)); - } + const bool bCustom = nPresetId == Resolution_Custom; m_pWidthSpinBox->setEnabled(bCustom); m_pHeightSpinBox->setEnabled(bCustom); updateSummary(); @@ -117,28 +144,112 @@ void nmWxPebiResultAnimationExportDlg::updateSummary() collectSettings(); const int nOutputFrameCount = oSettings.getOutputFrameCount(); m_pOutputFrameCountLabel->setText( - QString::number(nOutputFrameCount)); + tr("%1 frames").arg(nOutputFrameCount)); const double dDuration = oSettings.m_nFramesPerSecond > 0 ? static_cast(nOutputFrameCount) / static_cast(oSettings.m_nFramesPerSecond) : 0.0; m_pEstimatedDurationLabel->setText( - tr("%1 s").arg(QString::number(dDuration, 'g', 6))); - m_pErrorLabel->clear(); + tr("%1 s").arg(QString::number(dDuration, 'g', 4))); + clearError(); +} + +void nmWxPebiResultAnimationExportDlg::onVideoFormatChanged(int nIndex) +{ + Q_UNUSED(nIndex); + if(m_pTargetPathEdit != nullptr && + !m_pTargetPathEdit->text().isEmpty()) { + setTargetFilePath(normalizeTargetFilePath( + m_pTargetPathEdit->text())); + } + clearError(); +} + +void nmWxPebiResultAnimationExportDlg::browseTargetFile() +{ + const QString sExtension = getSelectedVideoExtension(); + if(sExtension.isEmpty()) { + showError(tr("The selected video format is unsupported.")); + return; + } + + const QString sInitialPath = m_pTargetPathEdit->text().isEmpty() + ? getSuggestedTargetFilePath() : m_pTargetPathEdit->text(); + const QFileInfo oInitialFileInfo(sInitialPath); + + QFileDialog oFileDialog(this, tr("Save Result Animation")); + oFileDialog.setAcceptMode(QFileDialog::AcceptSave); + oFileDialog.setFileMode(QFileDialog::AnyFile); + oFileDialog.setNameFilter(getSelectedVideoFilter()); + oFileDialog.setDefaultSuffix(sExtension); + oFileDialog.setOption(QFileDialog::DontConfirmOverwrite, true); + oFileDialog.setDirectory(oInitialFileInfo.absolutePath()); + oFileDialog.selectFile(oInitialFileInfo.fileName()); + if(oFileDialog.exec() != QDialog::Accepted || + oFileDialog.selectedFiles().isEmpty()) { + return; + } + + const QString sTargetPath = normalizeTargetFilePath( + oFileDialog.selectedFiles().first()); + const QFileInfo oTargetFileInfo(sTargetPath); + setTargetFilePath(oTargetFileInfo.absoluteFilePath()); + ZxBaseUtil::setLastDir(oTargetFileInfo.absolutePath()); + clearError(); } void nmWxPebiResultAnimationExportDlg::acceptSettings() { - // 接受前再规范化一次,覆盖按钮点击与焦点事件顺序差异。 - normalizeCustomDimensions(); + // 只有自定义模式需要把编辑值回写为编码器最终使用的偶数尺寸。 + if(m_pCustomSizeRadioButton->isChecked()) { + normalizeCustomDimensions(); + } const nmPebiResultAnimationExportSettings oSettings = collectSettings(); if(!oSettings.isValid(m_vecFrameTimesHours.size())) { - m_pErrorLabel->setText( - tr("The animation export settings are invalid.")); + showError(tr("The animation export settings are invalid.")); + return; + } + + if(m_pTargetPathEdit->text().isEmpty()) { + showError(tr("Please select an output file.")); + return; + } + + const QString sExtension = getSelectedVideoExtension(); + if(sExtension.isEmpty()) { + showError(tr("The selected video format is unsupported.")); + return; + } + const QString sTargetPath = QFileInfo(normalizeTargetFilePath( + m_pTargetPathEdit->text())).absoluteFilePath(); + const QFileInfo oTargetFileInfo(sTargetPath); + if(oTargetFileInfo.suffix().compare( + sExtension, Qt::CaseInsensitive) != 0) { + showError(tr("The selected video file extension does not match " + "the selected format.")); + return; + } + if(oTargetFileInfo.isDir()) { + showError(tr("The selected output path is a directory.")); + return; + } + if(!QDir(oTargetFileInfo.absolutePath()).exists()) { + showError(tr("The selected output directory does not exist.")); + return; + } + + setTargetFilePath(sTargetPath); + if(oTargetFileInfo.exists() && + QMessageBox::question( + this, tr("Export Animation"), + tr("The selected file already exists. Replace it?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) { return; } m_oAcceptedSettings = oSettings; + m_sAcceptedTargetFilePath = sTargetPath; accept(); } @@ -146,75 +257,167 @@ void nmWxPebiResultAnimationExportDlg::createUi() { setWindowTitle(tr("Export Animation")); setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); - setMinimumWidth(430); + setMinimumWidth(460); QVBoxLayout* pMainLayout = new QVBoxLayout(this); pMainLayout->setContentsMargins(12, 10, 12, 10); pMainLayout->setSpacing(8); - QGridLayout* pGrid = new QGridLayout(); - pGrid->setHorizontalSpacing(8); - pGrid->setVerticalSpacing(6); - int nRow = 0; + QGroupBox* pFrameGroup = new QGroupBox(tr("Frame Settings"), this); + QGridLayout* pFrameGrid = new QGridLayout(pFrameGroup); + pFrameGrid->setHorizontalSpacing(8); + pFrameGrid->setVerticalSpacing(6); + pFrameGrid->setColumnStretch(1, 1); + const QSizePolicy oFieldSizePolicy( + QSizePolicy::Expanding, QSizePolicy::Fixed); - m_pFpsSpinBox = new QSpinBox(this); + m_pFpsSpinBox = new QSpinBox(pFrameGroup); m_pFpsSpinBox->setRange(1, 5000); m_pFpsSpinBox->setValue(15); m_pFpsSpinBox->setSuffix(tr(" FPS")); - pGrid->addWidget(new QLabel(tr("Frame rate"), this), nRow, 0); - pGrid->addWidget(m_pFpsSpinBox, nRow++, 1, 1, 2); + m_pFpsSpinBox->setSizePolicy(oFieldSizePolicy); + pFrameGrid->addWidget( + createRightAlignedLabel(tr("Frame rate"), pFrameGroup), 0, 0); + pFrameGrid->addWidget(m_pFpsSpinBox, 0, 1); - m_pStartFrameCombo = new QComboBox(this); - pGrid->addWidget(new QLabel(tr("Start frame"), this), nRow, 0); - pGrid->addWidget(m_pStartFrameCombo, nRow++, 1, 1, 2); + m_pStartFrameCombo = new QComboBox(pFrameGroup); + m_pStartFrameCombo->setSizePolicy(oFieldSizePolicy); + pFrameGrid->addWidget( + createRightAlignedLabel(tr("Start time"), pFrameGroup), 1, 0); + pFrameGrid->addWidget(m_pStartFrameCombo, 1, 1); - m_pEndFrameCombo = new QComboBox(this); - pGrid->addWidget(new QLabel(tr("End frame"), this), nRow, 0); - pGrid->addWidget(m_pEndFrameCombo, nRow++, 1, 1, 2); + m_pEndFrameCombo = new QComboBox(pFrameGroup); + m_pEndFrameCombo->setSizePolicy(oFieldSizePolicy); + pFrameGrid->addWidget( + createRightAlignedLabel(tr("End time"), pFrameGroup), 2, 0); + pFrameGrid->addWidget(m_pEndFrameCombo, 2, 1); - m_pSamplingIntervalSpinBox = new QSpinBox(this); + m_pSamplingIntervalSpinBox = new QSpinBox(pFrameGroup); m_pSamplingIntervalSpinBox->setRange(1, 1); - pGrid->addWidget(new QLabel(tr("Sampling interval"), this), nRow, 0); - pGrid->addWidget(m_pSamplingIntervalSpinBox, nRow++, 1, 1, 2); - - m_pResolutionCombo = new QComboBox(this); - m_pResolutionCombo->addItem(tr("Current view"), Resolution_CurrentView); - m_pResolutionCombo->addItem(QString::fromLatin1("1280 x 720"), - Resolution_720p); - m_pResolutionCombo->addItem(QString::fromLatin1("1920 x 1080"), - Resolution_1080p); - m_pResolutionCombo->addItem(tr("Custom"), Resolution_Custom); - pGrid->addWidget(new QLabel(tr("Resolution"), this), nRow, 0); - pGrid->addWidget(m_pResolutionCombo, nRow++, 1, 1, 2); - - m_pWidthSpinBox = new QSpinBox(this); - m_pHeightSpinBox = new QSpinBox(this); + m_pSamplingIntervalSpinBox->setSizePolicy(oFieldSizePolicy); + pFrameGrid->addWidget( + createRightAlignedLabel(tr("Frame step"), pFrameGroup), 3, 0); + pFrameGrid->addWidget(m_pSamplingIntervalSpinBox, 3, 1); + pMainLayout->addWidget(pFrameGroup); + + QGroupBox* pOutputSizeGroup = new QGroupBox(tr("Output Size"), this); + QGridLayout* pOutputSizeGrid = new QGridLayout(pOutputSizeGroup); + pOutputSizeGrid->setHorizontalSpacing(6); + pOutputSizeGrid->setVerticalSpacing(6); + + m_pResolutionButtonGroup = new QButtonGroup(this); + m_pCurrentSizeRadioButton = new QRadioButton( + tr("Current image size") + QString::fromLatin1(" ") + + formatPixelSize(m_oEffectiveCurrentViewportSize.width(), + m_oEffectiveCurrentViewportSize.height()), + pOutputSizeGroup); + m_p720pRadioButton = new QRadioButton( + formatPixelSize(1280, 720), pOutputSizeGroup); + m_p1080pRadioButton = new QRadioButton( + formatPixelSize(1920, 1080), pOutputSizeGroup); + m_pCustomSizeRadioButton = new QRadioButton( + tr("Custom size"), pOutputSizeGroup); + m_pResolutionButtonGroup->addButton( + m_pCurrentSizeRadioButton, Resolution_CurrentView); + m_pResolutionButtonGroup->addButton( + m_p720pRadioButton, Resolution_720p); + m_pResolutionButtonGroup->addButton( + m_p1080pRadioButton, Resolution_1080p); + m_pResolutionButtonGroup->addButton( + m_pCustomSizeRadioButton, Resolution_Custom); + m_pCurrentSizeRadioButton->setChecked(true); + + pOutputSizeGrid->addWidget( + m_pCurrentSizeRadioButton, 0, 1, 1, 5, Qt::AlignLeft); + pOutputSizeGrid->addWidget( + m_p720pRadioButton, 1, 1, 1, 5, Qt::AlignLeft); + pOutputSizeGrid->addWidget( + m_p1080pRadioButton, 2, 1, 1, 5, Qt::AlignLeft); + pOutputSizeGrid->addWidget(m_pCustomSizeRadioButton, 3, 1, Qt::AlignLeft); + + m_pWidthSpinBox = new QSpinBox(pOutputSizeGroup); + m_pHeightSpinBox = new QSpinBox(pOutputSizeGroup); m_pWidthSpinBox->setRange(2, 8192); m_pHeightSpinBox->setRange(2, 8192); m_pWidthSpinBox->setSingleStep(2); m_pHeightSpinBox->setSingleStep(2); - QHBoxLayout* pCustomSizeLayout = new QHBoxLayout(); - pCustomSizeLayout->setContentsMargins(0, 0, 0, 0); - pCustomSizeLayout->setSpacing(6); - pCustomSizeLayout->addWidget(m_pWidthSpinBox); - pCustomSizeLayout->addWidget(new QLabel(QString::fromLatin1("x"), this)); - pCustomSizeLayout->addWidget(m_pHeightSpinBox); - pGrid->addWidget(new QLabel(tr("Output size"), this), nRow, 0); - pGrid->addLayout(pCustomSizeLayout, nRow++, 1, 1, 2); - - m_pOutputFrameCountLabel = new QLabel(this); - pGrid->addWidget(new QLabel(tr("Output frames"), this), nRow, 0); - pGrid->addWidget(m_pOutputFrameCountLabel, nRow++, 1, 1, 2); - - m_pEstimatedDurationLabel = new QLabel(this); - pGrid->addWidget(new QLabel(tr("Estimated duration"), this), nRow, 0); - pGrid->addWidget(m_pEstimatedDurationLabel, nRow++, 1, 1, 2); - pGrid->setColumnStretch(1, 1); - pMainLayout->addLayout(pGrid); + m_pWidthSpinBox->setMinimumWidth(90); + m_pWidthSpinBox->setMaximumWidth(110); + m_pHeightSpinBox->setMinimumWidth(90); + m_pHeightSpinBox->setMaximumWidth(110); + m_pWidthSpinBox->setValue(m_oEffectiveCurrentViewportSize.width()); + m_pHeightSpinBox->setValue(m_oEffectiveCurrentViewportSize.height()); + QLabel* pMultiplyLabel = new QLabel( + QString(QChar(0x00D7)), pOutputSizeGroup); + pMultiplyLabel->setAlignment(Qt::AlignCenter); + QLabel* pPixelLabel = new QLabel( + QString::fromLatin1("px"), pOutputSizeGroup); + pOutputSizeGrid->addWidget(m_pWidthSpinBox, 3, 2); + pOutputSizeGrid->addWidget(pMultiplyLabel, 3, 3, Qt::AlignCenter); + pOutputSizeGrid->addWidget(m_pHeightSpinBox, 3, 4); + pOutputSizeGrid->addWidget(pPixelLabel, 3, 5, Qt::AlignLeft); + // 两侧等权留白使完整尺寸选项块居中,块内控件仍保持左对齐。 + pOutputSizeGrid->setColumnStretch(0, 1); + pOutputSizeGrid->setColumnStretch(6, 1); + pMainLayout->addWidget(pOutputSizeGroup); + + QGroupBox* pOutputFileGroup = new QGroupBox( + tr("Output File"), this); + QGridLayout* pOutputFileGrid = new QGridLayout(pOutputFileGroup); + pOutputFileGrid->setHorizontalSpacing(8); + pOutputFileGrid->setVerticalSpacing(6); + pOutputFileGrid->setColumnStretch(1, 1); + + m_pVideoFormatCombo = new QComboBox(pOutputFileGroup); + m_pVideoFormatCombo->setSizePolicy(oFieldSizePolicy); + pOutputFileGrid->addWidget( + createRightAlignedLabel( + tr("File format"), pOutputFileGroup), 0, 0); + pOutputFileGrid->addWidget(m_pVideoFormatCombo, 0, 1, 1, 2); + + m_pTargetPathEdit = new QLineEdit(pOutputFileGroup); + m_pTargetPathEdit->setReadOnly(true); + m_pTargetPathEdit->setSizePolicy(oFieldSizePolicy); + QPushButton* pBrowseButton = new QPushButton( + tr("Browse..."), pOutputFileGroup); + pBrowseButton->setMinimumWidth(72); + pOutputFileGrid->addWidget( + createRightAlignedLabel( + tr("Save location"), pOutputFileGroup), 1, 0); + pOutputFileGrid->addWidget(m_pTargetPathEdit, 1, 1); + pOutputFileGrid->addWidget(pBrowseButton, 1, 2); + pMainLayout->addWidget(pOutputFileGroup); + + QGroupBox* pInformationGroup = new QGroupBox( + tr("Export Information"), this); + QGridLayout* pInformationGrid = new QGridLayout(pInformationGroup); + pInformationGrid->setHorizontalSpacing(8); + pInformationGrid->setVerticalSpacing(6); + m_pOutputFrameCountLabel = new QLabel(pInformationGroup); + m_pEstimatedDurationLabel = new QLabel(pInformationGroup); + m_pOutputFrameCountLabel->setTextInteractionFlags( + Qt::TextSelectableByMouse); + m_pEstimatedDurationLabel->setTextInteractionFlags( + Qt::TextSelectableByMouse); + pInformationGrid->addWidget( + createRightAlignedLabel( + tr("Output frames"), pInformationGroup), 0, 1); + pInformationGrid->addWidget( + m_pOutputFrameCountLabel, 0, 2, Qt::AlignLeft); + pInformationGrid->addWidget( + createRightAlignedLabel( + tr("Estimated duration"), pInformationGroup), 1, 1); + pInformationGrid->addWidget( + m_pEstimatedDurationLabel, 1, 2, Qt::AlignLeft); + // 标签列和数值列作为一个整体居中,保留内部左右对齐关系。 + pInformationGrid->setColumnStretch(0, 1); + pInformationGrid->setColumnStretch(3, 1); + pMainLayout->addWidget(pInformationGroup); m_pErrorLabel = new QLabel(this); m_pErrorLabel->setStyleSheet("QLabel { color: #a32626; }"); m_pErrorLabel->setWordWrap(true); + m_pErrorLabel->hide(); pMainLayout->addWidget(m_pErrorLabel); QDialogButtonBox* pButtonBox = new QDialogButtonBox( @@ -228,7 +431,7 @@ void nmWxPebiResultAnimationExportDlg::createUi() pCancelButton->setIconSize(QSize(16, 16)); pOkButton->setMinimumWidth(72); pCancelButton->setMinimumWidth(72); - pMainLayout->addWidget(pButtonBox); + pMainLayout->addWidget(pButtonBox, 0, Qt::AlignRight); connect(m_pStartFrameCombo, SIGNAL(currentIndexChanged(int)), this, SLOT(onFrameRangeChanged())); @@ -238,8 +441,12 @@ void nmWxPebiResultAnimationExportDlg::createUi() this, SLOT(updateSummary())); connect(m_pFpsSpinBox, SIGNAL(valueChanged(int)), this, SLOT(updateSummary())); - connect(m_pResolutionCombo, SIGNAL(currentIndexChanged(int)), + connect(m_pResolutionButtonGroup, SIGNAL(buttonClicked(int)), this, SLOT(onResolutionPresetChanged(int))); + connect(m_pVideoFormatCombo, SIGNAL(currentIndexChanged(int)), + this, SLOT(onVideoFormatChanged(int))); + connect(pBrowseButton, SIGNAL(clicked()), + this, SLOT(browseTargetFile())); connect(m_pWidthSpinBox, SIGNAL(valueChanged(int)), this, SLOT(updateSummary())); connect(m_pHeightSpinBox, SIGNAL(valueChanged(int)), @@ -270,6 +477,28 @@ void nmWxPebiResultAnimationExportDlg::populateFrameItems() } } +void nmWxPebiResultAnimationExportDlg::populateVideoFormats() +{ + // itemData只保存稳定扩展名,界面翻译不会参与格式识别。 + m_pVideoFormatCombo->addItem( + tr("MP4 Video (*.mp4)"), QString::fromLatin1("mp4")); + m_pVideoFormatCombo->addItem( + tr("AVI Video (*.avi)"), QString::fromLatin1("avi")); + m_pVideoFormatCombo->addItem( + tr("QuickTime Video (*.mov)"), QString::fromLatin1("mov")); + m_pVideoFormatCombo->addItem( + tr("MPEG Video (*.mpg)"), QString::fromLatin1("mpg")); + m_pVideoFormatCombo->addItem( + tr("WebM Video (*.webm)"), QString::fromLatin1("webm")); + m_pVideoFormatCombo->addItem( + tr("Matroska Video (*.mkv)"), QString::fromLatin1("mkv")); + m_pVideoFormatCombo->addItem( + tr("Windows Media Video (*.wmv)"), QString::fromLatin1("wmv")); + m_pVideoFormatCombo->addItem( + tr("Flash Video (*.flv)"), QString::fromLatin1("flv")); + m_pVideoFormatCombo->setCurrentIndex(0); +} + nmPebiResultAnimationExportSettings nmWxPebiResultAnimationExportDlg::collectSettings() const { @@ -283,13 +512,107 @@ nmWxPebiResultAnimationExportDlg::collectSettings() const m_pEndFrameCombo->currentIndex()).toInt(); oSettings.m_nSamplingInterval = m_pSamplingIntervalSpinBox->value(); - oSettings.m_nOutputWidth = - normalizeEvenDimension(m_pWidthSpinBox->value()); - oSettings.m_nOutputHeight = - normalizeEvenDimension(m_pHeightSpinBox->value()); + const QSize oOutputSize = getSelectedOutputSize(); + oSettings.m_nOutputWidth = oOutputSize.width(); + oSettings.m_nOutputHeight = oOutputSize.height(); return oSettings; } +QSize nmWxPebiResultAnimationExportDlg::getSelectedOutputSize() const +{ + const int nPresetId = m_pResolutionButtonGroup == nullptr + ? Resolution_CurrentView : + m_pResolutionButtonGroup->checkedId(); + if(nPresetId == Resolution_720p) { + return QSize(1280, 720); + } + if(nPresetId == Resolution_1080p) { + return QSize(1920, 1080); + } + if(nPresetId == Resolution_Custom) { + return QSize( + normalizeEvenDimension(m_pWidthSpinBox->value()), + normalizeEvenDimension(m_pHeightSpinBox->value())); + } + return m_oEffectiveCurrentViewportSize; +} + +QString nmWxPebiResultAnimationExportDlg::getSelectedVideoExtension() const +{ + if(m_pVideoFormatCombo == nullptr || + m_pVideoFormatCombo->currentIndex() < 0) { + return QString(); + } + const QString sExtension = m_pVideoFormatCombo->itemData( + m_pVideoFormatCombo->currentIndex()).toString().toLower(); + return nmFFmpegVideoWriter::isSupportedExtension(sExtension) + ? sExtension : QString(); +} + +QString nmWxPebiResultAnimationExportDlg::getSelectedVideoFilter() const +{ + return m_pVideoFormatCombo == nullptr + ? QString() : m_pVideoFormatCombo->currentText(); +} + +QString nmWxPebiResultAnimationExportDlg::normalizeTargetFilePath( + const QString& sPath) const +{ + const QString sExtension = getSelectedVideoExtension(); + if(sPath.isEmpty() || sExtension.isEmpty()) { + return QString(); + } + + QString sNormalizedPath = QDir::fromNativeSeparators(sPath); + const QString sExistingExtension = + QFileInfo(sNormalizedPath).suffix().toLower(); + if(sExistingExtension.isEmpty()) { + if(!sNormalizedPath.endsWith('.')) { + sNormalizedPath.append('.'); + } + sNormalizedPath.append(sExtension); + } else if(nmFFmpegVideoWriter::isSupportedExtension( + sExistingExtension)) { + sNormalizedPath.chop(sExistingExtension.length()); + sNormalizedPath.append(sExtension); + } else { + sNormalizedPath.append( + QString::fromLatin1(".%1").arg(sExtension)); + } + return QDir::cleanPath(sNormalizedPath); +} + +QString nmWxPebiResultAnimationExportDlg::getSuggestedTargetFilePath() const +{ + QString sDirectory = ZxBaseUtil::getLastDir(); + if(sDirectory.isEmpty() || !QDir(sDirectory).exists()) { + sDirectory = QDir::homePath(); + } + return QDir(sDirectory).absoluteFilePath( + QString::fromLatin1("PEBI-result.%1").arg( + getSelectedVideoExtension())); +} + +void nmWxPebiResultAnimationExportDlg::setTargetFilePath( + const QString& sPath) +{ + const QString sDisplayPath = QDir::toNativeSeparators(sPath); + m_pTargetPathEdit->setText(sDisplayPath); + m_pTargetPathEdit->setToolTip(sDisplayPath); +} + +void nmWxPebiResultAnimationExportDlg::showError(const QString& sError) +{ + m_pErrorLabel->setText(sError); + m_pErrorLabel->show(); +} + +void nmWxPebiResultAnimationExportDlg::clearError() +{ + m_pErrorLabel->clear(); + m_pErrorLabel->hide(); +} + int nmWxPebiResultAnimationExportDlg::normalizeEvenDimension(int nValue) { nValue = qBound(2, nValue, 8192); diff --git a/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp b/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp index 3ff7c2ff..88a3417d 100644 --- a/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp +++ b/Src/nmNum/nmSubWxs/nmWxPebiResultViewWidget.cpp @@ -16,8 +16,6 @@ #include #include #include -#include -#include #include #include #include @@ -101,55 +99,6 @@ private: bool m_bAcquired; }; -QString videoSaveFilters() -{ - QStringList listFilters; - listFilters << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "MP4 Video (*.mp4)") - << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "AVI Video (*.avi)") - << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "QuickTime Video (*.mov)") - << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "MPEG Video (*.mpg)") - << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "WebM Video (*.webm)") - << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "Matroska Video (*.mkv)") - << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "Windows Media Video (*.wmv)") - << QCoreApplication::translate( - "nmWxPebiResultViewWidget", "Flash Video (*.flv)"); - return listFilters.join(QString::fromLatin1(";;")); -} - -QString extensionFromFilter(const QString& sFilter) -{ - const QStringList listExtensions = - nmFFmpegVideoWriter::getSupportedExtensions(); - for(int nIndex = 0; nIndex < listExtensions.size(); ++nIndex) { - const QString& sExtension = listExtensions[nIndex]; - if(sFilter.contains(QString::fromLatin1("*.%1").arg(sExtension))) { - return sExtension; - } - } - return QString::fromLatin1("mp4"); -} - -QString normalizedVideoTargetPath(const QString& sPath, - const QString& sSelectedExtension) -{ - if(sPath.isEmpty()) { - return QString(); - } - const QString sExistingExtension = QFileInfo(sPath).suffix().toLower(); - if(sExistingExtension.isEmpty()) { - return sPath + QString::fromLatin1(".%1").arg(sSelectedExtension); - } - return nmFFmpegVideoWriter::isSupportedExtension(sExistingExtension) - ? sPath : QString(); -} - } nmWxPebiResultViewWidget::nmWxPebiResultViewWidget( @@ -379,34 +328,7 @@ void nmWxPebiResultViewWidget::onExportAnimationRequested() return; } - QString sSelectedFilter = videoSaveFilters().section( - QString::fromLatin1(";;"), 0, 0); - const QString sSelectedPath = QFileDialog::getSaveFileName( - this, tr("Save Result Animation"), - QDir::home().filePath(QString::fromLatin1("PEBI-result.mp4")), - videoSaveFilters(), &sSelectedFilter); - if(sSelectedPath.isEmpty()) { - return; - } - const QString sTargetPath = normalizedVideoTargetPath( - sSelectedPath, extensionFromFilter(sSelectedFilter)); - if(sTargetPath.isEmpty()) { - QMessageBox::warning( - this, tr("Export Animation"), - tr("The selected video file extension is unsupported.")); - return; - } - if(QFileInfo(sSelectedPath).suffix().isEmpty() && - QFileInfo(sTargetPath).exists() && - QMessageBox::question( - this, tr("Export Animation"), - tr("The selected file already exists. Replace it?"), - QMessageBox::Yes | QMessageBox::No, - QMessageBox::No) != QMessageBox::Yes) { - return; - } - - // 参数和文件窗口都可能形成嵌套事件循环,开始前重新核对完整显示身份。 + // 参数窗口内的文件浏览也会形成嵌套事件循环,开始前重新核对完整显示身份。 if(m_pDisplayedSnapshot.data() != pFrozenSnapshot.data() || m_pSceneController->getSnapshotBindingRevision() != nFrozenBindingRevision || @@ -433,7 +355,7 @@ void nmWxPebiResultViewWidget::onExportAnimationRequested() oRequest.m_sPropertyId = sFrozenPropertyId; oRequest.m_oDisplaySettings = oDisplaySettings; oRequest.m_oExportSettings = oSettingsDialog.getSettings(); - oRequest.m_sTargetFilePath = QFileInfo(sTargetPath).absoluteFilePath(); + oRequest.m_sTargetFilePath = oSettingsDialog.getTargetFilePath(); oRequest.m_bScalarBarVisible = m_pSceneController->isScalarBarVisible(); oRequest.m_bScaleGridVisible =