From 05d0bcbedd7ab7a7b1491cc4f258f690ebacc7e5 Mon Sep 17 00:00:00 2001 From: Aiden Date: Fri, 8 May 2026 15:33:40 +1000 Subject: [PATCH] PNG writer --- CMakeLists.txt | 4 + README.md | 6 + .../LoopThroughWithOpenGLCompositing.vcxproj | 10 +- ...roughWithOpenGLCompositing.vcxproj.filters | 6 + .../control/ControlServer.cpp | 5 + .../control/ControlServer.h | 1 + .../control/RuntimeControlBridge.cpp | 1 + .../gl/OpenGLComposite.cpp | 81 ++++++++++- .../gl/OpenGLComposite.h | 5 + .../gl/OpenGLDeckLinkBridge.cpp | 4 + .../gl/OpenGLDeckLinkBridge.h | 3 + .../gl/PngScreenshotWriter.cpp | 133 ++++++++++++++++++ .../gl/PngScreenshotWriter.h | 12 ++ docs/openapi.yaml | 18 +++ .../shader.json | 90 ++++++++++++ .../shader.slang | 93 ++++++++++++ ui/src/components/StackPresetToolbar.jsx | 40 ++++-- ui/src/styles.css | 11 ++ 18 files changed, 509 insertions(+), 14 deletions(-) create mode 100644 apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.cpp create mode 100644 apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.h create mode 100644 shaders/fisheye-equirectangular-mirror/shader.json create mode 100644 shaders/fisheye-equirectangular-mirror/shader.slang diff --git a/CMakeLists.txt b/CMakeLists.txt index 0321055..057059d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,6 +67,8 @@ set(APP_SOURCES "${APP_DIR}/gl/OpenGLRenderer.h" "${APP_DIR}/gl/OpenGLShaderPrograms.cpp" "${APP_DIR}/gl/OpenGLShaderPrograms.h" + "${APP_DIR}/gl/PngScreenshotWriter.cpp" + "${APP_DIR}/gl/PngScreenshotWriter.h" "${APP_DIR}/gl/ShaderProgramCompiler.cpp" "${APP_DIR}/gl/ShaderProgramCompiler.h" "${APP_DIR}/gl/ShaderBuildQueue.cpp" @@ -123,6 +125,8 @@ target_link_libraries(LoopThroughWithOpenGLCompositing PRIVATE Crypt32 Advapi32 Gdiplus + Ole32 + Windowscodecs ) target_compile_definitions(LoopThroughWithOpenGLCompositing PRIVATE diff --git a/README.md b/README.md index 54557a8..510f6a2 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,12 @@ http://127.0.0.1:/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. +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 The native host also listens for local OSC parameter control on the configured `oscPort`: diff --git a/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj b/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj index cab98a5..6b6fabe 100644 --- a/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj +++ b/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj @@ -99,7 +99,7 @@ stdcpp17 - opengl32.lib;Glu32.lib;%(AdditionalDependencies) + opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies) true Windows MachineX86 @@ -121,7 +121,7 @@ stdcpp17 - opengl32.lib;Glu32.lib;%(AdditionalDependencies) + opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies) true Windows MachineX64 @@ -141,7 +141,7 @@ stdcpp17 - opengl32.lib;Glu32.lib;%(AdditionalDependencies) + opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies) true Windows true @@ -166,7 +166,7 @@ stdcpp17 - opengl32.lib;Glu32.lib;%(AdditionalDependencies) + opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies) true Windows true @@ -181,6 +181,7 @@ + @@ -202,6 +203,7 @@ + diff --git a/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj.filters b/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj.filters index 356889b..0d6bc94 100644 --- a/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj.filters +++ b/apps/LoopThroughWithOpenGLCompositing/LoopThroughWithOpenGLCompositing.vcxproj.filters @@ -36,6 +36,9 @@ Source Files + + Source Files + Source Files @@ -80,6 +83,9 @@ Header Files + + Header Files + Header Files diff --git a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp index 8ee8d71..2947310 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.cpp @@ -433,6 +433,11 @@ bool ControlServer::InvokePostRoute(const std::string& path, const JsonValue& ro { return mCallbacks.reloadShader && mCallbacks.reloadShader(error); } + }, + { "/api/screenshot", [this](const JsonValue&, std::string& error) + { + return mCallbacks.requestScreenshot && mCallbacks.requestScreenshot(error); + } } }; diff --git a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h index df7a19f..98bba25 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h +++ b/apps/LoopThroughWithOpenGLCompositing/control/ControlServer.h @@ -32,6 +32,7 @@ public: std::function saveStackPreset; std::function loadStackPreset; std::function reloadShader; + std::function requestScreenshot; }; ControlServer(); diff --git a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp index c314701..11764bd 100644 --- a/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/control/RuntimeControlBridge.cpp @@ -26,6 +26,7 @@ bool StartRuntimeControlServices( 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.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) { if (!composite.ReloadShader()) { diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp index 7fc80ef..515abc6 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.cpp @@ -6,10 +6,17 @@ #include "OpenGLDeckLinkBridge.h" #include "OpenGLRenderPass.h" #include "OpenGLShaderPrograms.h" +#include "PngScreenshotWriter.h" #include "RuntimeServices.h" #include "ShaderBuildQueue.h" +#include +#include +#include +#include +#include #include +#include #include #include @@ -17,7 +24,8 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) : hGLWnd(hWnd), hGLDC(hDC), hGLRC(hRC), mDeckLink(std::make_unique()), mRenderer(std::make_unique()), - mUseCommittedLayerStates(false) + mUseCommittedLayerStates(false), + mScreenshotRequested(false) { InitializeCriticalSection(&pMutex); mRuntimeHost = std::make_unique(); @@ -29,6 +37,7 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) : hGLDC, hGLRC, [this]() { renderEffect(); }, + [this]() { ProcessScreenshotRequest(); }, [this]() { paintGL(); }); mRenderPass = std::make_unique(*mRenderer); mShaderPrograms = std::make_unique(*mRenderer, *mRuntimeHost); @@ -283,6 +292,13 @@ bool OpenGLComposite::ReloadShader() return true; } +bool OpenGLComposite::RequestScreenshot(std::string& error) +{ + (void)error; + mScreenshotRequested.store(true); + return true; +} + void OpenGLComposite::renderEffect() { 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 bottomUpPixels(static_cast(width) * height * 4); + std::vector 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(width) * 4; + for (unsigned y = 0; y < height; ++y) + { + const unsigned sourceY = height - 1 - y; + std::copy( + bottomUpPixels.begin() + static_cast(sourceY * rowBytes), + bottomUpPixels.begin() + static_cast((sourceY + 1) * rowBytes), + topDownPixels.begin() + static_cast(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(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() { if (!mRuntimeHost || !mRuntimeServices) diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h index 5db2c94..18c7d18 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLComposite.h @@ -18,6 +18,7 @@ #include #include +#include #include #include #include @@ -54,6 +55,7 @@ public: bool ResetLayerParameters(const std::string& layerId, std::string& error); bool SaveStackPreset(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 GetOscPort() const; std::string GetControlUrl() const; @@ -87,11 +89,14 @@ private: std::unique_ptr mRuntimeServices; std::vector mCachedLayerRenderStates; std::atomic mUseCommittedLayerStates; + std::atomic mScreenshotRequested; bool InitOpenGLState(); void renderEffect(); bool ProcessRuntimePollResults(); void RequestShaderBuild(); + void ProcessScreenshotRequest(); + std::filesystem::path BuildScreenshotPath() const; void broadcastRuntimeState(); void resetTemporalHistoryState(); }; diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.cpp index 6f1ef76..a455724 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.cpp @@ -15,6 +15,7 @@ OpenGLDeckLinkBridge::OpenGLDeckLinkBridge( HDC hdc, HGLRC hglrc, RenderEffectCallback renderEffect, + OutputReadyCallback outputReady, PaintCallback paint) : mDeckLink(deckLink), mRenderer(renderer), @@ -23,6 +24,7 @@ OpenGLDeckLinkBridge::OpenGLDeckLinkBridge( mHdc(hdc), mHglrc(hglrc), mRenderEffect(renderEffect), + mOutputReady(outputReady), mPaint(paint) { } @@ -129,6 +131,8 @@ void OpenGLDeckLinkBridge::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedF 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); glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputFramebuffer()); + if (mOutputReady) + mOutputReady(); if (mDeckLink.OutputIsTenBit()) { glBindFramebuffer(GL_FRAMEBUFFER, mRenderer.OutputPackFramebuffer()); diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.h b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.h index 4470a99..18827bf 100644 --- a/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.h +++ b/apps/LoopThroughWithOpenGLCompositing/gl/OpenGLDeckLinkBridge.h @@ -16,6 +16,7 @@ class OpenGLDeckLinkBridge { public: using RenderEffectCallback = std::function; + using OutputReadyCallback = std::function; using PaintCallback = std::function; OpenGLDeckLinkBridge( @@ -26,6 +27,7 @@ public: HDC hdc, HGLRC hglrc, RenderEffectCallback renderEffect, + OutputReadyCallback outputReady, PaintCallback paint); void VideoFrameArrived(IDeckLinkVideoInputFrame* inputFrame, bool hasNoInputSource); @@ -41,6 +43,7 @@ private: HDC mHdc; HGLRC mHglrc; RenderEffectCallback mRenderEffect; + OutputReadyCallback mOutputReady; PaintCallback mPaint; std::chrono::steady_clock::time_point mLastPlayoutCompletionTime; double mCompletionIntervalMilliseconds = 0.0; diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.cpp b/apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.cpp new file mode 100644 index 0000000..3771acd --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.cpp @@ -0,0 +1,133 @@ +#include "PngScreenshotWriter.h" + +#include +#include +#include + +#include +#include + +namespace +{ +std::string HResultToString(HRESULT hr) +{ + std::ostringstream stream; + stream << "HRESULT 0x" << std::hex << static_cast(hr); + return stream.str(); +} + +bool WritePngFile( + const std::filesystem::path& outputPath, + unsigned width, + unsigned height, + const std::vector& rgbaPixels, + std::string& error) +{ + if (width == 0 || height == 0 || rgbaPixels.size() < static_cast(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 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 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 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 frame; + CComPtr 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(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 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(); +} + diff --git a/apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.h b/apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.h new file mode 100644 index 0000000..f97ad78 --- /dev/null +++ b/apps/LoopThroughWithOpenGLCompositing/gl/PngScreenshotWriter.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include +#include + +void WritePngFileAsync( + const std::filesystem::path& outputPath, + unsigned width, + unsigned height, + std::vector rgbaPixels); + diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1c4c28e..9dab2b6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -214,6 +214,24 @@ paths: $ref: "#/components/responses/ActionOk" "400": $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: responses: ActionOk: diff --git a/shaders/fisheye-equirectangular-mirror/shader.json b/shaders/fisheye-equirectangular-mirror/shader.json new file mode 100644 index 0000000..84cd1eb --- /dev/null +++ b/shaders/fisheye-equirectangular-mirror/shader.json @@ -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] + } + ] +} diff --git a/shaders/fisheye-equirectangular-mirror/shader.slang b/shaders/fisheye-equirectangular-mirror/shader.slang new file mode 100644 index 0000000..f871453 --- /dev/null +++ b/shaders/fisheye-equirectangular-mirror/shader.slang @@ -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); +} diff --git a/ui/src/components/StackPresetToolbar.jsx b/ui/src/components/StackPresetToolbar.jsx index 483e1af..f7cd970 100644 --- a/ui/src/components/StackPresetToolbar.jsx +++ b/ui/src/components/StackPresetToolbar.jsx @@ -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"; @@ -9,6 +10,17 @@ export function StackPresetToolbar({ onPresetNameChange, onSelectedPresetNameChange, }) { + const [screenshotQueued, setScreenshotQueued] = useState(false); + + async function requestScreenshot() { + setScreenshotQueued(true); + try { + await postJson("/api/screenshot", {}); + } finally { + window.setTimeout(() => setScreenshotQueued(false), 1200); + } + } + return (
@@ -16,14 +28,24 @@ export function StackPresetToolbar({

Stack presets

Save or recall the current layer chain.

- +
+ + +
diff --git a/ui/src/styles.css b/ui/src/styles.css index b9732b9..3b4c15a 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -498,10 +498,21 @@ pre { align-content: start; } +.stack-panel__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; +} + .stack-panel__reload { min-width: 8.75rem; } +.stack-panel__screenshot { + min-width: 8.75rem; +} + .toolbar__group { display: grid; gap: 0.5rem;