Skip to content

[4.7] Video export. Add Page video export style#33030

Open
mike-spa wants to merge 3 commits intomusescore:4.7from
mike-spa:addPageVideoStyle
Open

[4.7] Video export. Add Page video export style#33030
mike-spa wants to merge 3 commits intomusescore:4.7from
mike-spa:addPageVideoStyle

Conversation

@mike-spa
Copy link
Copy Markdown
Contributor

@mike-spa mike-spa commented Apr 16, 2026

Summary by CodeRabbit

  • New Features

    • Added video export view mode selection with "Page Full" and "Flexible" options and exposed controls so selection persists.
  • UI/UX

    • Increased export dialog height to accommodate new controls.
    • Removed trailing colon from the "Video resolution" label.
  • Behavior

    • Default view now favors full-page presentation and frames are centered when using that mode; flexible mode keeps prior behavior.
  • Visual

    • Video export background changed to black.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 16, 2026

📝 Walkthrough

Walkthrough

Replaces several legacy video view modes with two modes (PageFull, Flexible); changes default view mode to PageFull; exposes viewMode in export model/UI; updates VideoWriter config and rendering to branch behavior for PageFull (page-fitting scale, centering) and Flexible, and adjusts frame background and painting translation.

Changes

Cohort / File(s) Summary
ViewMode enum
src/importexport/videoexport/videoexporttypes.h
Removed old enum values (Auto, PagedFloat, PagedOriginal, PagedFloatHeight, Pano) and introduced PageFull and Flexible.
Config default
src/importexport/videoexport/internal/videoexportconfiguration.cpp
Changed default fallback for VideoExportConfiguration::viewMode() from Auto to PageFull.
VideoWriter config & rendering
src/importexport/videoexport/internal/videowriter.h, src/importexport/videoexport/internal/videowriter.cpp
Added viewMode and moveToCenter to VideoWriter::Config; makeConfig() reads configuration()->viewMode(). prepareScore() now branches: for PageFull compute page-fitting scale, set canvasDpi and moveToCenter, and return early; for Flexible apply instrument/VBox hiding. Removed redundant doLayout() call. generateScoreFrames() uses black background and wraps score rendering with translate/restore for centering.
Export dialog model (QML API)
src/project/qml/MuseScore/Project/internal/Export/exportdialogmodel.h, src/project/qml/MuseScore/Project/internal/Export/exportdialogmodel.cpp
Added Q_PROPERTY viewMode, Q_INVOKABLE availableViewModes() returning {value,text} pairs, getter viewMode() and setter setViewMode(...) with viewModeChanged signal; forwards to VideoExportConfiguration.
Export dialog UI
src/project/qml/MuseScore/Project/ExportDialog.qml, src/project/qml/MuseScore/Project/internal/Export/Mp4SettingsPage.qml
Increased StyledDialogView contentHeight (372 → 420). Mp4 settings: removed colon from label, added id for resolution dropdown, and added a radio button group bound to availableViewModes() to select viewMode (two-way binding and navigation rows).

Sequence Diagram

