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:
@@ -14,6 +14,7 @@ find_package(OpenGL REQUIRED)
|
|||||||
find_package(OpenUSD REQUIRED)
|
find_package(OpenUSD REQUIRED)
|
||||||
find_package(Imgui REQUIRED)
|
find_package(Imgui REQUIRED)
|
||||||
find_package(Glad REQUIRED)
|
find_package(Glad REQUIRED)
|
||||||
|
find_package(FFmpeg REQUIRED)
|
||||||
|
|
||||||
# USD build that includes hdEmbree (built with PXR_ENABLE_EMBREE_PLUGIN=ON).
|
# 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.
|
# 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
|
OpenUSD::OpenUSD
|
||||||
Imgui::Imgui
|
Imgui::Imgui
|
||||||
Glad::Glad
|
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
|
# Cycles is an ExternalProject; UsdLayerManager must wait for it to finish
|
||||||
@@ -194,6 +202,17 @@ if(WIN32)
|
|||||||
_CRT_SECURE_NO_WARNINGS
|
_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}
|
||||||
|
"$<TARGET_FILE_DIR:UsdLayerManager>"
|
||||||
|
COMMENT "Copying FFmpeg runtime DLLs..."
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Copy runtime DLLs to output directory for each configuration
|
# Copy runtime DLLs to output directory for each configuration
|
||||||
add_custom_command(TARGET UsdLayerManager POST_BUILD
|
add_custom_command(TARGET UsdLayerManager POST_BUILD
|
||||||
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:UsdLayerManager>"
|
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:UsdLayerManager>"
|
||||||
@@ -504,11 +523,13 @@ add_executable(ViewportDisplayTest
|
|||||||
target_include_directories(ViewportDisplayTest PRIVATE
|
target_include_directories(ViewportDisplayTest PRIVATE
|
||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
${CMAKE_SOURCE_DIR}/include
|
${CMAKE_SOURCE_DIR}/include
|
||||||
|
"${CMAKE_SOURCE_DIR}/third_party/ffmpeg/include"
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(ViewportDisplayTest PRIVATE
|
target_link_libraries(ViewportDisplayTest PRIVATE
|
||||||
OpenUSD::OpenUSD
|
OpenUSD::OpenUSD
|
||||||
Glad::Glad
|
Glad::Glad
|
||||||
|
FFmpeg::FFmpeg
|
||||||
)
|
)
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
@@ -550,11 +571,13 @@ add_executable(RendererDiagnosticTest
|
|||||||
target_include_directories(RendererDiagnosticTest PRIVATE
|
target_include_directories(RendererDiagnosticTest PRIVATE
|
||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
${CMAKE_SOURCE_DIR}/include
|
${CMAKE_SOURCE_DIR}/include
|
||||||
|
"${CMAKE_SOURCE_DIR}/third_party/ffmpeg/include"
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(RendererDiagnosticTest PRIVATE
|
target_link_libraries(RendererDiagnosticTest PRIVATE
|
||||||
OpenUSD::OpenUSD
|
OpenUSD::OpenUSD
|
||||||
Glad::Glad
|
Glad::Glad
|
||||||
|
FFmpeg::FFmpeg
|
||||||
)
|
)
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
#include <pxr/base/gf/frustum.h>
|
#include <pxr/base/gf/frustum.h>
|
||||||
#include <pxr/base/gf/rotation.h>
|
#include <pxr/base/gf/rotation.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
@@ -496,6 +497,8 @@ bool UsdSceneRenderer::PickObjectsInRect(
|
|||||||
void UsdSceneRenderer::Render(int width, int height)
|
void UsdSceneRenderer::Render(int width, int height)
|
||||||
{
|
{
|
||||||
if (!m_stage || width <= 0 || height <= 0) return;
|
if (!m_stage || width <= 0 || height <= 0) return;
|
||||||
|
m_lastRenderWidth = width;
|
||||||
|
m_lastRenderHeight = height;
|
||||||
|
|
||||||
InitRenderer();
|
InitRenderer();
|
||||||
if (!m_renderer) return;
|
if (!m_renderer) return;
|
||||||
@@ -676,6 +679,40 @@ uint32_t UsdSceneRenderer::GetColorTextureID()
|
|||||||
return att ? static_cast<uint32_t>(att->GetGlTextureName()) : 0;
|
return att ? static_cast<uint32_t>(att->GetGlTextureName()) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool UsdSceneRenderer::CaptureFrame(std::vector<uint8_t>& 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<size_t>(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<GLuint>(prevFbo));
|
||||||
|
|
||||||
|
// OpenGL reads bottom-up; flip to top-down.
|
||||||
|
int stride = w * 4;
|
||||||
|
std::vector<uint8_t> 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)
|
// Axis Overlay (stageView.DrawAxis port)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
@@ -212,10 +212,20 @@ public:
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
uint32_t GetColorTextureID();
|
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<uint8_t>& outRGBA);
|
||||||
|
|
||||||
// For unit tests
|
// For unit tests
|
||||||
pxr::GlfDrawTargetRefPtr GetDrawTargetForTest() const { return m_drawTarget; }
|
pxr::GlfDrawTargetRefPtr GetDrawTargetForTest() const { return m_drawTarget; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
int m_lastRenderWidth = 0;
|
||||||
|
int m_lastRenderHeight = 0;
|
||||||
void InitRenderer();
|
void InitRenderer();
|
||||||
void InitGridResources();
|
void InitGridResources();
|
||||||
void RebuildGridVBO(); ///< (Re)build grid line geometry after up-axis or size change.
|
void RebuildGridVBO(); ///< (Re)build grid line geometry after up-axis or size change.
|
||||||
|
|||||||
+135
-2
@@ -13,6 +13,8 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cstring>
|
||||||
|
#include "../utils/MovieEncoder.h"
|
||||||
|
|
||||||
namespace UsdLayerManager {
|
namespace UsdLayerManager {
|
||||||
|
|
||||||
@@ -82,6 +84,10 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig
|
|||||||
m_viewportPanel->SetTimeCodes(displayTime, editTime);
|
m_viewportPanel->SetTimeCodes(displayTime, editTime);
|
||||||
m_propertyPanel->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).
|
// Initialize IconManager — must happen after OpenGL context is ready (ImGui init above).
|
||||||
m_iconManager = std::make_unique<IconManager>();
|
m_iconManager = std::make_unique<IconManager>();
|
||||||
@@ -249,6 +255,17 @@ void Application::RenderUI() {
|
|||||||
ImGui::End();
|
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();
|
m_imguiContext->Render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,11 +286,10 @@ void Application::RenderStatusBar() {
|
|||||||
if (open) {
|
if (open) {
|
||||||
ImGui::BeginMenuBar();
|
ImGui::BeginMenuBar();
|
||||||
|
|
||||||
// FPS
|
// FPS — right-aligned
|
||||||
ImGuiIO& io = ImGui::GetIO();
|
ImGuiIO& io = ImGui::GetIO();
|
||||||
char buf[32];
|
char buf[32];
|
||||||
snprintf(buf, sizeof(buf), "FPS: %.1f", io.Framerate);
|
snprintf(buf, sizeof(buf), "FPS: %.1f", io.Framerate);
|
||||||
// Right-align: measure text, then position cursor.
|
|
||||||
float textW = ImGui::CalcTextSize(buf).x;
|
float textW = ImGui::CalcTextSize(buf).x;
|
||||||
float avail = ImGui::GetContentRegionAvail().x;
|
float avail = ImGui::GetContentRegionAvail().x;
|
||||||
if (avail > textW)
|
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) {
|
void Application::CreatePrimOnStage(const std::string& typeName) {
|
||||||
if (!m_stageManager->HasStage()) return;
|
if (!m_stageManager->HasStage()) return;
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "../core/LayerManager.h"
|
#include "../core/LayerManager.h"
|
||||||
#include "../core/PropertyManager.h"
|
#include "../core/PropertyManager.h"
|
||||||
#include "../core/CommandHistory.h"
|
#include "../core/CommandHistory.h"
|
||||||
|
#include "../utils/MovieEncoder.h"
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -44,6 +45,9 @@ private:
|
|||||||
void AddReferenceToStage();
|
void AddReferenceToStage();
|
||||||
void CreatePrimOnStage(const std::string& typeName);
|
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<ImGuiContext> m_imguiContext;
|
||||||
std::unique_ptr<IconManager> m_iconManager;
|
std::unique_ptr<IconManager> m_iconManager;
|
||||||
std::unique_ptr<UsdStageManager> m_stageManager;
|
std::unique_ptr<UsdStageManager> m_stageManager;
|
||||||
@@ -63,6 +67,8 @@ private:
|
|||||||
bool m_showPropertyPanel;
|
bool m_showPropertyPanel;
|
||||||
bool m_showTimeline;
|
bool m_showTimeline;
|
||||||
bool m_running;
|
bool m_running;
|
||||||
|
|
||||||
|
MovieEncoder m_movieEncoder;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace UsdLayerManager
|
} // namespace UsdLayerManager
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
namespace UsdLayerManager {
|
namespace UsdLayerManager {
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ void TimelinePanel::SetStage(pxr::UsdStageRefPtr stage)
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
void TimelinePanel::Update(float deltaTime)
|
void TimelinePanel::Update(float deltaTime)
|
||||||
{
|
{
|
||||||
|
if (m_isPlayblasting) return; // time is stepped externally during capture
|
||||||
if (!m_playing) return;
|
if (!m_playing) return;
|
||||||
|
|
||||||
double playStart = m_startFrame;
|
double playStart = m_startFrame;
|
||||||
@@ -204,6 +206,117 @@ void TimelinePanel::Render()
|
|||||||
ImGui::Text("%.6g fps", m_fps);
|
ImGui::Text("%.6g fps", m_fps);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ImGui::Checkbox("Auto-Key", &m_autoKey)) NotifyTimeChanged();
|
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] ----
|
// ---- Slider row: [start] [----slider----] [end] ----
|
||||||
{
|
{
|
||||||
@@ -268,4 +381,23 @@ pxr::UsdTimeCode TimelinePanel::EditTime() const
|
|||||||
: pxr::UsdTimeCode::Default();
|
: 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
|
} // namespace UsdLayerManager
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ public:
|
|||||||
std::function<void(pxr::UsdTimeCode displayTime,
|
std::function<void(pxr::UsdTimeCode displayTime,
|
||||||
pxr::UsdTimeCode editTime)> OnTimeChanged;
|
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:
|
private:
|
||||||
/// Clamp to [startFrame, endFrame]; fires OnTimeChanged when the frame changes.
|
/// Clamp to [startFrame, endFrame]; fires OnTimeChanged when the frame changes.
|
||||||
void SetCurrentFrame(double frame);
|
void SetCurrentFrame(double frame);
|
||||||
@@ -54,6 +83,15 @@ private:
|
|||||||
|
|
||||||
// Auto-Key.
|
// Auto-Key.
|
||||||
bool m_autoKey = false;
|
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
|
} // namespace UsdLayerManager
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "FileDialog.h"
|
#include "FileDialog.h"
|
||||||
#include "Logger.h"
|
#include "Logger.h"
|
||||||
#include <commdlg.h>
|
#include <commdlg.h>
|
||||||
|
#include <shobjidl.h>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace UsdLayerManager {
|
namespace UsdLayerManager {
|
||||||
@@ -56,4 +57,39 @@ std::string FileDialog::SaveFile(const char* filter, const char* title, const ch
|
|||||||
return "";
|
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<wchar_t> 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<size_t>(n) - 1);
|
||||||
|
WideCharToMultiByte(CP_UTF8, 0, path, -1, result.data(), n, nullptr, nullptr);
|
||||||
|
CoTaskMemFree(path);
|
||||||
|
}
|
||||||
|
psi->Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pfd->Release();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace UsdLayerManager
|
} // namespace UsdLayerManager
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public:
|
|||||||
HWND owner = nullptr
|
HWND owner = nullptr
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Folder picker (Vista-style IFileOpenDialog)
|
||||||
|
static std::string BrowseFolder(
|
||||||
|
const char* title = "Select Output Folder",
|
||||||
|
HWND owner = nullptr
|
||||||
|
);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static const int MAX_PATH_LENGTH = 4096;
|
static const int MAX_PATH_LENGTH = 4096;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
#include "MovieEncoder.h"
|
||||||
|
#include "Logger.h"
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
#include <libavcodec/avcodec.h>
|
||||||
|
#include <libavformat/avformat.h>
|
||||||
|
#include <libavutil/opt.h>
|
||||||
|
#include <libavutil/imgutils.h>
|
||||||
|
#include <libswscale/swscale.h>
|
||||||
|
}
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user