PNG writer
All checks were successful
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 1m35s
CI / Windows Release Package (push) Successful in 2m17s

This commit is contained in:
2026-05-08 15:33:40 +10:00
parent 6ea70d9497
commit 05d0bcbedd
18 changed files with 509 additions and 14 deletions

View File

@@ -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

View File

@@ -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.
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`:

View File

@@ -99,7 +99,7 @@
<LanguageStandard>stdcpp17</LanguageStandard>
</ClCompile>
<Link>
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<GenerateDebugInformation>true</GenerateDebugInformation>
<SubSystem>Windows</SubSystem>
<TargetMachine>MachineX86</TargetMachine>
@@ -121,7 +121,7 @@
<LanguageStandard>stdcpp17</LanguageStandard>
</ClCompile>
<Link>
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<GenerateDebugInformation>true</GenerateDebugInformation>
<SubSystem>Windows</SubSystem>
<TargetMachine>MachineX64</TargetMachine>
@@ -141,7 +141,7 @@
<LanguageStandard>stdcpp17</LanguageStandard>
</ClCompile>
<Link>
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<GenerateDebugInformation>true</GenerateDebugInformation>
<SubSystem>Windows</SubSystem>
<OptimizeReferences>true</OptimizeReferences>
@@ -166,7 +166,7 @@
<LanguageStandard>stdcpp17</LanguageStandard>
</ClCompile>
<Link>
<AdditionalDependencies>opengl32.lib;Glu32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>opengl32.lib;Glu32.lib;Windowscodecs.lib;Ole32.lib;%(AdditionalDependencies)</AdditionalDependencies>
<GenerateDebugInformation>true</GenerateDebugInformation>
<SubSystem>Windows</SubSystem>
<OptimizeReferences>true</OptimizeReferences>
@@ -181,6 +181,7 @@
<ClCompile Include="gl\OpenGLRenderPass.cpp" />
<ClCompile Include="gl\OpenGLRenderer.cpp" />
<ClCompile Include="gl\OpenGLShaderPrograms.cpp" />
<ClCompile Include="gl\PngScreenshotWriter.cpp" />
<ClCompile Include="gl\ShaderBuildQueue.cpp" />
<ClCompile Include="gl\TemporalHistoryBuffers.cpp" />
<ClCompile Include="stdafx.cpp">
@@ -202,6 +203,7 @@
<ClInclude Include="gl\OpenGLRenderPass.h" />
<ClInclude Include="gl\OpenGLRenderer.h" />
<ClInclude Include="gl\OpenGLShaderPrograms.h" />
<ClInclude Include="gl\PngScreenshotWriter.h" />
<ClInclude Include="gl\ShaderBuildQueue.h" />
<ClInclude Include="gl\TemporalHistoryBuffers.h" />
<ClInclude Include="resource.h" />

View File

@@ -36,6 +36,9 @@
<ClCompile Include="gl\OpenGLShaderPrograms.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="gl\PngScreenshotWriter.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="gl\ShaderBuildQueue.cpp">
<Filter>Source Files</Filter>
</ClCompile>
@@ -80,6 +83,9 @@
<ClInclude Include="gl\OpenGLShaderPrograms.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="gl\PngScreenshotWriter.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="gl\ShaderBuildQueue.h">
<Filter>Header Files</Filter>
</ClInclude>

View File

@@ -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);
}
}
};

View File

@@ -32,6 +32,7 @@ public:
std::function<bool(const std::string&, std::string&)> saveStackPreset;
std::function<bool(const std::string&, std::string&)> loadStackPreset;
std::function<bool(std::string&)> reloadShader;
std::function<bool(std::string&)> requestScreenshot;
};
ControlServer();

View File

@@ -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())
{

View File

@@ -6,10 +6,17 @@
#include "OpenGLDeckLinkBridge.h"
#include "OpenGLRenderPass.h"
#include "OpenGLShaderPrograms.h"
#include "PngScreenshotWriter.h"
#include "RuntimeServices.h"
#include "ShaderBuildQueue.h"
#include <algorithm>
#include <chrono>
#include <ctime>
#include <filesystem>
#include <iomanip>
#include <memory>
#include <sstream>
#include <string>
#include <vector>
@@ -17,7 +24,8 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
hGLWnd(hWnd), hGLDC(hDC), hGLRC(hRC),
mDeckLink(std::make_unique<DeckLinkSession>()),
mRenderer(std::make_unique<OpenGLRenderer>()),
mUseCommittedLayerStates(false)
mUseCommittedLayerStates(false),
mScreenshotRequested(false)
{
InitializeCriticalSection(&pMutex);
mRuntimeHost = std::make_unique<RuntimeHost>();
@@ -29,6 +37,7 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
hGLDC,
hGLRC,
[this]() { renderEffect(); },
[this]() { ProcessScreenshotRequest(); },
[this]() { paintGL(); });
mRenderPass = std::make_unique<OpenGLRenderPass>(*mRenderer);
mShaderPrograms = std::make_unique<OpenGLShaderPrograms>(*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<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()
{
if (!mRuntimeHost || !mRuntimeServices)

View File

@@ -18,6 +18,7 @@
#include <functional>
#include <atomic>
#include <filesystem>
#include <map>
#include <memory>
#include <string>
@@ -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<RuntimeServices> mRuntimeServices;
std::vector<RuntimeRenderState> mCachedLayerRenderStates;
std::atomic<bool> mUseCommittedLayerStates;
std::atomic<bool> mScreenshotRequested;
bool InitOpenGLState();
void renderEffect();
bool ProcessRuntimePollResults();
void RequestShaderBuild();
void ProcessScreenshotRequest();
std::filesystem::path BuildScreenshotPath() const;
void broadcastRuntimeState();
void resetTemporalHistoryState();
};

View File

@@ -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());

View File

@@ -16,6 +16,7 @@ class OpenGLDeckLinkBridge
{
public:
using RenderEffectCallback = std::function<void()>;
using OutputReadyCallback = std::function<void()>;
using PaintCallback = std::function<void()>;
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;

View 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();
}

View File

@@ -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);

View File

@@ -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:

View 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]
}
]
}

View 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);
}

View File

@@ -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 (
<div className="panel stack-panel">
<div className="panel__header stack-panel__header">
@@ -16,14 +28,24 @@ export function StackPresetToolbar({
<h3>Stack presets</h3>
<p className="muted">Save or recall the current layer chain.</p>
</div>
<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 className="stack-panel__actions">
<button
type="button"
className="button-with-icon stack-panel__screenshot"
onClick={requestScreenshot}
>
<Camera size={16} strokeWidth={1.9} aria-hidden="true" />
<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 className="stack-panel__grid">

View File

@@ -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;