sequenceDiagram
    actor User
    participant UI as MP4SettingsPage
    participant Model as ExportDialogModel
    participant Config as VideoExportConfiguration
    participant Writer as VideoWriter
    participant Renderer as RenderingEngine

    User->>UI: choose view mode (PageFull / Flexible)
    UI->>Model: setViewMode(mode)
    Model->>Config: setViewMode(mode)
    Config-->>Model: ack

    User->>UI: start export
    UI->>Model: requestExport()
    Model->>Writer: makeConfig()/startExport()
    Writer->>Config: viewMode()
    Config-->>Writer: returns mode

    alt PageFull
        Writer->>Writer: compute page-fitting scale, set canvasDpi & moveToCenter
        Writer->>Renderer: render score at computed scale (centered via translate)
        Renderer-->>Writer: frame
    else Flexible
        Writer->>Writer: hide instrument names / VBox
        Writer->>Renderer: render score with flexible layout
        Renderer-->>Writer: frame
    end

    Writer-->>UI: export complete / frames written
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is empty; no content was provided by the author despite a template being available with required sections and checkboxes. Provide a complete PR description including: issue reference (Resolves #NNNNN), brief explanation of changes and motivation, and completion of all pre-submission checklist items.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a new 'Page' video export style (ViewMode::PageFull) with supporting UI and configuration changes throughout the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

width: videoSettingsComp.width
spacing: 4

model: [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's move it to c++ model, see available... methods in ExportDialogModel for example

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d9e5afd0-69fa-45b7-8fbc-9ae2b4a205c7

📥 Commits

Reviewing files that changed from the base of the PR and between 37dca8f and 59a8c25.

📒 Files selected for processing (9)
  • src/importexport/videoexport/internal/videoexportconfiguration.cpp
  • src/importexport/videoexport/internal/videoexportconfiguration.h
  • src/importexport/videoexport/internal/videowriter.cpp
  • src/importexport/videoexport/internal/videowriter.h
  • src/importexport/videoexport/ivideoexportconfiguration.h
  • src/project/qml/MuseScore/Project/ExportDialog.qml
  • src/project/qml/MuseScore/Project/internal/Export/Mp4SettingsPage.qml
  • src/project/qml/MuseScore/Project/internal/Export/exportdialogmodel.cpp
  • src/project/qml/MuseScore/Project/internal/Export/exportdialogmodel.h

Comment on lines +110 to +119

int VideoExportConfiguration::videoStyle() const
{
return m_videoStyle ? m_videoStyle.value() : 0;
}

void VideoExportConfiguration::setVideoStyle(int v)
{
m_videoStyle = v;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider using a named constant for the default value.

Other configuration values in this file use named constants (e.g., DEFAULT_VIEW_MODE, DEFAULT_FPS). The hard-coded 0 on line 113 would be clearer with a constant.

♻️ Proposed change for consistency

Add at the top of the file with other defaults:

static const int DEFAULT_VIDEO_STYLE = 0; // VideoStyle::PAGE

Then update the getter:

 int VideoExportConfiguration::videoStyle() const
 {
-    return m_videoStyle ? m_videoStyle.value() : 0;
+    return m_videoStyle ? m_videoStyle.value() : DEFAULT_VIDEO_STYLE;
 }

cfg.leadingSec = configuration()->leadingSec();
cfg.trailingSec = configuration()->trailingSec();

cfg.style = static_cast<VideoStyle>(configuration()->videoStyle());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clamp videoStyle before casting.

videoStyle() is an int, so a stale/corrupted setting can produce an enum value that matches neither PAGE nor FLEXIBLE. That drops export into an unintended mixed path below. Default invalid values here instead of static_casting blindly.

🛡️ Proposed fix
-    cfg.style = static_cast<VideoStyle>(configuration()->videoStyle());
+    const int rawStyle = configuration()->videoStyle();
+    cfg.style = rawStyle == static_cast<int>(VideoStyle::FLEXIBLE)
+                ? VideoStyle::FLEXIBLE
+                : VideoStyle::PAGE;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cfg.style = static_cast<VideoStyle>(configuration()->videoStyle());
const int rawStyle = configuration()->videoStyle();
cfg.style = rawStyle == static_cast<int>(VideoStyle::FLEXIBLE)
? VideoStyle::FLEXIBLE
: VideoStyle::PAGE;

Comment thread src/importexport/videoexport/internal/videowriter.cpp Outdated
Comment on lines +61 to +63

virtual int videoStyle() const = 0;
virtual void setVideoStyle(int v) = 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Inconsistent setter signature pattern.

Other setters in this interface use std::optional<T> (e.g., setViewMode(std::optional<ViewMode>), setFps(std::optional<int>)), but setVideoStyle takes a plain int. This breaks the established pattern and prevents resetting to default.

Consider aligning with the existing convention:

♻️ Proposed change for consistency
-    virtual void setVideoStyle(int v) = 0;
+    virtual void setVideoStyle(std::optional<int> v) = 0;

This would also require updating the implementation in VideoExportConfiguration.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
virtual int videoStyle() const = 0;
virtual void setVideoStyle(int v) = 0;
virtual int videoStyle() const = 0;
virtual void setVideoStyle(std::optional<int> v) = 0;

Comment on lines +138 to +160
ExportOptionItem {
id: videoStyleLabel
text: qsTrc("project/export", "Video style")

RadioButtonGroup {
width: videoSettingsComp.width
spacing: 4

model: [
{ text: qsTrc("project/export", "Page"), value: 0 },
{ text: qsTrc("project/export", "Flexible"), value: 1 }
]

delegate: FlatRadioButton {
width: 126
height: 30

text: modelData.text
checked: modelData.value === root.model.videoStyle
onToggled: root.model.videoStyle = modelData.value
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing null guard on root.model access.

The radio button bindings access root.model.videoStyle without null checks (lines 156-157), unlike other controls in this file that guard with root.model ? (e.g., lines 126, 130). This could cause errors if the model is not yet initialized.

🛡️ Proposed fix to add null guard
                 delegate: FlatRadioButton {
                     width: 126
                     height: 30

                     text: modelData.text
-                    checked: modelData.value === root.model.videoStyle
-                    onToggled: root.model.videoStyle = modelData.value
+                    checked: root.model ? modelData.value === root.model.videoStyle : false
+                    onToggled: {
+                        if (root.model) {
+                            root.model.videoStyle = modelData.value
+                        }
+                    }
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ExportOptionItem {
id: videoStyleLabel
text: qsTrc("project/export", "Video style")
RadioButtonGroup {
width: videoSettingsComp.width
spacing: 4
model: [
{ text: qsTrc("project/export", "Page"), value: 0 },
{ text: qsTrc("project/export", "Flexible"), value: 1 }
]
delegate: FlatRadioButton {
width: 126
height: 30
text: modelData.text
checked: modelData.value === root.model.videoStyle
onToggled: root.model.videoStyle = modelData.value
}
}
}
ExportOptionItem {
id: videoStyleLabel
text: qsTrc("project/export", "Video style")
RadioButtonGroup {
width: videoSettingsComp.width
spacing: 4
model: [
{ text: qsTrc("project/export", "Page"), value: 0 },
{ text: qsTrc("project/export", "Flexible"), value: 1 }
]
delegate: FlatRadioButton {
width: 126
height: 30
text: modelData.text
checked: root.model ? modelData.value === root.model.videoStyle : false
onToggled: {
if (root.model) {
root.model.videoStyle = modelData.value
}
}
}
}
}

@mike-spa mike-spa force-pushed the addPageVideoStyle branch from 59a8c25 to 41de519 Compare April 16, 2026 16:56
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/project/qml/MuseScore/Project/internal/Export/Mp4SettingsPage.qml (1)

147-153: ⚠️ Potential issue | 🟡 Minor

Missing null guard on root.model access.

The delegate bindings access root.model.viewMode without null checks (lines 151-152), unlike other controls in this file that guard with root.model ? (e.g., lines 126, 130, 143). This could cause errors if the model is not yet initialized.

🛡️ Proposed fix to add null guard
                 delegate: RoundedRadioButton {
                     required property var modelData

                     text: modelData.text
-                    checked: root.model.viewMode === modelData.value
-                    onToggled: root.model.viewMode = modelData.value
+                    checked: root.model ? root.model.viewMode === modelData.value : false
+                    onToggled: {
+                        if (root.model) {
+                            root.model.viewMode = modelData.value
+                        }
+                    }
                 }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ca1438b8-6342-457b-8014-6e1322fd569a

📥 Commits

Reviewing files that changed from the base of the PR and between 59a8c25 and 41de519.

📒 Files selected for processing (8)
  • src/importexport/videoexport/internal/videoexportconfiguration.cpp
  • src/importexport/videoexport/internal/videowriter.cpp
  • src/importexport/videoexport/internal/videowriter.h
  • src/importexport/videoexport/videoexporttypes.h
  • src/project/qml/MuseScore/Project/ExportDialog.qml
  • src/project/qml/MuseScore/Project/internal/Export/Mp4SettingsPage.qml
  • src/project/qml/MuseScore/Project/internal/Export/exportdialogmodel.cpp
  • src/project/qml/MuseScore/Project/internal/Export/exportdialogmodel.h

Comment on lines +323 to +330
if (config.viewMode == ViewMode::PageFull) {
double scaleX = config.width / page->width();
double scaleY = config.height / page->height();
double scale = std::min(scaleX, scaleY);
config.canvasDpi = scale * engraving::DPI;
config.moveToCenter = muse::PointF(0.5 * (config.width / scale - page->width()), 0.5 * (config.height / scale - page->height()));
return result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider guarding against zero page dimensions.

The scaling calculation divides by page->width() and page->height(). While pages should always have positive dimensions after doLayout(), a defensive check would prevent potential division by zero if layout fails silently.

🛡️ Proposed defensive check
     if (config.viewMode == ViewMode::PageFull) {
+        if (page->width() <= 0 || page->height() <= 0) {
+            LOGE() << "Invalid page dimensions";
+            restoreScore(notation, result);
+            return std::nullopt;
+        }
         double scaleX = config.width / page->width();
         double scaleY = config.height / page->height();

Comment on lines +138 to +154
RadioButtonGroup {
orientation: ListView.Vertical
spacing: 8
width: parent.width

model: root.model ? root.model.availableViewModes().map(function(obj) {
return { text: obj.text, value: obj.value }
}) : []

delegate: RoundedRadioButton {
required property var modelData

text: modelData.text
checked: root.model.viewMode === modelData.value
onToggled: root.model.viewMode = modelData.value
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please add navigation for these buttons

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c408189-52e3-49e1-b134-c525c5ff7118

📥 Commits

Reviewing files that changed from the base of the PR and between 41de519 and a99514d.

📒 Files selected for processing (1)
  • src/project/qml/MuseScore/Project/internal/Export/Mp4SettingsPage.qml

Comment on lines +144 to +146
model: root.model ? root.model.availableViewModes().map(function(obj) {
return { text: obj.text, value: obj.value }
}) : []
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider moving the option mapping into the C++ model.

availableViewModes() already returns {value, text} objects (see exportdialogmodel.cpp:618-635), so the .map(...) here is an identity transform. Either drop the .map and use the list directly, or — aligning with prior maintainer feedback on this file — expose the list as a C++ Q_PROPERTY/getter matching the available... pattern used in ExportDialogModel so QML does no transformation.

♻️ Proposed minimal simplification
-                model: root.model ? root.model.availableViewModes().map(function(obj) {
-                    return { text: obj.text, value: obj.value }
-                }) : []
+                model: root.model ? root.model.availableViewModes() : []

Comment thread src/project/qml/MuseScore/Project/internal/Export/Mp4SettingsPage.qml Outdated
@mike-spa mike-spa force-pushed the addPageVideoStyle branch from a99514d to 5c63ae8 Compare April 17, 2026 11:17
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: a1f860b3-6371-4bfc-9e4d-4e72725efbbd

📥 Commits

Reviewing files that changed from the base of the PR and between a99514d and 5c63ae8.

📒 Files selected for processing (2)
  • src/project/qml/MuseScore/Project/internal/Export/Mp4SettingsPage.qml
  • src/project/qml/MuseScore/Project/internal/Export/exportdialogmodel.cpp

Comment on lines +618 to +635
QVariantList ExportDialogModel::availableViewModes() const
{
std::map<ViewMode, QString> viewModes {
{ ViewMode::PageFull, muse::qtrc("project/export", "Use page layout") },
{ ViewMode::Flexible, muse::qtrc("project/export", "Reflow to fit video resolution") },
};

QVariantList result;

for (const auto& [value, text] : viewModes) {
QVariantMap obj;
obj["value"] = value;
obj["text"] = text;
result << obj;
}

return result;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Inconsistent value encoding in QVariantMap.

Other similar available...() helpers in this file cast enums to int when populating the QVariantMap (e.g., availableUnitTypes() line 373, musicXmlLayoutTypes() line 864, availableSampleFormats() line 949). Here obj["value"] = value; stores the raw ViewMode enum. Since the Q_PROPERTY viewMode is of type ViewMode and QML compares via ===, this currently works, but aligning with the established pattern avoids subtle mismatches (e.g., JS strict-equality across enum vs int representations) and keeps the codebase consistent.

♻️ Proposed change
     for (const auto& [value, text] : viewModes) {
         QVariantMap obj;
-        obj["value"] = value;
+        obj["value"] = static_cast<int>(value);
         obj["text"] = text;
         result << obj;
     }

Also note: iteration over std::map<ViewMode, QString> orders entries by enum value, so the UI display order is coupled to the underlying enum numeric order rather than an explicit desired order. If a specific order (e.g., PageFull first) is desired, consider using an ordered container like std::vector<std::pair<ViewMode, QString>>.

@Eism Eism changed the title Add Page video export style [4.7] Video export. Add Page video export style Apr 17, 2026
@avvvvve
Copy link
Copy Markdown

avvvvve commented Apr 17, 2026

Found a crash:

  1. Open this score:
    Zelda Theme.mscz.zip
  2. Export mp4 in page view
  3. Export is successful, but MSS crashes afterward

Also, this file has a title page with just two vertical frames and no notation. That page is missing from the exported page view. I think such pages should be included in page view exports even if frames are excluded from "reflow" exports.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants