Add playblast: viewport capture to H.264 MP4 via libavcodec

- TimelinePanel: Playblast button opens modal with output dir (native
  folder picker via IFileOpenDialog), custom resolution (HD/FHD/4K
  presets), frame range, and movie export toggle
- UsdSceneRenderer: CaptureFrame() reads pixels from resolved MSAA FBO;
  Render() stores last dimensions for off-screen re-render at capture res
- MovieEncoder: streams RGBA frames directly into MP4 using libavcodec/
  libswscale (H.264, tries libx264 → nvenc → qsv → amf → openh264)
- Application: CapturePlayblastFrame() re-renders at capture resolution
  each frame; opens/writes/closes MovieEncoder inline with the render loop
- FileDialog: BrowseFolder() using Vista-style IFileOpenDialog (COM)
- CMake: FindFFmpeg.cmake locates prebuilt BtbN GPL shared package in
  third_party/ffmpeg; links and copies DLLs for main and test targets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 09:36:30 +08:00
parent c796c76561
commit 0538dd3243
12 changed files with 764 additions and 3 deletions
+136 -3
View File
@@ -13,6 +13,8 @@
#include <filesystem>
#include <algorithm>
#include <cctype>
#include <cstring>
#include "../utils/MovieEncoder.h"
namespace UsdLayerManager {
@@ -82,6 +84,10 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig
m_viewportPanel->SetTimeCodes(displayTime, editTime);
m_propertyPanel->SetTimeCodes(displayTime, editTime);
};
m_timelinePanel->OnBrowseFolder = [this]() -> std::string {
HWND hwnd = m_imguiContext ? m_imguiContext->GetWindowHandle() : nullptr;
return FileDialog::BrowseFolder("Select Playblast Output Folder", hwnd);
};
// Initialize IconManager — must happen after OpenGL context is ready (ImGui init above).
m_iconManager = std::make_unique<IconManager>();
@@ -148,7 +154,7 @@ void Application::Shutdown() {
m_iconManager->Shutdown();
m_iconManager.reset();
}
if (m_stageManager) {
m_stageManager->CloseStage();
m_stageManager.reset();
@@ -249,6 +255,17 @@ void Application::RenderUI() {
ImGui::End();
}
// Capture happens after the timeline so that the "Start" button click in
// this frame is detected, and after the viewport has rendered the scene.
if (m_timelinePanel->IsPlayblasting()) {
CapturePlayblastFrame();
}
// If user aborted playblast mid-sequence, close any open encoder.
if (m_movieEncoder.IsOpen() && !m_timelinePanel->IsPlayblasting()) {
m_movieEncoder.Close();
}
m_imguiContext->Render();
}
@@ -269,11 +286,10 @@ void Application::RenderStatusBar() {
if (open) {
ImGui::BeginMenuBar();
// FPS
// FPS — right-aligned
ImGuiIO& io = ImGui::GetIO();
char buf[32];
snprintf(buf, sizeof(buf), "FPS: %.1f", io.Framerate);
// Right-align: measure text, then position cursor.
float textW = ImGui::CalcTextSize(buf).x;
float avail = ImGui::GetContentRegionAvail().x;
if (avail > textW)
@@ -550,6 +566,123 @@ void Application::AddReferenceToStage() {
}
}
// ---------------------------------------------------------------------------
// Writes top-down RGBA8 pixel data as a 24-bit BMP file.
// Negative height in the DIB header tells viewers the rows are top-down.
static bool WriteBMP(const char* path, int w, int h, const uint8_t* rgba) {
FILE* f = nullptr;
if (fopen_s(&f, path, "wb") != 0 || !f) return false;
int rowStride = (w * 3 + 3) & ~3;
int dataSize = rowStride * h;
int fileSize = 54 + dataSize;
uint8_t hdr[54] = {};
hdr[0] = 'B'; hdr[1] = 'M';
auto put32 = [&](int off, uint32_t v) { memcpy(hdr + off, &v, 4); };
auto put32s = [&](int off, int32_t v) { memcpy(hdr + off, &v, 4); };
auto put16 = [&](int off, uint16_t v) { memcpy(hdr + off, &v, 2); };
put32(2, uint32_t(fileSize));
put32(10, 54u);
put32(14, 40u);
put32s(18, w);
put32s(22, -h); // negative = top-down storage
put16(26, 1u);
put16(28, 24u);
put32(34, uint32_t(dataSize));
fwrite(hdr, 1, 54, f);
std::vector<uint8_t> row(rowStride, 0);
for (int y = 0; y < h; ++y) {
const uint8_t* src = rgba + y * w * 4;
for (int x = 0; x < w; ++x) {
row[x*3+0] = src[x*4+2]; // B
row[x*3+1] = src[x*4+1]; // G
row[x*3+2] = src[x*4+0]; // R
}
fwrite(row.data(), 1, rowStride, f);
}
fclose(f);
return true;
}
void Application::CapturePlayblastFrame() {
// The frame BeginPlayblast() is called, the viewport has already rendered
// at the old time. Skip one frame so the next viewport render picks up the
// start timecode before we read pixels.
if (m_timelinePanel->GetPlayblastSkipFirst()) {
m_timelinePanel->ClearPlayblastSkipFirst();
return;
}
const auto& settings = m_timelinePanel->GetPlayblastSettings();
auto& renderer = m_viewportPanel->GetRenderer();
// Re-render at the requested capture resolution. The renderer retains the
// camera matrices from the viewport render, and SetFraming() adapts the
// frustum to the new aspect ratio, so the output is correct regardless of
// whether it matches the display viewport size.
int cw = settings.captureWidth;
int ch = settings.captureHeight;
renderer.Render(cw, ch);
std::vector<uint8_t> pixels;
if (!renderer.CaptureFrame(pixels)) {
LOG_ERROR("Playblast: CaptureFrame failed");
m_timelinePanel->AbortPlayblast();
return;
}
int idx = m_timelinePanel->GetPlayblastFrameIndex();
std::error_code ec;
std::filesystem::create_directories(settings.outputDir, ec);
// ── Movie encode (direct libav streaming) ─────────────────────────────
if (settings.exportMovie) {
if (idx == 0) {
char moviePath[1024];
snprintf(moviePath, sizeof(moviePath), "%s\\%s.mp4",
settings.outputDir, settings.filePrefix);
if (!m_movieEncoder.Open(moviePath, cw, ch, settings.fps)) {
LOG_ERROR("MovieEncoder open failed: " + m_movieEncoder.GetLastError());
m_timelinePanel->AbortPlayblast();
return;
}
}
if (!m_movieEncoder.WriteFrame(pixels.data())) {
LOG_ERROR("MovieEncoder write failed: " + m_movieEncoder.GetLastError());
m_movieEncoder.Close();
m_timelinePanel->AbortPlayblast();
return;
}
}
// ── BMP frames (always written when not exporting movie, or when keepFrames) ─
if (!settings.exportMovie || settings.keepFrames) {
char filename[1024];
snprintf(filename, sizeof(filename), "%s\\%s.%04d.bmp",
settings.outputDir, settings.filePrefix, idx);
if (!WriteBMP(filename, cw, ch, pixels.data())) {
LOG_ERROR(std::string("Playblast: failed to write ") + filename);
if (settings.exportMovie) m_movieEncoder.Close();
m_timelinePanel->AbortPlayblast();
return;
}
}
if (!m_timelinePanel->AdvancePlayblast()) {
// Last frame — close the encoder (writes trailer).
if (settings.exportMovie && m_movieEncoder.IsOpen()) {
m_movieEncoder.Close();
LOG_INFO(std::string("Playblast movie: ") +
settings.outputDir + "\\" + settings.filePrefix + ".mp4");
}
LOG_INFO("Playblast complete: " + std::to_string(idx + 1) +
" frames captured to " + settings.outputDir);
}
}
void Application::CreatePrimOnStage(const std::string& typeName) {
if (!m_stageManager->HasStage()) return;