From 0538dd3243c286f5953efe79ff2e0198d7aa892c Mon Sep 17 00:00:00 2001 From: indigo Date: Tue, 23 Jun 2026 09:36:30 +0800 Subject: [PATCH] Add playblast: viewport capture to H.264 MP4 via libavcodec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CMakeLists.txt | 23 ++++ cmake/modules/FindFFmpeg.cmake | 64 +++++++++ src/core/UsdSceneRenderer.cpp | 37 ++++++ src/core/UsdSceneRenderer.h | 10 ++ src/ui/Application.cpp | 139 ++++++++++++++++++- src/ui/Application.h | 6 + src/ui/TimelinePanel.cpp | 132 ++++++++++++++++++ src/ui/TimelinePanel.h | 38 ++++++ src/utils/FileDialog.cpp | 36 +++++ src/utils/FileDialog.h | 6 + src/utils/MovieEncoder.cpp | 236 +++++++++++++++++++++++++++++++++ src/utils/MovieEncoder.h | 40 ++++++ 12 files changed, 764 insertions(+), 3 deletions(-) create mode 100644 cmake/modules/FindFFmpeg.cmake create mode 100644 src/utils/MovieEncoder.cpp create mode 100644 src/utils/MovieEncoder.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b62e12..b88d28c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,6 +14,7 @@ find_package(OpenGL REQUIRED) find_package(OpenUSD REQUIRED) find_package(Imgui REQUIRED) find_package(Glad REQUIRED) +find_package(FFmpeg REQUIRED) # USD build that includes hdEmbree (built with PXR_ENABLE_EMBREE_PLUGIN=ON). # Can be a separate install from OpenUSD_ROOT_DIR; leave empty to search there instead. @@ -177,6 +178,13 @@ target_link_libraries(UsdLayerManager PRIVATE OpenUSD::OpenUSD Imgui::Imgui Glad::Glad + FFmpeg::FFmpeg + ole32 + shell32 +) + +target_include_directories(UsdLayerManager PRIVATE + "${CMAKE_SOURCE_DIR}/third_party/ffmpeg/include" ) # Cycles is an ExternalProject; UsdLayerManager must wait for it to finish @@ -194,6 +202,17 @@ if(WIN32) _CRT_SECURE_NO_WARNINGS ) + # FFmpeg DLLs — copy all .dll files from third_party/ffmpeg/bin + file(GLOB FFMPEG_RUNTIME_DLLS "${CMAKE_SOURCE_DIR}/third_party/ffmpeg/bin/*.dll") + if(FFMPEG_RUNTIME_DLLS) + add_custom_command(TARGET UsdLayerManager POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${FFMPEG_RUNTIME_DLLS} + "$" + COMMENT "Copying FFmpeg runtime DLLs..." + ) + endif() + # Copy runtime DLLs to output directory for each configuration add_custom_command(TARGET UsdLayerManager POST_BUILD COMMAND ${CMAKE_COMMAND} -E make_directory "$" @@ -504,11 +523,13 @@ add_executable(ViewportDisplayTest target_include_directories(ViewportDisplayTest PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/include + "${CMAKE_SOURCE_DIR}/third_party/ffmpeg/include" ) target_link_libraries(ViewportDisplayTest PRIVATE OpenUSD::OpenUSD Glad::Glad + FFmpeg::FFmpeg ) if(WIN32) @@ -550,11 +571,13 @@ add_executable(RendererDiagnosticTest target_include_directories(RendererDiagnosticTest PRIVATE ${CMAKE_SOURCE_DIR}/src ${CMAKE_SOURCE_DIR}/include + "${CMAKE_SOURCE_DIR}/third_party/ffmpeg/include" ) target_link_libraries(RendererDiagnosticTest PRIVATE OpenUSD::OpenUSD Glad::Glad + FFmpeg::FFmpeg ) if(WIN32) diff --git a/cmake/modules/FindFFmpeg.cmake b/cmake/modules/FindFFmpeg.cmake new file mode 100644 index 0000000..fe88d07 --- /dev/null +++ b/cmake/modules/FindFFmpeg.cmake @@ -0,0 +1,64 @@ +# FindFFmpeg.cmake +# Locates the prebuilt FFmpeg dev package in third_party/ffmpeg. +# Sets up IMPORTED targets: FFmpeg::avcodec FFmpeg::avformat +# FFmpeg::avutil FFmpeg::swscale +# and an umbrella: FFmpeg::FFmpeg + +set(FFMPEG_ROOT "${CMAKE_SOURCE_DIR}/third_party/ffmpeg" + CACHE PATH "FFmpeg install root (contains bin/ include/ lib/)") + +set(FFmpeg_FOUND TRUE) + +foreach(_comp avcodec avformat avutil swscale) + # Import library (.lib) — required for MSVC linking + find_library(FFmpeg_${_comp}_LIB + NAMES ${_comp} + PATHS "${FFMPEG_ROOT}/lib" + NO_DEFAULT_PATH + ) + + # Runtime DLL — name includes a version suffix, e.g. avcodec-62.dll + file(GLOB _dlls "${FFMPEG_ROOT}/bin/${_comp}-*.dll") + if(_dlls) + list(GET _dlls 0 FFmpeg_${_comp}_DLL) + else() + set(FFmpeg_${_comp}_DLL "") + endif() + + if(NOT FFmpeg_${_comp}_LIB) + set(FFmpeg_FOUND FALSE) + message(STATUS "FFmpeg: ${_comp}.lib NOT found in ${FFMPEG_ROOT}/lib") + continue() + endif() + if(NOT FFmpeg_${_comp}_DLL) + set(FFmpeg_FOUND FALSE) + message(STATUS "FFmpeg: ${_comp}-*.dll NOT found in ${FFMPEG_ROOT}/bin") + continue() + endif() + + add_library(FFmpeg::${_comp} SHARED IMPORTED) + set_target_properties(FFmpeg::${_comp} PROPERTIES + IMPORTED_IMPLIB "${FFmpeg_${_comp}_LIB}" + IMPORTED_LOCATION "${FFmpeg_${_comp}_DLL}" + INTERFACE_INCLUDE_DIRECTORIES "${FFMPEG_ROOT}/include" + ) + message(STATUS "FFmpeg: ${_comp} -> ${FFmpeg_${_comp}_LIB}") +endforeach() + +if(FFmpeg_FOUND) + # Umbrella target so callers can just link FFmpeg::FFmpeg + if(NOT TARGET FFmpeg::FFmpeg) + add_library(FFmpeg::FFmpeg INTERFACE IMPORTED) + set_target_properties(FFmpeg::FFmpeg PROPERTIES + INTERFACE_LINK_LIBRARIES + "FFmpeg::avcodec;FFmpeg::avformat;FFmpeg::avutil;FFmpeg::swscale" + INTERFACE_INCLUDE_DIRECTORIES + "${FFMPEG_ROOT}/include" + ) + endif() +endif() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(FFmpeg + REQUIRED_VARS FFmpeg_avcodec_LIB FFmpeg_avformat_LIB + FFmpeg_avutil_LIB FFmpeg_swscale_LIB) diff --git a/src/core/UsdSceneRenderer.cpp b/src/core/UsdSceneRenderer.cpp index 3c5439b..e6c80e7 100644 --- a/src/core/UsdSceneRenderer.cpp +++ b/src/core/UsdSceneRenderer.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include @@ -496,6 +497,8 @@ bool UsdSceneRenderer::PickObjectsInRect( void UsdSceneRenderer::Render(int width, int height) { if (!m_stage || width <= 0 || height <= 0) return; + m_lastRenderWidth = width; + m_lastRenderHeight = height; InitRenderer(); if (!m_renderer) return; @@ -676,6 +679,40 @@ uint32_t UsdSceneRenderer::GetColorTextureID() return att ? static_cast(att->GetGlTextureName()) : 0; } +bool UsdSceneRenderer::CaptureFrame(std::vector& outRGBA) +{ + if (!m_drawTarget || m_lastRenderWidth <= 0 || m_lastRenderHeight <= 0) + return false; + + int w = m_lastRenderWidth; + int h = m_lastRenderHeight; + + // Resolve MSAA so we read from the non-multisampled colour attachment. + m_drawTarget->Resolve(); + + outRGBA.resize(static_cast(w) * h * 4); + + // Save/restore the read-framebuffer binding so we don't upset ImGui. + GLint prevFbo = 0; + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &prevFbo); + glBindFramebuffer(GL_READ_FRAMEBUFFER, m_drawTarget->GetFramebufferId()); + glReadPixels(0, 0, w, h, GL_RGBA, GL_UNSIGNED_BYTE, outRGBA.data()); + glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(prevFbo)); + + // OpenGL reads bottom-up; flip to top-down. + int stride = w * 4; + std::vector row(stride); + for (int y = 0; y < h / 2; ++y) { + uint8_t* top = outRGBA.data() + y * stride; + uint8_t* bot = outRGBA.data() + (h - 1 - y) * stride; + std::copy(top, top + stride, row.data()); + std::copy(bot, bot + stride, top); + std::copy(row.data(), row.data() + stride, bot); + } + + return true; +} + // =========================================================================== // Axis Overlay (stageView.DrawAxis port) // =========================================================================== diff --git a/src/core/UsdSceneRenderer.h b/src/core/UsdSceneRenderer.h index ba41cbb..9ad6179 100644 --- a/src/core/UsdSceneRenderer.h +++ b/src/core/UsdSceneRenderer.h @@ -212,10 +212,20 @@ public: // ----------------------------------------------------------------------- uint32_t GetColorTextureID(); + /// Dimensions of the most recent Render() call. + int GetLastRenderWidth() const { return m_lastRenderWidth; } + int GetLastRenderHeight() const { return m_lastRenderHeight; } + + /// Read the last rendered frame into top-down RGBA8 pixels. + /// Resolves MSAA before reading. Returns false if the draw target is not ready. + bool CaptureFrame(std::vector& outRGBA); + // For unit tests pxr::GlfDrawTargetRefPtr GetDrawTargetForTest() const { return m_drawTarget; } private: + int m_lastRenderWidth = 0; + int m_lastRenderHeight = 0; void InitRenderer(); void InitGridResources(); void RebuildGridVBO(); ///< (Re)build grid line geometry after up-axis or size change. diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp index 1eff908..c721bfb 100644 --- a/src/ui/Application.cpp +++ b/src/ui/Application.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#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(); @@ -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 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 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; diff --git a/src/ui/Application.h b/src/ui/Application.h index ebc2a2f..631fa54 100644 --- a/src/ui/Application.h +++ b/src/ui/Application.h @@ -11,6 +11,7 @@ #include "../core/LayerManager.h" #include "../core/PropertyManager.h" #include "../core/CommandHistory.h" +#include "../utils/MovieEncoder.h" #include #include @@ -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 m_imguiContext; std::unique_ptr m_iconManager; @@ -63,6 +67,8 @@ private: bool m_showPropertyPanel; bool m_showTimeline; bool m_running; + + MovieEncoder m_movieEncoder; }; } // namespace UsdLayerManager diff --git a/src/ui/TimelinePanel.cpp b/src/ui/TimelinePanel.cpp index 5e7ba4c..ea6871b 100644 --- a/src/ui/TimelinePanel.cpp +++ b/src/ui/TimelinePanel.cpp @@ -5,6 +5,7 @@ #include #include +#include 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: \\.NNNN.bmp%s", + m_playblastSettings.exportMovie ? " → .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 diff --git a/src/ui/TimelinePanel.h b/src/ui/TimelinePanel.h index 8b0a8d4..87f7fa9 100644 --- a/src/ui/TimelinePanel.h +++ b/src/ui/TimelinePanel.h @@ -30,6 +30,35 @@ public: std::function 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 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 diff --git a/src/utils/FileDialog.cpp b/src/utils/FileDialog.cpp index ddb441b..2280113 100644 --- a/src/utils/FileDialog.cpp +++ b/src/utils/FileDialog.cpp @@ -1,6 +1,7 @@ #include "FileDialog.h" #include "Logger.h" #include +#include #include namespace UsdLayerManager { @@ -56,4 +57,39 @@ std::string FileDialog::SaveFile(const char* filter, const char* title, const ch return ""; } +std::string FileDialog::BrowseFolder(const char* title, HWND owner) { + IFileOpenDialog* pfd = nullptr; + if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, + CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pfd)))) + return ""; + + DWORD opts = 0; + pfd->GetOptions(&opts); + pfd->SetOptions(opts | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM); + + if (title) { + int n = MultiByteToWideChar(CP_UTF8, 0, title, -1, nullptr, 0); + std::vector wt(n); + MultiByteToWideChar(CP_UTF8, 0, title, -1, wt.data(), n); + pfd->SetTitle(wt.data()); + } + + std::string result; + if (SUCCEEDED(pfd->Show(owner))) { + IShellItem* psi = nullptr; + if (SUCCEEDED(pfd->GetResult(&psi))) { + PWSTR path = nullptr; + if (SUCCEEDED(psi->GetDisplayName(SIGDN_FILESYSPATH, &path)) && path) { + int n = WideCharToMultiByte(CP_UTF8, 0, path, -1, nullptr, 0, nullptr, nullptr); + result.resize(static_cast(n) - 1); + WideCharToMultiByte(CP_UTF8, 0, path, -1, result.data(), n, nullptr, nullptr); + CoTaskMemFree(path); + } + psi->Release(); + } + } + pfd->Release(); + return result; +} + } // namespace UsdLayerManager diff --git a/src/utils/FileDialog.h b/src/utils/FileDialog.h index 944dfae..7dfc497 100644 --- a/src/utils/FileDialog.h +++ b/src/utils/FileDialog.h @@ -21,6 +21,12 @@ public: const char* defaultExt = "usd", HWND owner = nullptr ); + + // Folder picker (Vista-style IFileOpenDialog) + static std::string BrowseFolder( + const char* title = "Select Output Folder", + HWND owner = nullptr + ); private: static const int MAX_PATH_LENGTH = 4096; diff --git a/src/utils/MovieEncoder.cpp b/src/utils/MovieEncoder.cpp new file mode 100644 index 0000000..06cb267 --- /dev/null +++ b/src/utils/MovieEncoder.cpp @@ -0,0 +1,236 @@ +#include "MovieEncoder.h" +#include "Logger.h" + +extern "C" { +#include +#include +#include +#include +#include +} + +#include + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// Impl holds all libav state. +// --------------------------------------------------------------------------- +struct MovieEncoder::Impl { + AVFormatContext* fmtCtx = nullptr; + AVCodecContext* encCtx = nullptr; + AVStream* stream = nullptr; + SwsContext* sws = nullptr; + AVFrame* frame = nullptr; + AVPacket* pkt = nullptr; + int64_t pts = 0; + int width = 0; + int height = 0; +}; + +// --------------------------------------------------------------------------- +MovieEncoder::MovieEncoder() = default; +MovieEncoder::~MovieEncoder() { Close(); Reset(); } + +// --------------------------------------------------------------------------- +static std::string AvError(int code) { + char buf[128] = {}; + av_strerror(code, buf, sizeof(buf)); + return buf; +} + +// --------------------------------------------------------------------------- +bool MovieEncoder::Open(const char* outputPath, int width, int height, double fps) { + if (m_open) Close(); + Reset(); + + m_impl = new Impl(); + m_impl->width = width; + m_impl->height = height; + + // ── Format context ──────────────────────────────────────────────────── + int ret = avformat_alloc_output_context2(&m_impl->fmtCtx, + nullptr, nullptr, outputPath); + if (ret < 0 || !m_impl->fmtCtx) { + m_error = "avformat_alloc_output_context2: " + AvError(ret); + Reset(); return false; + } + + // ── Find encoder (prefer libx264, fall back to hardware or generic) ─── + const char* encoderOrder[] = { + "libx264", "h264_nvenc", "h264_qsv", "h264_amf", "libopenh264", nullptr + }; + const AVCodec* codec = nullptr; + for (int i = 0; encoderOrder[i]; ++i) { + codec = avcodec_find_encoder_by_name(encoderOrder[i]); + if (codec) { LOG_INFO(std::string("MovieEncoder: using ") + codec->name); break; } + } + if (!codec) codec = avcodec_find_encoder(AV_CODEC_ID_H264); + if (!codec) { + m_error = "No H.264 encoder found (install ffmpeg GPL or NVIDIA/Intel drivers)"; + Reset(); return false; + } + + // ── Stream ──────────────────────────────────────────────────────────── + m_impl->stream = avformat_new_stream(m_impl->fmtCtx, nullptr); + if (!m_impl->stream) { + m_error = "avformat_new_stream failed"; + Reset(); return false; + } + m_impl->stream->id = 0; + + // ── Codec context ───────────────────────────────────────────────────── + m_impl->encCtx = avcodec_alloc_context3(codec); + if (!m_impl->encCtx) { + m_error = "avcodec_alloc_context3 failed"; + Reset(); return false; + } + + AVRational fpsRat = av_d2q(fps, 65536); + m_impl->encCtx->width = width; + m_impl->encCtx->height = height; + m_impl->encCtx->pix_fmt = AV_PIX_FMT_YUV420P; + m_impl->encCtx->framerate = fpsRat; + m_impl->encCtx->time_base = av_inv_q(fpsRat); // {1, fps_num} + // Reasonable quality defaults for CRF-capable encoders. + av_opt_set(m_impl->encCtx->priv_data, "crf", "23", AV_OPT_SEARCH_CHILDREN); + av_opt_set(m_impl->encCtx->priv_data, "preset", "fast", AV_OPT_SEARCH_CHILDREN); + // Ensure broad playback compatibility. + av_opt_set(m_impl->encCtx->priv_data, "profile", "high", AV_OPT_SEARCH_CHILDREN); + av_opt_set(m_impl->encCtx->priv_data, "level", "4.1", AV_OPT_SEARCH_CHILDREN); + + if (m_impl->fmtCtx->oformat->flags & AVFMT_GLOBALHEADER) + m_impl->encCtx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; + + ret = avcodec_open2(m_impl->encCtx, codec, nullptr); + if (ret < 0) { + m_error = "avcodec_open2: " + AvError(ret); + Reset(); return false; + } + + ret = avcodec_parameters_from_context(m_impl->stream->codecpar, m_impl->encCtx); + if (ret < 0) { + m_error = "avcodec_parameters_from_context: " + AvError(ret); + Reset(); return false; + } + m_impl->stream->time_base = m_impl->encCtx->time_base; + + // ── Open output file ────────────────────────────────────────────────── + if (!(m_impl->fmtCtx->oformat->flags & AVFMT_NOFILE)) { + ret = avio_open(&m_impl->fmtCtx->pb, outputPath, AVIO_FLAG_WRITE); + if (ret < 0) { + m_error = std::string("avio_open(") + outputPath + "): " + AvError(ret); + Reset(); return false; + } + } + + ret = avformat_write_header(m_impl->fmtCtx, nullptr); + if (ret < 0) { + m_error = "avformat_write_header: " + AvError(ret); + Reset(); return false; + } + + // ── Frame + packet buffers ──────────────────────────────────────────── + m_impl->frame = av_frame_alloc(); + if (!m_impl->frame) { + m_error = "av_frame_alloc failed"; + Reset(); return false; + } + m_impl->frame->width = width; + m_impl->frame->height = height; + m_impl->frame->format = AV_PIX_FMT_YUV420P; + ret = av_frame_get_buffer(m_impl->frame, 0); + if (ret < 0) { + m_error = "av_frame_get_buffer: " + AvError(ret); + Reset(); return false; + } + + m_impl->pkt = av_packet_alloc(); + if (!m_impl->pkt) { + m_error = "av_packet_alloc failed"; + Reset(); return false; + } + + // ── SwsContext: RGBA → YUV420P ──────────────────────────────────────── + m_impl->sws = sws_getContext( + width, height, AV_PIX_FMT_RGBA, + width, height, AV_PIX_FMT_YUV420P, + SWS_BILINEAR, nullptr, nullptr, nullptr); + if (!m_impl->sws) { + m_error = "sws_getContext failed"; + Reset(); return false; + } + + m_impl->pts = 0; + m_open = true; + return true; +} + +// --------------------------------------------------------------------------- +bool MovieEncoder::WriteFrame(const uint8_t* rgba) { + if (!m_open || !m_impl) { m_error = "encoder not open"; return false; } + + int ret = av_frame_make_writable(m_impl->frame); + if (ret < 0) { m_error = "av_frame_make_writable: " + AvError(ret); return false; } + + const uint8_t* srcSlice[1] = { rgba }; + int srcStride[1] = { m_impl->width * 4 }; + sws_scale(m_impl->sws, + srcSlice, srcStride, 0, m_impl->height, + m_impl->frame->data, m_impl->frame->linesize); + + m_impl->frame->pts = m_impl->pts++; + + ret = avcodec_send_frame(m_impl->encCtx, m_impl->frame); + if (ret < 0) { m_error = "avcodec_send_frame: " + AvError(ret); return false; } + + return DrainPackets(); +} + +// --------------------------------------------------------------------------- +bool MovieEncoder::Close() { + if (!m_open || !m_impl) return true; + + // Flush + avcodec_send_frame(m_impl->encCtx, nullptr); + DrainPackets(); + + av_write_trailer(m_impl->fmtCtx); + + if (m_impl->fmtCtx && !(m_impl->fmtCtx->oformat->flags & AVFMT_NOFILE)) + avio_closep(&m_impl->fmtCtx->pb); + + Reset(); + m_open = false; + return true; +} + +// --------------------------------------------------------------------------- +bool MovieEncoder::DrainPackets() { + int ret = 0; + while ((ret = avcodec_receive_packet(m_impl->encCtx, m_impl->pkt)) == 0) { + av_packet_rescale_ts(m_impl->pkt, + m_impl->encCtx->time_base, + m_impl->stream->time_base); + m_impl->pkt->stream_index = m_impl->stream->index; + ret = av_interleaved_write_frame(m_impl->fmtCtx, m_impl->pkt); + av_packet_unref(m_impl->pkt); + if (ret < 0) { m_error = "av_interleaved_write_frame: " + AvError(ret); return false; } + } + return ret == AVERROR(EAGAIN) || ret == AVERROR_EOF; +} + +// --------------------------------------------------------------------------- +void MovieEncoder::Reset() { + if (!m_impl) return; + if (m_impl->sws) { sws_freeContext(m_impl->sws); m_impl->sws = nullptr; } + if (m_impl->pkt) { av_packet_free(&m_impl->pkt); m_impl->pkt = nullptr; } + if (m_impl->frame) { av_frame_free(&m_impl->frame); m_impl->frame = nullptr; } + if (m_impl->encCtx) { avcodec_free_context(&m_impl->encCtx); m_impl->encCtx = nullptr; } + if (m_impl->fmtCtx) { avformat_free_context(m_impl->fmtCtx); m_impl->fmtCtx = nullptr; } + delete m_impl; + m_impl = nullptr; + m_open = false; +} + +} // namespace UsdLayerManager diff --git a/src/utils/MovieEncoder.h b/src/utils/MovieEncoder.h new file mode 100644 index 0000000..4711855 --- /dev/null +++ b/src/utils/MovieEncoder.h @@ -0,0 +1,40 @@ +#pragma once +#include +#include + +namespace UsdLayerManager { + +/// Streams RGBA frames directly into an H.264/MP4 file using libavcodec. +/// Usage: +/// MovieEncoder enc; +/// enc.Open("out.mp4", 1920, 1080, 24.0); +/// for each frame: enc.WriteFrame(rgbaPixels); // top-to-bottom, 4 bytes/px +/// enc.Close(); +class MovieEncoder { +public: + MovieEncoder(); + ~MovieEncoder(); + + // Non-copyable + MovieEncoder(const MovieEncoder&) = delete; + MovieEncoder& operator=(const MovieEncoder&) = delete; + + bool Open(const char* outputPath, int width, int height, double fps); + bool WriteFrame(const uint8_t* rgba); + bool Close(); // flush + write trailer; resets state for reuse + + bool IsOpen() const { return m_open; } + const std::string& GetLastError() const { return m_error; } + +private: + bool DrainPackets(); + void Reset(); + + struct Impl; + Impl* m_impl = nullptr; + + bool m_open = false; + std::string m_error; +}; + +} // namespace UsdLayerManager