PNG writer
This commit is contained in:
@@ -67,6 +67,8 @@ set(APP_SOURCES
|
|||||||
"${APP_DIR}/gl/OpenGLRenderer.h"
|
"${APP_DIR}/gl/OpenGLRenderer.h"
|
||||||
"${APP_DIR}/gl/OpenGLShaderPrograms.cpp"
|
"${APP_DIR}/gl/OpenGLShaderPrograms.cpp"
|
||||||
"${APP_DIR}/gl/OpenGLShaderPrograms.h"
|
"${APP_DIR}/gl/OpenGLShaderPrograms.h"
|
||||||
|
"${APP_DIR}/gl/PngScreenshotWriter.cpp"
|
||||||
|
"${APP_DIR}/gl/PngScreenshotWriter.h"
|
||||||
"${APP_DIR}/gl/ShaderProgramCompiler.cpp"
|
"${APP_DIR}/gl/ShaderProgramCompiler.cpp"
|
||||||
"${APP_DIR}/gl/ShaderProgramCompiler.h"
|
"${APP_DIR}/gl/ShaderProgramCompiler.h"
|
||||||
"${APP_DIR}/gl/ShaderBuildQueue.cpp"
|
"${APP_DIR}/gl/ShaderBuildQueue.cpp"
|
||||||
@@ -123,6 +125,8 @@ target_link_libraries(LoopThroughWithOpenGLCompositing PRIVATE
|
|||||||
Crypt32
|
Crypt32
|
||||||
Advapi32
|
Advapi32
|
||||||
Gdiplus
|
Gdiplus
|
||||||
|
Ole32
|
||||||
|
Windowscodecs
|
||||||
)
|
)
|
||||||
|
|
||||||
target_compile_definitions(LoopThroughWithOpenGLCompositing PRIVATE
|
target_compile_definitions(LoopThroughWithOpenGLCompositing PRIVATE
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ http://127.0.0.1:<serverPort>/docs
|
|||||||
|
|
||||||
Use those docs to inspect the `/api/state`, layer control, stack preset, and reload endpoints. Live state updates are also sent over the `/ws` WebSocket.
|
Use those docs to inspect the `/api/state`, layer control, stack preset, and reload endpoints. Live state updates are also sent over the `/ws` WebSocket.
|
||||||
|
|
||||||
|
The control UI also has a Screenshot button. It queues a capture of the final output render target and writes a PNG under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
runtime/screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
## OSC Control
|
## OSC Control
|
||||||
|
|
||||||
The native host also listens for local OSC parameter control on the configured `oscPort`:
|
The native host also listens for local OSC parameter control on the configured `oscPort`:
|
||||||
|
|||||||
@@ -99,7 +99,7 @@
|
|||||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
<Link>
|
<Link>
|
||||||
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||||
<SubSystem>Windows</SubSystem>
|
<SubSystem>Windows</SubSystem>
|
||||||
<TargetMachine>MachineX86</TargetMachine>
|
<TargetMachine>MachineX86</TargetMachine>
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
<Link>
|
<Link>
|
||||||
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||||
<SubSystem>Windows</SubSystem>
|
<SubSystem>Windows</SubSystem>
|
||||||
<TargetMachine>MachineX64</TargetMachine>
|
<TargetMachine>MachineX64</TargetMachine>
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
<Link>
|
<Link>
|
||||||
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||||
<SubSystem>Windows</SubSystem>
|
<SubSystem>Windows</SubSystem>
|
||||||
<OptimizeReferences>true</OptimizeReferences>
|
<OptimizeReferences>true</OptimizeReferences>
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
<Link>
|
<Link>
|
||||||
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||||
<SubSystem>Windows</SubSystem>
|
<SubSystem>Windows</SubSystem>
|
||||||
<OptimizeReferences>true</OptimizeReferences>
|
<OptimizeReferences>true</OptimizeReferences>
|
||||||
@@ -181,6 +181,7 @@
|
|||||||
<ClCompile Include="gl\OpenGLRenderPass.cpp" />
|
<ClCompile Include="gl\OpenGLRenderPass.cpp" />
|
||||||
<ClCompile Include="gl\OpenGLRenderer.cpp" />
|
<ClCompile Include="gl\OpenGLRenderer.cpp" />
|
||||||
<ClCompile Include="gl\OpenGLShaderPrograms.cpp" />
|
<ClCompile Include="gl\OpenGLShaderPrograms.cpp" />
|
||||||
|
<ClCompile Include="gl\PngScreenshotWriter.cpp" />
|
||||||
<ClCompile Include="gl\ShaderBuildQueue.cpp" />
|
<ClCompile Include="gl\ShaderBuildQueue.cpp" />
|
||||||
<ClCompile Include="gl\TemporalHistoryBuffers.cpp" />
|
<ClCompile Include="gl\TemporalHistoryBuffers.cpp" />
|
||||||
<ClCompile Include="stdafx.cpp">
|
<ClCompile Include="stdafx.cpp">
|
||||||
@@ -202,6 +203,7 @@
|
|||||||
<ClInclude Include="gl\OpenGLRenderPass.h" />
|
<ClInclude Include="gl\OpenGLRenderPass.h" />
|
||||||
<ClInclude Include="gl\OpenGLRenderer.h" />
|
<ClInclude Include="gl\OpenGLRenderer.h" />
|
||||||
<ClInclude Include="gl\OpenGLShaderPrograms.h" />
|
<ClInclude Include="gl\OpenGLShaderPrograms.h" />
|
||||||
|
<ClInclude Include="gl\PngScreenshotWriter.h" />
|
||||||
<ClInclude Include="gl\ShaderBuildQueue.h" />
|
<ClInclude Include="gl\ShaderBuildQueue.h" />
|
||||||
<ClInclude Include="gl\TemporalHistoryBuffers.h" />
|
<ClInclude Include="gl\TemporalHistoryBuffers.h" />
|
||||||
<ClInclude Include="resource.h" />
|
<ClInclude Include="resource.h" />
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
<ClCompile Include="gl\OpenGLShaderPrograms.cpp">
|
<ClCompile Include="gl\OpenGLShaderPrograms.cpp">
|
||||||
<Filter>Source Files</Filter>
|
<Filter>Source Files</Filter>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
|
<ClCompile Include="gl\PngScreenshotWriter.cpp">
|
||||||
|
<Filter>Source Files</Filter>
|
||||||
|
</ClCompile>
|
||||||
<ClCompile Include="gl\ShaderBuildQueue.cpp">
|
<ClCompile Include="gl\ShaderBuildQueue.cpp">
|
||||||
<Filter>Source Files</Filter>
|
<Filter>Source Files</Filter>
|
||||||
</ClCompile>
|
</ClCompile>
|
||||||
@@ -80,6 +83,9 @@
|
|||||||
<ClInclude Include="gl\OpenGLShaderPrograms.h">
|
<ClInclude Include="gl\OpenGLShaderPrograms.h">
|
||||||
<Filter>Header Files</Filter>
|
<Filter>Header Files</Filter>
|
||||||
</ClInclude>
|
</ClInclude>
|
||||||
|
<ClInclude Include="gl\PngScreenshotWriter.h">
|
||||||
|
<Filter>Header Files</Filter>
|
||||||
|
</ClInclude>
|
||||||
<ClInclude Include="gl\ShaderBuildQueue.h">
|
<ClInclude Include="gl\ShaderBuildQueue.h">
|
||||||
<Filter>Header Files</Filter>
|
<Filter>Header Files</Filter>
|
||||||
</ClInclude>
|
</ClInclude>
|
||||||
|
|||||||
@@ -433,6 +433,11 @@ bool ControlServer::InvokePostRoute(const std::string& path, const JsonValue& ro
|
|||||||
{
|
{
|
||||||
return mCallbacks.reloadShader && mCallbacks.reloadShader(error);
|
return mCallbacks.reloadShader && mCallbacks.reloadShader(error);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{ "/api/screenshot", [this](const JsonValue&, std::string& error)
|
||||||
|
{
|
||||||
|
return mCallbacks.requestScreenshot && mCallbacks.requestScreenshot(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ public:
|
|||||||
std::function<bool(const std::string&, std::string&)> saveStackPreset;
|
std::function<bool(const std::string&, std::string&)> saveStackPreset;
|
||||||
std::function<bool(const std::string&, std::string&)> loadStackPreset;
|
std::function<bool(const std::string&, std::string&)> loadStackPreset;
|
||||||
std::function<bool(std::string&)> reloadShader;
|
std::function<bool(std::string&)> reloadShader;
|
||||||
|
std::function<bool(std::string&)> requestScreenshot;
|
||||||
};
|
};
|
||||||
|
|
||||||
ControlServer();
|
ControlServer();
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ bool StartRuntimeControlServices(
|
|||||||
callbacks.resetLayerParameters = [&composite](const std::string& layerId, std::string& actionError) { return composite.ResetLayerParameters(layerId, actionError); };
|
callbacks.resetLayerParameters = [&composite](const std::string& layerId, std::string& actionError) { return composite.ResetLayerParameters(layerId, actionError); };
|
||||||
callbacks.saveStackPreset = [&composite](const std::string& presetName, std::string& actionError) { return composite.SaveStackPreset(presetName, actionError); };
|
callbacks.saveStackPreset = [&composite](const std::string& presetName, std::string& actionError) { return composite.SaveStackPreset(presetName, actionError); };
|
||||||
callbacks.loadStackPreset = [&composite](const std::string& presetName, std::string& actionError) { return composite.LoadStackPreset(presetName, actionError); };
|
callbacks.loadStackPreset = [&composite](const std::string& presetName, std::string& actionError) { return composite.LoadStackPreset(presetName, actionError); };
|
||||||
|
callbacks.requestScreenshot = [&composite](std::string& actionError) { return composite.RequestScreenshot(actionError); };
|
||||||
callbacks.reloadShader = [&composite](std::string& actionError) {
|
callbacks.reloadShader = [&composite](std::string& actionError) {
|
||||||
if (!composite.ReloadShader())
|
if (!composite.ReloadShader())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,10 +6,17 @@
|
|||||||
#include "OpenGLDeckLinkBridge.h"
|
#include "OpenGLDeckLinkBridge.h"
|
||||||
#include "OpenGLRenderPass.h"
|
#include "OpenGLRenderPass.h"
|
||||||
#include "OpenGLShaderPrograms.h"
|
#include "OpenGLShaderPrograms.h"
|
||||||
|
#include "PngScreenshotWriter.h"
|
||||||
#include "RuntimeServices.h"
|
#include "RuntimeServices.h"
|
||||||
#include "ShaderBuildQueue.h"
|
#include "ShaderBuildQueue.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <ctime>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <iomanip>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -17,7 +24,8 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
|
|||||||
hGLWnd(hWnd), hGLDC(hDC), hGLRC(hRC),
|
hGLWnd(hWnd), hGLDC(hDC), hGLRC(hRC),
|
||||||
mDeckLink(std::make_unique<DeckLinkSession>()),
|
mDeckLink(std::make_unique<DeckLinkSession>()),
|
||||||
mRenderer(std::make_unique<OpenGLRenderer>()),
|
mRenderer(std::make_unique<OpenGLRenderer>()),
|
||||||
mUseCommittedLayerStates(false)
|
mUseCommittedLayerStates(false),
|
||||||
|
mScreenshotRequested(false)
|
||||||
{
|
{
|
||||||
InitializeCriticalSection(&pMutex);
|
InitializeCriticalSection(&pMutex);
|
||||||
mRuntimeHost = std::make_unique<RuntimeHost>();
|
mRuntimeHost = std::make_unique<RuntimeHost>();
|
||||||
@@ -29,6 +37,7 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
|
|||||||
hGLDC,
|
hGLDC,
|
||||||
hGLRC,
|
hGLRC,
|
||||||
[this]() { renderEffect(); },
|
[this]() { renderEffect(); },
|
||||||
|
[this]() { ProcessScreenshotRequest(); },
|
||||||
[this]() { paintGL(); });
|
[this]() { paintGL(); });
|
||||||
mRenderPass = std::make_unique<OpenGLRenderPass>(*mRenderer);
|
mRenderPass = std::make_unique<OpenGLRenderPass>(*mRenderer);
|
||||||
mShaderPrograms = std::make_unique<OpenGLShaderPrograms>(*mRenderer, *mRuntimeHost);
|
mShaderPrograms = std::make_unique<OpenGLShaderPrograms>(*mRenderer, *mRuntimeHost);
|
||||||
@@ -283,6 +292,13 @@ bool OpenGLComposite::ReloadShader()
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool OpenGLComposite::RequestScreenshot(std::string& error)
|
||||||
|
{
|
||||||
|
(void)error;
|
||||||
|
mScreenshotRequested.store(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void OpenGLComposite::renderEffect()
|
void OpenGLComposite::renderEffect()
|
||||||
{
|
{
|
||||||
ProcessRuntimePollResults();
|
ProcessRuntimePollResults();
|
||||||
@@ -324,6 +340,69 @@ void OpenGLComposite::renderEffect()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void OpenGLComposite::ProcessScreenshotRequest()
|
||||||
|
{
|
||||||
|
if (!mScreenshotRequested.exchange(false))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const unsigned width = mDeckLink ? mDeckLink->OutputFrameWidth() : 0;
|
||||||
|
const unsigned height = mDeckLink ? mDeckLink->OutputFrameHeight() : 0;
|
||||||
|
if (width == 0 || height == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<unsigned char> bottomUpPixels(static_cast<std::size_t>(width) * height * 4);
|
||||||
|
std::vector<unsigned char> topDownPixels(bottomUpPixels.size());
|
||||||
|
|
||||||
|
glBindFramebuffer(GL_READ_FRAMEBUFFER, mRenderer->OutputFramebuffer());
|
||||||
|
glReadBuffer(GL_COLOR_ATTACHMENT0);
|
||||||
|
glPixelStorei(GL_PACK_ALIGNMENT, 1);
|
||||||
|
glPixelStorei(GL_PACK_ROW_LENGTH, 0);
|
||||||
|
glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, bottomUpPixels.data());
|
||||||
|
glPixelStorei(GL_PACK_ALIGNMENT, 4);
|
||||||
|
|
||||||
|
const std::size_t rowBytes = static_cast<std::size_t>(width) * 4;
|
||||||
|
for (unsigned y = 0; y < height; ++y)
|
||||||
|
{
|
||||||
|
const unsigned sourceY = height - 1 - y;
|
||||||
|
std::copy(
|
||||||
|
bottomUpPixels.begin() + static_cast<std::ptrdiff_t>(sourceY * rowBytes),
|
||||||
|
bottomUpPixels.begin() + static_cast<std::ptrdiff_t>((sourceY + 1) * rowBytes),
|
||||||
|
topDownPixels.begin() + static_cast<std::ptrdiff_t>(y * rowBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const std::filesystem::path outputPath = BuildScreenshotPath();
|
||||||
|
std::filesystem::create_directories(outputPath.parent_path());
|
||||||
|
WritePngFileAsync(outputPath, width, height, std::move(topDownPixels));
|
||||||
|
}
|
||||||
|
catch (const std::exception& exception)
|
||||||
|
{
|
||||||
|
OutputDebugStringA((std::string("Screenshot request failed: ") + exception.what() + "\n").c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path OpenGLComposite::BuildScreenshotPath() const
|
||||||
|
{
|
||||||
|
const std::filesystem::path root = mRuntimeHost && !mRuntimeHost->GetRuntimeRoot().empty()
|
||||||
|
? mRuntimeHost->GetRuntimeRoot()
|
||||||
|
: std::filesystem::current_path();
|
||||||
|
|
||||||
|
const auto now = std::chrono::system_clock::now();
|
||||||
|
const auto milliseconds = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
|
||||||
|
const std::time_t nowTime = std::chrono::system_clock::to_time_t(now);
|
||||||
|
std::tm localTime = {};
|
||||||
|
localtime_s(&localTime, &nowTime);
|
||||||
|
|
||||||
|
std::ostringstream filename;
|
||||||
|
filename << "video-shader-toys-"
|
||||||
|
<< std::put_time(&localTime, "%Y%m%d-%H%M%S")
|
||||||
|
<< "-" << std::setw(3) << std::setfill('0') << milliseconds.count()
|
||||||
|
<< ".png";
|
||||||
|
|
||||||
|
return root / "screenshots" / filename.str();
|
||||||
|
}
|
||||||
|
|
||||||
bool OpenGLComposite::ProcessRuntimePollResults()
|
bool OpenGLComposite::ProcessRuntimePollResults()
|
||||||
{
|
{
|
||||||
if (!mRuntimeHost || !mRuntimeServices)
|
if (!mRuntimeHost || !mRuntimeServices)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <filesystem>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -54,6 +55,7 @@ public:
|
|||||||
bool ResetLayerParameters(const std::string& layerId, std::string& error);
|
bool ResetLayerParameters(const std::string& layerId, std::string& error);
|
||||||
bool SaveStackPreset(const std::string& presetName, std::string& error);
|
bool SaveStackPreset(const std::string& presetName, std::string& error);
|
||||||
bool LoadStackPreset(const std::string& presetName, std::string& error);
|
bool LoadStackPreset(const std::string& presetName, std::string& error);
|
||||||
|
bool RequestScreenshot(std::string& error);
|
||||||
unsigned short GetControlServerPort() const;
|
unsigned short GetControlServerPort() const;
|
||||||
unsigned short GetOscPort() const;
|
unsigned short GetOscPort() const;
|
||||||
std::string GetControlUrl() const;
|
std::string GetControlUrl() const;
|
||||||
@@ -87,11 +89,14 @@ private:
|
|||||||
std::unique_ptr<RuntimeServices> mRuntimeServices;
|
std::unique_ptr<RuntimeServices> mRuntimeServices;
|
||||||
std::vector<RuntimeRenderState> mCachedLayerRenderStates;
|
std::vector<RuntimeRenderState> mCachedLayerRenderStates;
|
||||||
std::atomic<bool> mUseCommittedLayerStates;
|
std::atomic<bool> mUseCommittedLayerStates;
|
||||||
|
std::atomic<bool> mScreenshotRequested;
|
||||||
|
|
||||||
bool InitOpenGLState();
|
bool InitOpenGLState();
|
||||||
void renderEffect();
|
void renderEffect();
|
||||||
bool ProcessRuntimePollResults();
|
bool ProcessRuntimePollResults();
|
||||||
void RequestShaderBuild();
|
void RequestShaderBuild();
|
||||||
|
void ProcessScreenshotRequest();
|
||||||
|
std::filesystem::path BuildScreenshotPath() const;
|
||||||
void broadcastRuntimeState();
|
void broadcastRuntimeState();
|
||||||
void resetTemporalHistoryState();
|
void resetTemporalHistoryState();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ OpenGLDeckLinkBridge::OpenGLDeckLinkBridge(
|
|||||||
HDC hdc,
|
HDC hdc,
|
||||||
HGLRC hglrc,
|
HGLRC hglrc,
|
||||||
RenderEffectCallback renderEffect,
|
RenderEffectCallback renderEffect,
|
||||||
|
OutputReadyCallback outputReady,
|
||||||
PaintCallback paint) :
|
PaintCallback paint) :
|
||||||
mDeckLink(deckLink),
|
mDeckLink(deckLink),
|
||||||
mRenderer(renderer),
|
mRenderer(renderer),
|
||||||
@@ -23,6 +24,7 @@ OpenGLDeckLinkBridge::OpenGLDeckLinkBridge(
|
|||||||
mHdc(hdc),
|
mHdc(hdc),
|
||||||
mHglrc(hglrc),
|
mHglrc(hglrc),
|
||||||
mRenderEffect(renderEffect),
|
mRenderEffect(renderEffect),
|
||||||
|
mOutputReady(outputReady),
|
||||||
mPaint(paint)
|
mPaint(paint)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,8 @@ void OpenGLDeckLinkBridge::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedF
|
|||||||
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, mRenderer.OutputFramebuffer());
|
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, mRenderer.OutputFramebuffer());
|
||||||
glBlitFramebuffer(0, 0, mDeckLink.InputFrameWidth(), mDeckLink.InputFrameHeight(), 0, 0, mDeckLink.OutputFrameWidth(), mDeckLink.OutputFrameHeight(), GL_COLOR_BUFFER_BIT, GL_LINEAR);
|
glBlitFramebuffer(0, 0, mDeckLink.InputFrameWidth(), mDeckLink.InputFrameHeight(), 0, 0, mDeckLink.OutputFrameWidth(), mDeckLink.OutputFrameHeight(), GL_COLOR_BUFFER_BIT, GL_LINEAR);
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputFramebuffer());
|
glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputFramebuffer());
|
||||||
|
if (mOutputReady)
|
||||||
|
mOutputReady();
|
||||||
if (mDeckLink.OutputIsTenBit())
|
if (mDeckLink.OutputIsTenBit())
|
||||||
{
|
{
|
||||||
glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputPackFramebuffer());
|
glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputPackFramebuffer());
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class OpenGLDeckLinkBridge
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
using RenderEffectCallback = std::function<void()>;
|
using RenderEffectCallback = std::function<void()>;
|
||||||
|
using OutputReadyCallback = std::function<void()>;
|
||||||
using PaintCallback = std::function<void()>;
|
using PaintCallback = std::function<void()>;
|
||||||
|
|
||||||
OpenGLDeckLinkBridge(
|
OpenGLDeckLinkBridge(
|
||||||
@@ -26,6 +27,7 @@ public:
|
|||||||
HDC hdc,
|
HDC hdc,
|
||||||
HGLRC hglrc,
|
HGLRC hglrc,
|
||||||
RenderEffectCallback renderEffect,
|
RenderEffectCallback renderEffect,
|
||||||
|
OutputReadyCallback outputReady,
|
||||||
PaintCallback paint);
|
PaintCallback paint);
|
||||||
|
|
||||||
void VideoFrameArrived(IDeckLinkVideoInputFrame* inputFrame, bool hasNoInputSource);
|
void VideoFrameArrived(IDeckLinkVideoInputFrame* inputFrame, bool hasNoInputSource);
|
||||||
@@ -41,6 +43,7 @@ private:
|
|||||||
HDC mHdc;
|
HDC mHdc;
|
||||||
HGLRC mHglrc;
|
HGLRC mHglrc;
|
||||||
RenderEffectCallback mRenderEffect;
|
RenderEffectCallback mRenderEffect;
|
||||||
|
OutputReadyCallback mOutputReady;
|
||||||
PaintCallback mPaint;
|
PaintCallback mPaint;
|
||||||
std::chrono::steady_clock::time_point mLastPlayoutCompletionTime;
|
std::chrono::steady_clock::time_point mLastPlayoutCompletionTime;
|
||||||
double mCompletionIntervalMilliseconds = 0.0;
|
double mCompletionIntervalMilliseconds = 0.0;
|
||||||
|
|||||||
133
apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.cpp
Normal file
133
apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.cpp
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#include "PngScreenshotWriter.h"
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <wincodec.h>
|
||||||
|
#include <atlbase.h>
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
std::string HResultToString(HRESULT hr)
|
||||||
|
{
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream << "HRESULT 0x" << std::hex << static_cast<unsigned long>(hr);
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool WritePngFile(
|
||||||
|
const std::filesystem::path& outputPath,
|
||||||
|
unsigned width,
|
||||||
|
unsigned height,
|
||||||
|
const std::vector<unsigned char>& rgbaPixels,
|
||||||
|
std::string& error)
|
||||||
|
{
|
||||||
|
if (width == 0 || height == 0 || rgbaPixels.size() < static_cast<std::size_t>(width) * height * 4)
|
||||||
|
{
|
||||||
|
error = "Invalid screenshot dimensions or pixel buffer.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT initializeResult = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
|
||||||
|
const bool shouldUninitialize = SUCCEEDED(initializeResult);
|
||||||
|
if (FAILED(initializeResult) && initializeResult != RPC_E_CHANGED_MODE)
|
||||||
|
{
|
||||||
|
error = "CoInitializeEx failed: " + HResultToString(initializeResult);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CComPtr<IWICImagingFactory> factory;
|
||||||
|
HRESULT result = CoCreateInstance(
|
||||||
|
CLSID_WICImagingFactory,
|
||||||
|
nullptr,
|
||||||
|
CLSCTX_INPROC_SERVER,
|
||||||
|
IID_PPV_ARGS(&factory));
|
||||||
|
if (FAILED(result))
|
||||||
|
{
|
||||||
|
error = "Could not create WIC imaging factory: " + HResultToString(result);
|
||||||
|
if (shouldUninitialize)
|
||||||
|
CoUninitialize();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CComPtr<IWICStream> stream;
|
||||||
|
result = factory->CreateStream(&stream);
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = stream->InitializeFromFilename(outputPath.wstring().c_str(), GENERIC_WRITE);
|
||||||
|
if (FAILED(result))
|
||||||
|
{
|
||||||
|
error = "Could not open screenshot output file: " + HResultToString(result);
|
||||||
|
if (shouldUninitialize)
|
||||||
|
CoUninitialize();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CComPtr<IWICBitmapEncoder> encoder;
|
||||||
|
result = factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder);
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = encoder->Initialize(stream, WICBitmapEncoderNoCache);
|
||||||
|
if (FAILED(result))
|
||||||
|
{
|
||||||
|
error = "Could not initialize PNG encoder: " + HResultToString(result);
|
||||||
|
if (shouldUninitialize)
|
||||||
|
CoUninitialize();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
CComPtr<IWICBitmapFrameEncode> frame;
|
||||||
|
CComPtr<IPropertyBag2> propertyBag;
|
||||||
|
result = encoder->CreateNewFrame(&frame, &propertyBag);
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = frame->Initialize(propertyBag);
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = frame->SetSize(width, height);
|
||||||
|
|
||||||
|
WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat32bppRGBA;
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = frame->SetPixelFormat(&pixelFormat);
|
||||||
|
if (SUCCEEDED(result) && pixelFormat != GUID_WICPixelFormat32bppRGBA)
|
||||||
|
{
|
||||||
|
error = "PNG encoder did not accept RGBA pixel format.";
|
||||||
|
result = E_FAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UINT stride = width * 4;
|
||||||
|
const UINT imageSize = stride * height;
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = frame->WritePixels(height, stride, imageSize, const_cast<BYTE*>(rgbaPixels.data()));
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = frame->Commit();
|
||||||
|
if (SUCCEEDED(result))
|
||||||
|
result = encoder->Commit();
|
||||||
|
|
||||||
|
if (shouldUninitialize)
|
||||||
|
CoUninitialize();
|
||||||
|
|
||||||
|
if (FAILED(result))
|
||||||
|
{
|
||||||
|
error = "Could not write screenshot PNG: " + HResultToString(result);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void WritePngFileAsync(
|
||||||
|
const std::filesystem::path& outputPath,
|
||||||
|
unsigned width,
|
||||||
|
unsigned height,
|
||||||
|
std::vector<unsigned char> rgbaPixels)
|
||||||
|
{
|
||||||
|
std::thread(
|
||||||
|
[outputPath, width, height, pixels = std::move(rgbaPixels)]() mutable
|
||||||
|
{
|
||||||
|
std::string error;
|
||||||
|
if (!WritePngFile(outputPath, width, height, pixels, error))
|
||||||
|
OutputDebugStringA(("Screenshot write failed: " + error + "\n").c_str());
|
||||||
|
else
|
||||||
|
OutputDebugStringA(("Screenshot written: " + outputPath.string() + "\n").c_str());
|
||||||
|
}).detach();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
void WritePngFileAsync(
|
||||||
|
const std::filesystem::path& outputPath,
|
||||||
|
unsigned width,
|
||||||
|
unsigned height,
|
||||||
|
std::vector<unsigned char> rgbaPixels);
|
||||||
|
|
||||||
@@ -214,6 +214,24 @@ paths:
|
|||||||
$ref: "#/components/responses/ActionOk"
|
$ref: "#/components/responses/ActionOk"
|
||||||
"400":
|
"400":
|
||||||
$ref: "#/components/responses/ActionError"
|
$ref: "#/components/responses/ActionError"
|
||||||
|
/api/screenshot:
|
||||||
|
post:
|
||||||
|
tags: [Runtime]
|
||||||
|
summary: Queue a PNG screenshot of the final output render target
|
||||||
|
description: Captures the next completed output render target and writes it under `runtime/screenshots/`.
|
||||||
|
operationId: queueScreenshot
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
$ref: "#/components/responses/ActionOk"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/ActionError"
|
||||||
components:
|
components:
|
||||||
responses:
|
responses:
|
||||||
ActionOk:
|
ActionOk:
|
||||||
|
|||||||
90
shaders/fisheye-equirectangular-mirror/shader.json
Normal file
90
shaders/fisheye-equirectangular-mirror/shader.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"id": "fisheye-equirectangular-mirror",
|
||||||
|
"name": "Fisheye Equirectangular Mirror",
|
||||||
|
"description": "Unwraps a single fisheye lens into a 360x180 equirectangular map by mirroring the rear hemisphere into the same fisheye source.",
|
||||||
|
"category": "Projection",
|
||||||
|
"entryPoint": "shadeVideo",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"id": "lensFovDegrees",
|
||||||
|
"label": "Lens FOV",
|
||||||
|
"type": "float",
|
||||||
|
"default": 180.0,
|
||||||
|
"min": 1.0,
|
||||||
|
"max": 220.0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "center",
|
||||||
|
"label": "Optical Center",
|
||||||
|
"type": "vec2",
|
||||||
|
"default": [0.5, 0.5],
|
||||||
|
"min": [0.0, 0.0],
|
||||||
|
"max": [1.0, 1.0],
|
||||||
|
"step": [0.001, 0.001]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "radius",
|
||||||
|
"label": "Fisheye Radius",
|
||||||
|
"type": "vec2",
|
||||||
|
"default": [0.5, 0.5],
|
||||||
|
"min": [0.001, 0.001],
|
||||||
|
"max": [2.0, 2.0],
|
||||||
|
"step": [0.001, 0.001]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "yawDegrees",
|
||||||
|
"label": "Yaw",
|
||||||
|
"type": "float",
|
||||||
|
"default": 0.0,
|
||||||
|
"min": -180.0,
|
||||||
|
"max": 180.0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pitchDegrees",
|
||||||
|
"label": "Pitch",
|
||||||
|
"type": "float",
|
||||||
|
"default": 0.0,
|
||||||
|
"min": -120.0,
|
||||||
|
"max": 120.0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rollDegrees",
|
||||||
|
"label": "Roll",
|
||||||
|
"type": "float",
|
||||||
|
"default": 0.0,
|
||||||
|
"min": -180.0,
|
||||||
|
"max": 180.0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seamAngleDegrees",
|
||||||
|
"label": "Seam Angle",
|
||||||
|
"type": "float",
|
||||||
|
"default": 0.0,
|
||||||
|
"min": -180.0,
|
||||||
|
"max": 180.0,
|
||||||
|
"step": 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fisheyeModel",
|
||||||
|
"label": "Fisheye Model",
|
||||||
|
"type": "enum",
|
||||||
|
"default": "equidistant",
|
||||||
|
"options": [
|
||||||
|
{ "value": "equidistant", "label": "Equidistant" },
|
||||||
|
{ "value": "equisolid", "label": "Equisolid" },
|
||||||
|
{ "value": "stereographic", "label": "Stereographic" },
|
||||||
|
{ "value": "orthographic", "label": "Orthographic" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "outsideColor",
|
||||||
|
"label": "Outside Color",
|
||||||
|
"type": "color",
|
||||||
|
"default": [0.0, 0.0, 0.0, 1.0]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
93
shaders/fisheye-equirectangular-mirror/shader.slang
Normal file
93
shaders/fisheye-equirectangular-mirror/shader.slang
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
static const float PI = 3.14159265358979323846;
|
||||||
|
static const float TWO_PI = 6.28318530717958647692;
|
||||||
|
|
||||||
|
float radiansFromDegrees(float degrees)
|
||||||
|
{
|
||||||
|
return degrees * (PI / 180.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 rotateX(float3 ray, float angle)
|
||||||
|
{
|
||||||
|
float s = sin(angle);
|
||||||
|
float c = cos(angle);
|
||||||
|
return float3(ray.x, c * ray.y - s * ray.z, s * ray.y + c * ray.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 rotateY(float3 ray, float angle)
|
||||||
|
{
|
||||||
|
float s = sin(angle);
|
||||||
|
float c = cos(angle);
|
||||||
|
return float3(c * ray.x + s * ray.z, ray.y, -s * ray.x + c * ray.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 rotateZ(float3 ray, float angle)
|
||||||
|
{
|
||||||
|
float s = sin(angle);
|
||||||
|
float c = cos(angle);
|
||||||
|
return float3(c * ray.x - s * ray.y, s * ray.x + c * ray.y, ray.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
float normalizedFisheyeRadius(float theta, float halfFov)
|
||||||
|
{
|
||||||
|
float safeHalfFov = max(halfFov, 0.0001);
|
||||||
|
|
||||||
|
if (fisheyeModel == 1)
|
||||||
|
{
|
||||||
|
return sin(theta * 0.5) / max(sin(safeHalfFov * 0.5), 0.0001);
|
||||||
|
}
|
||||||
|
else if (fisheyeModel == 2)
|
||||||
|
{
|
||||||
|
return tan(theta * 0.5) / max(tan(safeHalfFov * 0.5), 0.0001);
|
||||||
|
}
|
||||||
|
else if (fisheyeModel == 3)
|
||||||
|
{
|
||||||
|
return sin(theta) / max(sin(safeHalfFov), 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
return theta / safeHalfFov;
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 equirectangularRay(float2 uv)
|
||||||
|
{
|
||||||
|
float longitude = (uv.x - 0.5) * TWO_PI + radiansFromDegrees(seamAngleDegrees);
|
||||||
|
float latitude = (0.5 - uv.y) * PI;
|
||||||
|
float latitudeCos = cos(latitude);
|
||||||
|
|
||||||
|
return normalize(float3(
|
||||||
|
sin(longitude) * latitudeCos,
|
||||||
|
sin(latitude),
|
||||||
|
cos(longitude) * latitudeCos
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
float4 shadeVideo(ShaderContext context)
|
||||||
|
{
|
||||||
|
float3 ray = equirectangularRay(context.uv);
|
||||||
|
|
||||||
|
ray = rotateZ(ray, radiansFromDegrees(rollDegrees));
|
||||||
|
ray = rotateX(ray, radiansFromDegrees(-pitchDegrees));
|
||||||
|
ray = rotateY(ray, radiansFromDegrees(yawDegrees));
|
||||||
|
|
||||||
|
// Mirror the rear hemisphere into the front-facing fisheye image so one
|
||||||
|
// circular lens source fills both halves of the equirectangular output.
|
||||||
|
ray.z = abs(ray.z);
|
||||||
|
ray = normalize(ray);
|
||||||
|
|
||||||
|
float halfFov = radiansFromDegrees(clamp(lensFovDegrees, 1.0, 220.0) * 0.5);
|
||||||
|
float theta = acos(clamp(ray.z, -1.0, 1.0));
|
||||||
|
if (theta > halfFov)
|
||||||
|
return outsideColor;
|
||||||
|
|
||||||
|
float phi = atan2(ray.y, ray.x);
|
||||||
|
float fisheyeRadius = normalizedFisheyeRadius(theta, halfFov);
|
||||||
|
|
||||||
|
float2 sourceUv = float2(
|
||||||
|
center.x + cos(phi) * fisheyeRadius * radius.x,
|
||||||
|
center.y - sin(phi) * fisheyeRadius * radius.y
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sourceUv.x < 0.0 || sourceUv.x > 1.0 || sourceUv.y < 0.0 || sourceUv.y > 1.0)
|
||||||
|
return outsideColor;
|
||||||
|
|
||||||
|
return sampleVideo(sourceUv);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { FolderOpen, RefreshCw, Save } from "lucide-react";
|
import { Camera, FolderOpen, RefreshCw, Save } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { postJson } from "../api/controlApi";
|
import { postJson } from "../api/controlApi";
|
||||||
|
|
||||||
@@ -9,6 +10,17 @@ export function StackPresetToolbar({
|
|||||||
onPresetNameChange,
|
onPresetNameChange,
|
||||||
onSelectedPresetNameChange,
|
onSelectedPresetNameChange,
|
||||||
}) {
|
}) {
|
||||||
|
const [screenshotQueued, setScreenshotQueued] = useState(false);
|
||||||
|
|
||||||
|
async function requestScreenshot() {
|
||||||
|
setScreenshotQueued(true);
|
||||||
|
try {
|
||||||
|
await postJson("/api/screenshot", {});
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => setScreenshotQueued(false), 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="panel stack-panel">
|
<div className="panel stack-panel">
|
||||||
<div className="panel__header stack-panel__header">
|
<div className="panel__header stack-panel__header">
|
||||||
@@ -16,14 +28,24 @@ export function StackPresetToolbar({
|
|||||||
<h3>Stack presets</h3>
|
<h3>Stack presets</h3>
|
||||||
<p className="muted">Save or recall the current layer chain.</p>
|
<p className="muted">Save or recall the current layer chain.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="stack-panel__actions">
|
||||||
type="button"
|
<button
|
||||||
className="button-with-icon stack-panel__reload"
|
type="button"
|
||||||
onClick={() => postJson("/api/reload", {})}
|
className="button-with-icon stack-panel__screenshot"
|
||||||
>
|
onClick={requestScreenshot}
|
||||||
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
>
|
||||||
<span>Reload shader</span>
|
<Camera size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||||
</button>
|
<span>{screenshotQueued ? "Queued" : "Screenshot"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-with-icon stack-panel__reload"
|
||||||
|
onClick={() => postJson("/api/reload", {})}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} strokeWidth={1.9} aria-hidden="true" />
|
||||||
|
<span>Reload shader</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stack-panel__grid">
|
<div className="stack-panel__grid">
|
||||||
|
|||||||
@@ -498,10 +498,21 @@ pre {
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stack-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.stack-panel__reload {
|
.stack-panel__reload {
|
||||||
min-width: 8.75rem;
|
min-width: 8.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stack-panel__screenshot {
|
||||||
|
min-width: 8.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar__group {
|
.toolbar__group {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user