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:
+136
-3
@@ -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;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "../core/LayerManager.h"
|
||||
#include "../core/PropertyManager.h"
|
||||
#include "../core/CommandHistory.h"
|
||||
#include "../utils/MovieEncoder.h"
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -43,6 +44,9 @@ private:
|
||||
// Stage editing operations (also exposed via Stage menu)
|
||||
void AddReferenceToStage();
|
||||
void CreatePrimOnStage(const std::string& typeName);
|
||||
|
||||
// Playblast — called each frame while a capture is in progress.
|
||||
void CapturePlayblastFrame();
|
||||
|
||||
std::unique_ptr<ImGuiContext> m_imguiContext;
|
||||
std::unique_ptr<IconManager> m_iconManager;
|
||||
@@ -63,6 +67,8 @@ private:
|
||||
bool m_showPropertyPanel;
|
||||
bool m_showTimeline;
|
||||
bool m_running;
|
||||
|
||||
MovieEncoder m_movieEncoder;
|
||||
};
|
||||
|
||||
} // namespace UsdLayerManager
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
namespace UsdLayerManager {
|
||||
|
||||
@@ -35,6 +36,7 @@ void TimelinePanel::SetStage(pxr::UsdStageRefPtr stage)
|
||||
// ---------------------------------------------------------------------------
|
||||
void TimelinePanel::Update(float deltaTime)
|
||||
{
|
||||
if (m_isPlayblasting) return; // time is stepped externally during capture
|
||||
if (!m_playing) return;
|
||||
|
||||
double playStart = m_startFrame;
|
||||
@@ -204,6 +206,117 @@ void TimelinePanel::Render()
|
||||
ImGui::Text("%.6g fps", m_fps);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Checkbox("Auto-Key", &m_autoKey)) NotifyTimeChanged();
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine();
|
||||
|
||||
// ---- Playblast button / progress ----
|
||||
if (m_isPlayblasting) {
|
||||
int total = m_playblastSettings.endFrame - m_playblastSettings.startFrame + 1;
|
||||
ImGui::Text("Capturing %d / %d", m_playblastFrameIdx, total);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Cancel##pb")) AbortPlayblast();
|
||||
} else {
|
||||
if (ImGui::Button("Playblast")) {
|
||||
m_playblastSettings.startFrame = int(m_startFrame);
|
||||
m_playblastSettings.endFrame = int(m_endFrame);
|
||||
m_showPlayblastDialog = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Playblast settings dialog ----
|
||||
if (m_showPlayblastDialog) {
|
||||
ImGui::OpenPopup("Playblast##dlg");
|
||||
m_showPlayblastDialog = false;
|
||||
}
|
||||
if (ImGui::BeginPopupModal("Playblast##dlg", nullptr,
|
||||
ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
// ── Output path ──────────────────────────────────────────────────────
|
||||
ImGui::Text("Output directory:");
|
||||
ImGui::SetNextItemWidth(340.f);
|
||||
ImGui::InputText("##outdir", m_playblastSettings.outputDir,
|
||||
sizeof(m_playblastSettings.outputDir));
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Browse...##outdir")) {
|
||||
if (OnBrowseFolder) {
|
||||
std::string dir = OnBrowseFolder();
|
||||
if (!dir.empty()) {
|
||||
strncpy(m_playblastSettings.outputDir, dir.c_str(),
|
||||
sizeof(m_playblastSettings.outputDir) - 1);
|
||||
m_playblastSettings.outputDir[sizeof(m_playblastSettings.outputDir) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Text("File prefix:");
|
||||
ImGui::SetNextItemWidth(200.f);
|
||||
ImGui::InputText("##prefix", m_playblastSettings.filePrefix,
|
||||
sizeof(m_playblastSettings.filePrefix));
|
||||
|
||||
// ── Frame range ───────────────────────────────────────────────────────
|
||||
ImGui::Separator();
|
||||
ImGui::SetNextItemWidth(90.f);
|
||||
ImGui::InputInt("Start frame##pb", &m_playblastSettings.startFrame);
|
||||
ImGui::SetNextItemWidth(90.f);
|
||||
ImGui::InputInt("End frame##pb", &m_playblastSettings.endFrame);
|
||||
if (m_playblastSettings.endFrame < m_playblastSettings.startFrame)
|
||||
m_playblastSettings.endFrame = m_playblastSettings.startFrame;
|
||||
|
||||
// ── Capture resolution ────────────────────────────────────────────────
|
||||
ImGui::Separator();
|
||||
ImGui::Text("Capture resolution:");
|
||||
ImGui::SetNextItemWidth(80.f);
|
||||
ImGui::InputInt("W##pbw", &m_playblastSettings.captureWidth);
|
||||
if (m_playblastSettings.captureWidth < 16) m_playblastSettings.captureWidth = 16;
|
||||
ImGui::SameLine();
|
||||
ImGui::Text("x");
|
||||
ImGui::SameLine();
|
||||
ImGui::SetNextItemWidth(80.f);
|
||||
ImGui::InputInt("H##pbh", &m_playblastSettings.captureHeight);
|
||||
if (m_playblastSettings.captureHeight < 16) m_playblastSettings.captureHeight = 16;
|
||||
|
||||
// Common presets
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("HD")) { m_playblastSettings.captureWidth = 1280; m_playblastSettings.captureHeight = 720; }
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("FHD")) { m_playblastSettings.captureWidth = 1920; m_playblastSettings.captureHeight = 1080; }
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("4K")) { m_playblastSettings.captureWidth = 3840; m_playblastSettings.captureHeight = 2160; }
|
||||
|
||||
// ── Movie export ──────────────────────────────────────────────────────
|
||||
ImGui::Separator();
|
||||
ImGui::Checkbox("Export movie (H.264 MP4 via libavcodec)", &m_playblastSettings.exportMovie);
|
||||
if (m_playblastSettings.exportMovie) {
|
||||
ImGui::Indent();
|
||||
ImGui::Checkbox("Also save BMP frames alongside movie", &m_playblastSettings.keepFrames);
|
||||
ImGui::Unindent();
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
ImGui::TextDisabled("Writes: <dir>\\<prefix>.NNNN.bmp%s",
|
||||
m_playblastSettings.exportMovie ? " → <prefix>.mp4" : "");
|
||||
|
||||
// ── Buttons ───────────────────────────────────────────────────────────
|
||||
ImGui::Spacing();
|
||||
if (ImGui::Button("Cancel##dlg")) ImGui::CloseCurrentPopup();
|
||||
ImGui::SameLine();
|
||||
bool canStart = m_playblastSettings.outputDir[0] != '\0' &&
|
||||
m_playblastSettings.filePrefix[0] != '\0';
|
||||
ImGui::BeginDisabled(!canStart);
|
||||
if (ImGui::Button("Start##dlg")) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
m_playblastWasPlaying = m_playing;
|
||||
m_playing = false;
|
||||
m_playblastSettings.fps = m_fps;
|
||||
m_playblastFrame = double(m_playblastSettings.startFrame);
|
||||
m_playblastFrameIdx = 0;
|
||||
m_playblastSkipFirst = true;
|
||||
m_isPlayblasting = true;
|
||||
SetCurrentFrame(m_playblastFrame);
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// ---- Slider row: [start] [----slider----] [end] ----
|
||||
{
|
||||
@@ -268,4 +381,23 @@ pxr::UsdTimeCode TimelinePanel::EditTime() const
|
||||
: pxr::UsdTimeCode::Default();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
bool TimelinePanel::AdvancePlayblast()
|
||||
{
|
||||
m_playblastFrame += 1.0;
|
||||
m_playblastFrameIdx++;
|
||||
if (m_playblastFrame > double(m_playblastSettings.endFrame)) {
|
||||
AbortPlayblast();
|
||||
return false;
|
||||
}
|
||||
SetCurrentFrame(m_playblastFrame);
|
||||
return true;
|
||||
}
|
||||
|
||||
void TimelinePanel::AbortPlayblast()
|
||||
{
|
||||
m_isPlayblasting = false;
|
||||
m_playing = m_playblastWasPlaying;
|
||||
}
|
||||
|
||||
} // namespace UsdLayerManager
|
||||
|
||||
@@ -30,6 +30,35 @@ public:
|
||||
std::function<void(pxr::UsdTimeCode displayTime,
|
||||
pxr::UsdTimeCode editTime)> OnTimeChanged;
|
||||
|
||||
// ── Playblast ────────────────────────────────────────────────────────────
|
||||
struct PlayblastSettings {
|
||||
char outputDir[512] = "";
|
||||
char filePrefix[128] = "playblast";
|
||||
int startFrame = 1;
|
||||
int endFrame = 100;
|
||||
int captureWidth = 1920;
|
||||
int captureHeight = 1080;
|
||||
bool exportMovie = true; // encode directly to MP4 (libavcodec)
|
||||
bool keepFrames = false; // also write BMP frames alongside movie
|
||||
double fps = 24.0; // filled from stage fps at capture start
|
||||
};
|
||||
|
||||
bool IsPlayblasting() const { return m_isPlayblasting; }
|
||||
/// True on the frame immediately after playblast begins — the viewport has
|
||||
/// not yet rendered at the start time, so the first capture must be skipped.
|
||||
bool GetPlayblastSkipFirst() const { return m_playblastSkipFirst; }
|
||||
void ClearPlayblastSkipFirst() { m_playblastSkipFirst = false; }
|
||||
int GetPlayblastFrameIndex() const { return m_playblastFrameIdx; }
|
||||
const PlayblastSettings& GetPlayblastSettings() const { return m_playblastSettings; }
|
||||
double GetFps() const { return m_fps; }
|
||||
|
||||
/// Step to the next capture frame. Returns false (and stops capture) when done.
|
||||
bool AdvancePlayblast();
|
||||
void AbortPlayblast();
|
||||
|
||||
/// Callback set by Application so the dialog can open a native folder picker.
|
||||
std::function<std::string()> OnBrowseFolder;
|
||||
|
||||
private:
|
||||
/// Clamp to [startFrame, endFrame]; fires OnTimeChanged when the frame changes.
|
||||
void SetCurrentFrame(double frame);
|
||||
@@ -54,6 +83,15 @@ private:
|
||||
|
||||
// Auto-Key.
|
||||
bool m_autoKey = false;
|
||||
|
||||
// Playblast state.
|
||||
bool m_isPlayblasting = false;
|
||||
bool m_playblastSkipFirst = false;
|
||||
double m_playblastFrame = 0.0;
|
||||
int m_playblastFrameIdx = 0;
|
||||
bool m_playblastWasPlaying = false;
|
||||
PlayblastSettings m_playblastSettings;
|
||||
bool m_showPlayblastDialog = false;
|
||||
};
|
||||
|
||||
} // namespace UsdLayerManager
|
||||
|
||||
Reference in New Issue
Block a user