OSC updates and video resolution fixes
Some checks failed
CI / Native Windows Build And Tests (push) Failing after 7s
CI / React UI Build (push) Has been cancelled
CI / Windows Release Package (push) Has been cancelled

This commit is contained in:
2026-05-03 14:33:33 +10:00
parent bfc12a1aea
commit 7dc4b552a5
20 changed files with 842 additions and 124 deletions

104
OSC/Test.json Normal file
View File

@@ -0,0 +1,104 @@
{
"createdWith": "Open Stage Control",
"version": "1.30.3",
"type": "session",
"content": {
"type": "root",
"lock": false,
"id": "root",
"visible": true,
"interaction": true,
"comments": "",
"width": "auto",
"height": "auto",
"colorText": "auto",
"colorWidget": "auto",
"alphaFillOn": "auto",
"borderRadius": "auto",
"padding": "auto",
"html": "",
"css": "",
"colorBg": "auto",
"layout": "default",
"justify": "start",
"gridTemplate": "",
"contain": true,
"scroll": true,
"innerPadding": true,
"tabsPosition": "top",
"hideMenu": false,
"variables": "@{parent.variables}",
"traversing": false,
"value": "",
"default": "",
"linkId": "",
"address": "auto",
"preArgs": "",
"typeTags": "",
"decimals": 2,
"target": "127.0.0.1:9000",
"ignoreDefaults": false,
"bypass": false,
"onCreate": "",
"onValue": "",
"onTouch": "",
"onPreload": "",
"widgets": [
{
"type": "xy",
"top": 120,
"left": 120,
"lock": false,
"id": "fisheye_pan_tilt",
"visible": true,
"interaction": true,
"comments": "XY control for Fisheye Reproject pan and tilt.",
"width": 420,
"height": 420,
"expand": false,
"colorText": "auto",
"colorWidget": "auto",
"colorStroke": "auto",
"colorFill": "auto",
"alphaStroke": "auto",
"alphaFillOff": "auto",
"alphaFillOn": "auto",
"lineWidth": "auto",
"borderRadius": "auto",
"padding": "auto",
"html": "",
"css": "",
"design": "default",
"pips": true,
"snap": false,
"spring": false,
"rangeX": {
"min": -180,
"max": 180
},
"rangeY": {
"min": -120,
"max": 120
},
"logScaleX": false,
"logScaleY": false,
"sensitivity": 1,
"value": [0, 0],
"default": [0, 0],
"linkId": "",
"address": "/VideoShaderToys/fisheye-reproject/xy",
"preArgs": "",
"typeTags": "",
"decimals": "2f",
"target": "127.0.0.1:9000",
"ignoreDefaults": false,
"bypass": true,
"split": [],
"onCreate": "",
"onValue": "if (touch !== undefined) return;\nvar pan = Array.isArray(value) ? Number(value[0]) : 0;\nvar tilt = Array.isArray(value) ? Number(value[1]) : 0;\nsend('127.0.0.1:9000', '/VideoShaderToys/fisheye-reproject/panDegrees', {type: 'f', value: pan});\nsend('127.0.0.1:9000', '/VideoShaderToys/fisheye-reproject/tiltDegrees', {type: 'f', value: tilt});",
"onTouch": ""
}
],
"tabs": []
}
}

View File

@@ -119,15 +119,20 @@ Current native test coverage includes:
{
"shaderLibrary": "shaders",
"serverPort": 8080,
"videoFormat": "1080p",
"frameRate": "59.94",
"oscPort": 9000,
"inputVideoFormat": "1080p",
"inputFrameRate": "59.94",
"outputVideoFormat": "1080p",
"outputFrameRate": "59.94",
"autoReload": true,
"maxTemporalHistoryFrames": 12,
"enableExternalKeying": true
}
```
`videoFormat` and `frameRate` select the DeckLink capture/playout display mode. Common examples include `720p`/`50`, `720p`/`59.94`, `1080i`/`50`, `1080i`/`59.94`, `1080p`/`25`, `1080p`/`50`, `1080p`/`59.94`, and `2160p`/`59.94`, depending on card support.
`inputVideoFormat`/`inputFrameRate` select the DeckLink capture mode. `outputVideoFormat`/`outputFrameRate` select the playout mode. The shader stack runs at input resolution and the final rendered frame is scaled once into the configured output mode. Common examples include `720p`/`50`, `720p`/`59.94`, `1080i`/`50`, `1080i`/`59.94`, `1080p`/`25`, `1080p`/`50`, `1080p`/`59.94`, and `2160p`/`59.94`, depending on card support.
Legacy `videoFormat` and `frameRate` keys are still accepted and apply to both input and output unless the explicit input/output keys are present.
The control UI is available at:

View File

@@ -133,7 +133,7 @@ Fields:
- `uv`: normalized texture coordinates, usually `0..1`.
- `sourceColor`: decoded RGBA source video at `uv`.
- `inputResolution`: decoded input video resolution in pixels.
- `outputResolution`: output/render resolution in pixels.
- `outputResolution`: shader render resolution in pixels. The current pipeline renders the shader stack at input resolution, then scales the final frame to the configured DeckLink output mode.
- `time`: elapsed runtime time in seconds.
- `frameCount`: incrementing frame counter.
- `mixAmount`: runtime mix amount.

View File

@@ -192,6 +192,28 @@ bool ResolveConfiguredDisplayMode(const std::string& videoFormat, const std::str
return false;
}
bool FindDeckLinkDisplayMode(IDeckLinkDisplayModeIterator* iterator, BMDDisplayMode targetMode, IDeckLinkDisplayMode** foundMode)
{
if (!iterator || !foundMode)
return false;
*foundMode = NULL;
IDeckLinkDisplayMode* candidate = NULL;
while (iterator->Next(&candidate) == S_OK)
{
if (candidate->GetDisplayMode() == targetMode)
{
*foundMode = candidate;
return true;
}
candidate->Release();
candidate = NULL;
}
return false;
}
class ScopedGlShader
{
public:
@@ -295,8 +317,10 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
mCaptureDelegate(NULL), mPlayoutDelegate(NULL),
mDLInput(NULL), mDLOutput(NULL), mDLKeyer(NULL),
mPlayoutAllocator(NULL),
mFrameWidth(0), mFrameHeight(0),
mDisplayModeName("1080p59.94"),
mInputFrameWidth(0), mInputFrameHeight(0),
mOutputFrameWidth(0), mOutputFrameHeight(0),
mInputDisplayModeName("1080p59.94"),
mOutputDisplayModeName("1080p59.94"),
mHasNoInputSource(true),
mDeckLinkSupportsInternalKeying(false),
mDeckLinkSupportsExternalKeying(false),
@@ -307,10 +331,12 @@ OpenGLComposite::OpenGLComposite(HWND hWnd, HDC hDC, HGLRC hRC) :
mDecodedTexture(0),
mLayerTempTexture(0),
mFBOTexture(0),
mOutputTexture(0),
mUnpinnedTextureBuffer(0),
mDecodeFrameBuf(0),
mLayerTempFrameBuf(0),
mIdFrameBuf(0),
mOutputFrameBuf(0),
mIdColorBuf(0),
mIdDepthBuf(0),
mFullscreenVAO(0),
@@ -407,6 +433,10 @@ OpenGLComposite::~OpenGLComposite()
glDeleteTextures(1, &mLayerTempTexture);
if (mFBOTexture != 0)
glDeleteTextures(1, &mFBOTexture);
if (mOutputTexture != 0)
glDeleteTextures(1, &mOutputTexture);
if (mOutputFrameBuf != 0)
glDeleteFramebuffers(1, &mOutputFrameBuf);
if (mUnpinnedTextureBuffer != 0)
glDeleteBuffers(1, &mUnpinnedTextureBuffer);
@@ -427,10 +457,14 @@ bool OpenGLComposite::InitDeckLink()
IDeckLinkIterator* pDLIterator = NULL;
IDeckLink* pDL = NULL;
IDeckLinkProfileAttributes* deckLinkAttributes = NULL;
IDeckLinkDisplayModeIterator* pDLDisplayModeIterator = NULL;
IDeckLinkDisplayMode* pDLDisplayMode = NULL;
BMDDisplayMode displayMode = bmdModeHD1080p5994; // mode to use for capture and playout
std::string displayModeName = "1080p59.94";
IDeckLinkDisplayModeIterator* pDLInputDisplayModeIterator = NULL;
IDeckLinkDisplayModeIterator* pDLOutputDisplayModeIterator = NULL;
IDeckLinkDisplayMode* pDLInputDisplayMode = NULL;
IDeckLinkDisplayMode* pDLOutputDisplayMode = NULL;
BMDDisplayMode inputDisplayMode = bmdModeHD1080p5994;
BMDDisplayMode outputDisplayMode = bmdModeHD1080p5994;
std::string inputDisplayModeName = "1080p59.94";
std::string outputDisplayModeName = "1080p59.94";
int outputFrameRowBytes;
HRESULT result;
@@ -446,15 +480,23 @@ bool OpenGLComposite::InitDeckLink()
if (mRuntimeHost)
{
if (!ResolveConfiguredDisplayMode(mRuntimeHost->GetVideoFormat(), mRuntimeHost->GetFrameRate(), displayMode, displayModeName))
if (!ResolveConfiguredDisplayMode(mRuntimeHost->GetInputVideoFormat(), mRuntimeHost->GetInputFrameRate(), inputDisplayMode, inputDisplayModeName))
{
const std::string error = "Unsupported DeckLink video format/frameRate in config/runtime-host.json: " +
mRuntimeHost->GetVideoFormat() + " / " + mRuntimeHost->GetFrameRate();
MessageBoxA(NULL, error.c_str(), "DeckLink mode configuration error", MB_OK);
const std::string error = "Unsupported DeckLink inputVideoFormat/inputFrameRate in config/runtime-host.json: " +
mRuntimeHost->GetInputVideoFormat() + " / " + mRuntimeHost->GetInputFrameRate();
MessageBoxA(NULL, error.c_str(), "DeckLink input mode configuration error", MB_OK);
return false;
}
if (!ResolveConfiguredDisplayMode(mRuntimeHost->GetOutputVideoFormat(), mRuntimeHost->GetOutputFrameRate(), outputDisplayMode, outputDisplayModeName))
{
const std::string error = "Unsupported DeckLink outputVideoFormat/outputFrameRate in config/runtime-host.json: " +
mRuntimeHost->GetOutputVideoFormat() + " / " + mRuntimeHost->GetOutputFrameRate();
MessageBoxA(NULL, error.c_str(), "DeckLink output mode configuration error", MB_OK);
return false;
}
}
mDisplayModeName = displayModeName;
mInputDisplayModeName = inputDisplayModeName;
mOutputDisplayModeName = outputDisplayModeName;
result = CoCreateInstance(CLSID_CDeckLinkIterator, NULL, CLSCTX_ALL, IID_IDeckLinkIterator, (void**)&pDLIterator);
if (FAILED(result))
@@ -538,35 +580,48 @@ bool OpenGLComposite::InitDeckLink()
goto error;
}
if (mDLOutput->GetDisplayModeIterator(&pDLDisplayModeIterator) != S_OK)
if (mDLInput->GetDisplayModeIterator(&pDLInputDisplayModeIterator) != S_OK)
{
MessageBox(NULL, _T("Cannot get Display Mode Iterator."), _T("DeckLink error."), MB_OK);
MessageBox(NULL, _T("Cannot get input Display Mode Iterator."), _T("DeckLink error."), MB_OK);
goto error;
}
while (pDLDisplayModeIterator->Next(&pDLDisplayMode) == S_OK)
if (!FindDeckLinkDisplayMode(pDLInputDisplayModeIterator, inputDisplayMode, &pDLInputDisplayMode))
{
if (pDLDisplayMode->GetDisplayMode() == displayMode)
break;
pDLDisplayMode->Release();
pDLDisplayMode = NULL;
const std::string error = "Cannot get specified input BMDDisplayMode for configured mode: " + inputDisplayModeName;
MessageBoxA(NULL, error.c_str(), "DeckLink input error.", MB_OK);
goto error;
}
pDLDisplayModeIterator->Release();
pDLDisplayModeIterator = NULL;
pDLInputDisplayModeIterator->Release();
pDLInputDisplayModeIterator = NULL;
if (pDLDisplayMode == NULL)
if (mDLOutput->GetDisplayModeIterator(&pDLOutputDisplayModeIterator) != S_OK)
{
const std::string error = "Cannot get specified BMDDisplayMode for configured mode: " + displayModeName;
MessageBoxA(NULL, error.c_str(), "DeckLink error.", MB_OK);
MessageBox(NULL, _T("Cannot get output Display Mode Iterator."), _T("DeckLink error."), MB_OK);
goto error;
}
mFrameWidth = pDLDisplayMode->GetWidth();
mFrameHeight = pDLDisplayMode->GetHeight();
if (!FindDeckLinkDisplayMode(pDLOutputDisplayModeIterator, outputDisplayMode, &pDLOutputDisplayMode))
{
const std::string error = "Cannot get specified output BMDDisplayMode for configured mode: " + outputDisplayModeName;
MessageBoxA(NULL, error.c_str(), "DeckLink output error.", MB_OK);
goto error;
}
pDLOutputDisplayModeIterator->Release();
pDLOutputDisplayModeIterator = NULL;
mInputFrameWidth = pDLInputDisplayMode->GetWidth();
mInputFrameHeight = pDLInputDisplayMode->GetHeight();
mOutputFrameWidth = pDLOutputDisplayMode->GetWidth();
mOutputFrameHeight = pDLOutputDisplayMode->GetHeight();
if (! CheckOpenGLExtensions())
goto error;
if (mInputFrameWidth != mOutputFrameWidth || mInputFrameHeight != mOutputFrameHeight)
{
mFastTransferExtensionAvailable = false;
OutputDebugStringA("Input/output dimensions differ; using regular OpenGL transfer fallback instead of fast transfer.\n");
}
if (! InitOpenGLState())
goto error;
@@ -586,18 +641,18 @@ bool OpenGLComposite::InitDeckLink()
mDeckLinkStatusMessage);
}
pDLDisplayMode->GetFrameRate(&mFrameDuration, &mFrameTimescale);
pDLOutputDisplayMode->GetFrameRate(&mFrameDuration, &mFrameTimescale);
// Resize window to match video frame, but scale large formats down by half for viewing
if (mFrameWidth < 1920)
resizeWindow(mFrameWidth, mFrameHeight);
// Resize window to match output video frame, but scale large formats down by half for viewing.
if (mOutputFrameWidth < 1920)
resizeWindow(mOutputFrameWidth, mOutputFrameHeight);
else
resizeWindow(mFrameWidth / 2, mFrameHeight / 2);
resizeWindow(mOutputFrameWidth / 2, mOutputFrameHeight / 2);
if (mFastTransferExtensionAvailable)
{
// Initialize fast video frame transfers
if (! VideoFrameTransfer::initialize(mFrameWidth, mFrameHeight, mCaptureTexture, mFBOTexture))
if (! VideoFrameTransfer::initialize(mInputFrameWidth, mInputFrameHeight, mCaptureTexture, mOutputTexture))
{
MessageBox(NULL, _T("Cannot initialize video transfers."), _T("VideoFrameTransfer error."), MB_OK);
goto error;
@@ -608,7 +663,7 @@ bool OpenGLComposite::InitDeckLink()
// Use custom allocators so we pin only once then recycle them
CComPtr<IDeckLinkVideoBufferAllocatorProvider> captureAllocator(new (std::nothrow) InputAllocatorPool(hGLDC, hGLRC));
if (mDLInput->EnableVideoInputWithAllocatorProvider(displayMode, bmdFormat8BitYUV, bmdVideoInputFlagDefault, captureAllocator) != S_OK)
if (mDLInput->EnableVideoInputWithAllocatorProvider(inputDisplayMode, bmdFormat8BitYUV, bmdVideoInputFlagDefault, captureAllocator) != S_OK)
goto error;
}
@@ -616,13 +671,13 @@ bool OpenGLComposite::InitDeckLink()
if (mDLInput->SetCallback(mCaptureDelegate) != S_OK)
goto error;
if (mDLOutput->RowBytesForPixelFormat(bmdFormat8BitBGRA, mFrameWidth, &outputFrameRowBytes) != S_OK)
if (mDLOutput->RowBytesForPixelFormat(bmdFormat8BitBGRA, mOutputFrameWidth, &outputFrameRowBytes) != S_OK)
goto error;
// Use a custom allocator so we pin only once then recycle them
mPlayoutAllocator = new PinnedMemoryAllocator(hGLDC, hGLRC, VideoFrameTransfer::GPUtoCPU, 1, outputFrameRowBytes * mFrameHeight);
mPlayoutAllocator = new PinnedMemoryAllocator(hGLDC, hGLRC, VideoFrameTransfer::GPUtoCPU, 1, outputFrameRowBytes * mOutputFrameHeight);
if (mDLOutput->EnableVideoOutput(displayMode, bmdVideoOutputFlagDefault) != S_OK)
if (mDLOutput->EnableVideoOutput(outputDisplayMode, bmdVideoOutputFlagDefault) != S_OK)
goto error;
if (mDLOutput->QueryInterface(IID_IDeckLinkKeyer, (void**)&mDLKeyer) == S_OK && mDLKeyer != NULL)
@@ -680,7 +735,7 @@ bool OpenGLComposite::InitDeckLink()
if (mPlayoutAllocator->AllocateVideoBuffer(&outputFrameBuffer) != S_OK)
goto error;
if (mDLOutput->CreateVideoFrameWithBuffer(mFrameWidth, mFrameHeight, outputFrameRowBytes, bmdFormat8BitBGRA, bmdFrameFlagFlipVertical, outputFrameBuffer, &outputFrame) != S_OK)
if (mDLOutput->CreateVideoFrameWithBuffer(mOutputFrameWidth, mOutputFrameHeight, outputFrameRowBytes, bmdFormat8BitBGRA, bmdFrameFlagFlipVertical, outputFrameBuffer, &outputFrame) != S_OK)
goto error;
mDLOutputVideoFrameQueue.push_back(outputFrame);
@@ -723,10 +778,28 @@ error:
pDL = NULL;
}
if (pDLDisplayMode != NULL)
if (pDLInputDisplayMode != NULL)
{
pDLDisplayMode->Release();
pDLDisplayMode = NULL;
pDLInputDisplayMode->Release();
pDLInputDisplayMode = NULL;
}
if (pDLOutputDisplayMode != NULL)
{
pDLOutputDisplayMode->Release();
pDLOutputDisplayMode = NULL;
}
if (pDLInputDisplayModeIterator != NULL)
{
pDLInputDisplayModeIterator->Release();
pDLInputDisplayModeIterator = NULL;
}
if (pDLOutputDisplayModeIterator != NULL)
{
pDLOutputDisplayModeIterator->Release();
pDLOutputDisplayModeIterator = NULL;
}
if (pDLIterator != NULL)
@@ -757,9 +830,9 @@ void OpenGLComposite::paintGL()
int destX = 0;
int destY = 0;
if (mFrameWidth > 0 && mFrameHeight > 0 && mViewWidth > 0 && mViewHeight > 0)
if (mOutputFrameWidth > 0 && mOutputFrameHeight > 0 && mViewWidth > 0 && mViewHeight > 0)
{
const double frameAspect = static_cast<double>(mFrameWidth) / static_cast<double>(mFrameHeight);
const double frameAspect = static_cast<double>(mOutputFrameWidth) / static_cast<double>(mOutputFrameHeight);
const double viewAspect = static_cast<double>(mViewWidth) / static_cast<double>(mViewHeight);
if (viewAspect > frameAspect)
@@ -776,12 +849,12 @@ void OpenGLComposite::paintGL()
}
}
glBindFramebuffer(GL_READ_FRAMEBUFFER, mIdFrameBuf);
glBindFramebuffer(GL_READ_FRAMEBUFFER, mOutputFrameBuf);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glViewport(0, 0, mViewWidth, mViewHeight);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glBlitFramebuffer(0, 0, mFrameWidth, mFrameHeight, destX, destY, destX + destWidth, destY + destHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
glBlitFramebuffer(0, 0, mOutputFrameWidth, mOutputFrameHeight, destX, destY, destX + destWidth, destY + destHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
SwapBuffers(hGLDC);
ValidateRect(hGLWnd, NULL);
@@ -893,7 +966,7 @@ bool OpenGLComposite::InitOpenGLState()
// Create texture with empty data, we will update it using glTexSubImage2D each frame.
// The captured video is YCbCr 4:2:2 packed into a UYVY macropixel. OpenGL has no YCbCr format
// so treat it as RGBA 4:4:4:4 by halving the width and using GL_RGBA internal format.
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth/2, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mInputFrameWidth / 2, mInputFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glBindTexture(GL_TEXTURE_2D, 0);
glGenTextures(1, &mDecodedTexture);
@@ -902,7 +975,7 @@ bool OpenGLComposite::InitOpenGLState()
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mInputFrameWidth, mInputFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glBindTexture(GL_TEXTURE_2D, 0);
glGenTextures(1, &mLayerTempTexture);
@@ -911,7 +984,7 @@ bool OpenGLComposite::InitOpenGLState()
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mInputFrameWidth, mInputFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glBindTexture(GL_TEXTURE_2D, 0);
@@ -920,6 +993,7 @@ bool OpenGLComposite::InitOpenGLState()
glGenFramebuffers(1, &mDecodeFrameBuf);
glGenFramebuffers(1, &mLayerTempFrameBuf);
glGenFramebuffers(1, &mIdFrameBuf);
glGenFramebuffers(1, &mOutputFrameBuf);
glGenRenderbuffers(1, &mIdColorBuf);
glGenRenderbuffers(1, &mIdDepthBuf);
glGenVertexArrays(1, &mFullscreenVAO);
@@ -952,11 +1026,11 @@ bool OpenGLComposite::InitOpenGLState()
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mInputFrameWidth, mInputFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
// Attach a depth buffer
glBindRenderbuffer(GL_RENDERBUFFER, mIdDepthBuf);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mFrameWidth, mFrameHeight);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mInputFrameWidth, mInputFrameHeight);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT_EXT, GL_RENDERBUFFER, mIdDepthBuf);
@@ -970,6 +1044,23 @@ bool OpenGLComposite::InitOpenGLState()
return false;
}
glGenTextures(1, &mOutputTexture);
glBindTexture(GL_TEXTURE_2D, mOutputTexture);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mOutputFrameWidth, mOutputFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glBindFramebuffer(GL_FRAMEBUFFER, mOutputFrameBuf);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, mOutputTexture, 0);
glStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (glStatus != GL_FRAMEBUFFER_COMPLETE)
{
MessageBox(NULL, _T("Cannot initialize output framebuffer."), _T("OpenGL initialization error."), MB_OK);
return false;
}
glBindTexture(GL_TEXTURE_2D, 0);
glBindRenderbuffer(GL_RENDERBUFFER, 0);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
@@ -991,7 +1082,7 @@ void OpenGLComposite::VideoFrameArrived(IDeckLinkVideoInputFrame* inputFrame, bo
{
mHasNoInputSource = hasNoInputSource;
if (mRuntimeHost)
mRuntimeHost->SetSignalStatus(!hasNoInputSource, mFrameWidth, mFrameHeight, mDisplayModeName);
mRuntimeHost->SetSignalStatus(!hasNoInputSource, mInputFrameWidth, mInputFrameHeight, mInputDisplayModeName);
if (mHasNoInputSource)
return; // don't transfer texture when there's no input
@@ -1030,7 +1121,7 @@ void OpenGLComposite::VideoFrameArrived(IDeckLinkVideoInputFrame* inputFrame, bo
glBindTexture(GL_TEXTURE_2D, mCaptureTexture);
// NULL for last arg indicates use current GL_PIXEL_UNPACK_BUFFER target as texture data
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, mFrameWidth/2, mFrameHeight, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, mInputFrameWidth / 2, mInputFrameHeight, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glBindTexture(GL_TEXTURE_2D, 0);
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0);
@@ -1064,6 +1155,10 @@ void OpenGLComposite::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame,
VideoFrameTransfer::beginTextureInUse(VideoFrameTransfer::GPUtoCPU);
glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf);
renderEffect();
glBindFramebuffer(GL_READ_FRAMEBUFFER, mIdFrameBuf);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, mOutputFrameBuf);
glBlitFramebuffer(0, 0, mInputFrameWidth, mInputFrameHeight, 0, 0, mOutputFrameWidth, mOutputFrameHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
glBindFramebuffer(GL_FRAMEBUFFER, mOutputFrameBuf);
glFlush();
if (mFastTransferExtensionAvailable)
VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::GPUtoCPU);
@@ -1101,7 +1196,7 @@ void OpenGLComposite::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame,
// Finished with mCaptureTexture
VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU);
if (! mPlayoutAllocator->transferFrame(pFrame, mFBOTexture))
if (! mPlayoutAllocator->transferFrame(pFrame, mOutputTexture))
OutputDebugStringA("Playback: transferFrame() failed\n");
paintGL();
@@ -1111,8 +1206,8 @@ void OpenGLComposite::PlayoutFrameCompleted(IDeckLinkVideoFrame* completedFrame,
}
else
{
glBindFramebuffer(GL_READ_FRAMEBUFFER, mIdFrameBuf);
glReadPixels(0, 0, mFrameWidth, mFrameHeight, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, pFrame);
glBindFramebuffer(GL_READ_FRAMEBUFFER, mOutputFrameBuf);
glReadPixels(0, 0, mOutputFrameWidth, mOutputFrameHeight, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, pFrame);
paintGL();
}
@@ -1158,7 +1253,7 @@ bool OpenGLComposite::Start()
void* pFrame;
outputVideoFrameBuffer->GetBytes((void**)&pFrame);
memset(pFrame, 0, outputVideoFrame->GetRowBytes() * mFrameHeight); // 0 is black in RGBA format
memset(pFrame, 0, outputVideoFrame->GetRowBytes() * mOutputFrameHeight); // 0 is black in BGRA format
outputVideoFrameBuffer->EndAccess(bmdBufferAccessWrite);
outputVideoFrameBuffer->Release();
@@ -1347,7 +1442,7 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state,
bool OpenGLComposite::compileLayerPrograms(int errorMessageSize, char* errorMessage)
{
const std::vector<RuntimeRenderState> layerStates = mRuntimeHost ? mRuntimeHost->GetLayerRenderStates(mFrameWidth, mFrameHeight) : std::vector<RuntimeRenderState>();
const std::vector<RuntimeRenderState> layerStates = mRuntimeHost ? mRuntimeHost->GetLayerRenderStates(mInputFrameWidth, mInputFrameHeight) : std::vector<RuntimeRenderState>();
std::string temporalError;
if (!validateTemporalTextureUnitBudget(layerStates, temporalError))
{
@@ -1650,7 +1745,7 @@ bool OpenGLComposite::createHistoryRing(HistoryRing& ring, unsigned effectiveLen
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mFrameWidth, mFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, mInputFrameWidth, mInputFrameHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, NULL);
glGenFramebuffers(1, &slot.framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, slot.framebuffer);
@@ -1768,7 +1863,7 @@ void OpenGLComposite::pushFramebufferToHistoryRing(GLuint sourceFramebuffer, His
HistorySlot& targetSlot = ring.slots[ring.nextWriteIndex];
glBindFramebuffer(GL_READ_FRAMEBUFFER, sourceFramebuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, targetSlot.framebuffer);
glBlitFramebuffer(0, 0, mFrameWidth, mFrameHeight, 0, 0, mFrameWidth, mFrameHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
glBlitFramebuffer(0, 0, mInputFrameWidth, mInputFrameHeight, 0, 0, mInputFrameWidth, mInputFrameHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
ring.nextWriteIndex = (ring.nextWriteIndex + 1) % ring.slots.size();
ring.filledCount = std::min<std::size_t>(ring.filledCount + 1, ring.slots.size());
}
@@ -1837,12 +1932,12 @@ void OpenGLComposite::renderEffect()
glDisable(GL_DEPTH_TEST);
renderDecodePass();
const std::vector<RuntimeRenderState> layerStates = mRuntimeHost ? mRuntimeHost->GetLayerRenderStates(mFrameWidth, mFrameHeight) : std::vector<RuntimeRenderState>();
const std::vector<RuntimeRenderState> layerStates = mRuntimeHost ? mRuntimeHost->GetLayerRenderStates(mInputFrameWidth, mInputFrameHeight) : std::vector<RuntimeRenderState>();
if (layerStates.empty() || mLayerPrograms.empty())
{
glBindFramebuffer(GL_READ_FRAMEBUFFER, mDecodeFrameBuf);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, mIdFrameBuf);
glBlitFramebuffer(0, 0, mFrameWidth, mFrameHeight, 0, 0, mFrameWidth, mFrameHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
glBlitFramebuffer(0, 0, mInputFrameWidth, mInputFrameHeight, 0, 0, mInputFrameWidth, mInputFrameHeight, GL_COLOR_BUFFER_BIT, GL_LINEAR);
glBindFramebuffer(GL_FRAMEBUFFER, mIdFrameBuf);
}
else
@@ -1874,7 +1969,7 @@ void OpenGLComposite::renderEffect()
void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, const LayerProgram& layerProgram, const RuntimeRenderState& state)
{
glBindFramebuffer(GL_FRAMEBUFFER, destinationFrameBuffer);
glViewport(0, 0, mFrameWidth, mFrameHeight);
glViewport(0, 0, mInputFrameWidth, mInputFrameHeight);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0 + kDecodedVideoTextureUnit);
glBindTexture(GL_TEXTURE_2D, sourceTexture);
@@ -1908,7 +2003,7 @@ void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinati
void OpenGLComposite::renderDecodePass()
{
glBindFramebuffer(GL_FRAMEBUFFER, mDecodeFrameBuf);
glViewport(0, 0, mFrameWidth, mFrameHeight);
glViewport(0, 0, mInputFrameWidth, mInputFrameHeight);
glClear(GL_COLOR_BUFFER_BIT);
glActiveTexture(GL_TEXTURE0 + kPackedVideoTextureUnit);
glBindTexture(GL_TEXTURE_2D, mCaptureTexture);
@@ -1918,9 +2013,9 @@ void OpenGLComposite::renderDecodePass()
const GLint packedResolutionLocation = glGetUniformLocation(mDecodeProgram, "uPackedVideoResolution");
const GLint decodedResolutionLocation = glGetUniformLocation(mDecodeProgram, "uDecodedVideoResolution");
if (packedResolutionLocation >= 0)
glUniform2f(packedResolutionLocation, static_cast<float>(mFrameWidth / 2), static_cast<float>(mFrameHeight));
glUniform2f(packedResolutionLocation, static_cast<float>(mInputFrameWidth / 2), static_cast<float>(mInputFrameHeight));
if (decodedResolutionLocation >= 0)
glUniform2f(decodedResolutionLocation, static_cast<float>(mFrameWidth), static_cast<float>(mFrameHeight));
glUniform2f(decodedResolutionLocation, static_cast<float>(mInputFrameWidth), static_cast<float>(mInputFrameHeight));
glDrawArrays(GL_TRIANGLES, 0, 3);

View File

@@ -118,9 +118,12 @@ private:
BMDTimeValue mFrameDuration;
BMDTimeScale mFrameTimescale;
unsigned mTotalPlayoutFrames;
unsigned mFrameWidth;
unsigned mFrameHeight;
std::string mDisplayModeName;
unsigned mInputFrameWidth;
unsigned mInputFrameHeight;
unsigned mOutputFrameWidth;
unsigned mOutputFrameHeight;
std::string mInputDisplayModeName;
std::string mOutputDisplayModeName;
bool mHasNoInputSource;
std::string mDeckLinkOutputModelName;
bool mDeckLinkSupportsInternalKeying;
@@ -135,10 +138,12 @@ private:
GLuint mDecodedTexture;
GLuint mLayerTempTexture;
GLuint mFBOTexture;
GLuint mOutputTexture;
GLuint mUnpinnedTextureBuffer;
GLuint mDecodeFrameBuf;
GLuint mLayerTempFrameBuf;
GLuint mIdFrameBuf;
GLuint mOutputFrameBuf;
GLuint mIdColorBuf;
GLuint mIdDepthBuf;
GLuint mFullscreenVAO;

View File

@@ -114,7 +114,14 @@ void OscServer::ServerLoop()
OscMessage message;
std::string error;
if (DecodeMessage(buffer.data(), byteCount, message, error))
DispatchMessage(message, error);
{
if (!DispatchMessage(message, error) && !error.empty())
OutputDebugStringA(("OSC dispatch failed: " + error + "\n").c_str());
}
else if (!error.empty())
{
OutputDebugStringA(("OSC decode failed: " + error + "\n").c_str());
}
}
}
@@ -152,6 +159,17 @@ bool OscServer::DecodeMessage(const char* data, int byteCount, OscMessage& messa
return true;
}
if (valueType == 'd')
{
double value = 0.0;
if (!ReadFloat64(data, byteCount, offset, value))
return false;
std::ostringstream stream;
stream << std::setprecision(17) << value;
message.valueJson = stream.str();
return true;
}
if (valueType == 'i')
{
int value = 0;
@@ -234,6 +252,21 @@ bool OscServer::ReadFloat32(const char* data, int byteCount, int& offset, double
return true;
}
bool OscServer::ReadFloat64(const char* data, int byteCount, int& offset, double& value)
{
if (offset + 8 > byteCount)
return false;
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(data + offset);
uint64_t bits = 0;
for (int index = 0; index < 8; ++index)
bits = (bits << 8) | static_cast<uint64_t>(bytes[index]);
std::memcpy(&value, &bits, sizeof(value));
offset += 8;
return true;
}
std::string OscServer::BuildJsonString(const std::string& value)
{
std::ostringstream stream;

View File

@@ -40,6 +40,7 @@ private:
static bool ReadPaddedString(const char* data, int byteCount, int& offset, std::string& value);
static bool ReadInt32(const char* data, int byteCount, int& offset, int& value);
static bool ReadFloat32(const char* data, int byteCount, int& offset, double& value);
static bool ReadFloat64(const char* data, int byteCount, int& offset, double& value);
static std::string BuildJsonString(const std::string& value);
Callbacks mCallbacks;

View File

@@ -1185,17 +1185,56 @@ bool RuntimeHost::LoadConfig(std::string& error)
if (const JsonValue* videoFormatValue = configJson.find("videoFormat"))
{
if (videoFormatValue->isString() && !videoFormatValue->asString().empty())
mConfig.videoFormat = videoFormatValue->asString();
{
mConfig.inputVideoFormat = videoFormatValue->asString();
mConfig.outputVideoFormat = videoFormatValue->asString();
}
}
if (const JsonValue* frameRateValue = configJson.find("frameRate"))
{
if (frameRateValue->isString() && !frameRateValue->asString().empty())
mConfig.frameRate = frameRateValue->asString();
{
mConfig.inputFrameRate = frameRateValue->asString();
mConfig.outputFrameRate = frameRateValue->asString();
}
else if (frameRateValue->isNumber())
{
std::ostringstream stream;
stream << frameRateValue->asNumber();
mConfig.frameRate = stream.str();
mConfig.inputFrameRate = stream.str();
mConfig.outputFrameRate = stream.str();
}
}
if (const JsonValue* inputVideoFormatValue = configJson.find("inputVideoFormat"))
{
if (inputVideoFormatValue->isString() && !inputVideoFormatValue->asString().empty())
mConfig.inputVideoFormat = inputVideoFormatValue->asString();
}
if (const JsonValue* inputFrameRateValue = configJson.find("inputFrameRate"))
{
if (inputFrameRateValue->isString() && !inputFrameRateValue->asString().empty())
mConfig.inputFrameRate = inputFrameRateValue->asString();
else if (inputFrameRateValue->isNumber())
{
std::ostringstream stream;
stream << inputFrameRateValue->asNumber();
mConfig.inputFrameRate = stream.str();
}
}
if (const JsonValue* outputVideoFormatValue = configJson.find("outputVideoFormat"))
{
if (outputVideoFormatValue->isString() && !outputVideoFormatValue->asString().empty())
mConfig.outputVideoFormat = outputVideoFormatValue->asString();
}
if (const JsonValue* outputFrameRateValue = configJson.find("outputFrameRate"))
{
if (outputFrameRateValue->isString() && !outputFrameRateValue->asString().empty())
mConfig.outputFrameRate = outputFrameRateValue->asString();
else if (outputFrameRateValue->isNumber())
{
std::ostringstream stream;
stream << outputFrameRateValue->asNumber();
mConfig.outputFrameRate = stream.str();
}
}
@@ -1480,8 +1519,10 @@ JsonValue RuntimeHost::BuildStateValue() const
app.set("autoReload", JsonValue(mAutoReloadEnabled));
app.set("maxTemporalHistoryFrames", JsonValue(static_cast<double>(mConfig.maxTemporalHistoryFrames)));
app.set("enableExternalKeying", JsonValue(mConfig.enableExternalKeying));
app.set("videoFormat", JsonValue(mConfig.videoFormat));
app.set("frameRate", JsonValue(mConfig.frameRate));
app.set("inputVideoFormat", JsonValue(mConfig.inputVideoFormat));
app.set("inputFrameRate", JsonValue(mConfig.inputFrameRate));
app.set("outputVideoFormat", JsonValue(mConfig.outputVideoFormat));
app.set("outputFrameRate", JsonValue(mConfig.outputFrameRate));
root.set("app", app);
JsonValue runtime = JsonValue::MakeObject();

View File

@@ -52,8 +52,10 @@ public:
unsigned short GetOscPort() const { return mConfig.oscPort; }
unsigned GetMaxTemporalHistoryFrames() const { return mConfig.maxTemporalHistoryFrames; }
bool ExternalKeyingEnabled() const { return mConfig.enableExternalKeying; }
const std::string& GetVideoFormat() const { return mConfig.videoFormat; }
const std::string& GetFrameRate() const { return mConfig.frameRate; }
const std::string& GetInputVideoFormat() const { return mConfig.inputVideoFormat; }
const std::string& GetInputFrameRate() const { return mConfig.inputFrameRate; }
const std::string& GetOutputVideoFormat() const { return mConfig.outputVideoFormat; }
const std::string& GetOutputFrameRate() const { return mConfig.outputFrameRate; }
void SetServerPort(unsigned short port);
bool AutoReloadEnabled() const { return mAutoReloadEnabled; }
@@ -66,8 +68,10 @@ private:
bool autoReload = true;
unsigned maxTemporalHistoryFrames = 4;
bool enableExternalKeying = false;
std::string videoFormat = "1080p";
std::string frameRate = "59.94";
std::string inputVideoFormat = "1080p";
std::string inputFrameRate = "59.94";
std::string outputVideoFormat = "1080p";
std::string outputFrameRate = "59.94";
};
struct DeckLinkOutputStatus

View File

@@ -2,8 +2,10 @@
"shaderLibrary": "shaders",
"serverPort": 8080,
"oscPort": 9000,
"videoFormat": "1080p",
"frameRate": "59.94",
"inputVideoFormat": "1080p",
"inputFrameRate": "59.94",
"outputVideoFormat": "1080p",
"outputFrameRate": "59.94",
"autoReload": true,
"maxTemporalHistoryFrames": 12,
"enableExternalKeying": true

View File

@@ -367,9 +367,13 @@ components:
type: number
enableExternalKeying:
type: boolean
videoFormat:
inputVideoFormat:
type: string
frameRate:
inputFrameRate:
type: string
outputVideoFormat:
type: string
outputFrameRate:
type: string
RuntimeStatus:
type: object

View File

@@ -41,6 +41,33 @@
"max": 175.0,
"step": 0.1
},
{
"id": "basePanDegrees",
"label": "Base Pan",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
},
{
"id": "baseTiltDegrees",
"label": "Base Tilt",
"type": "float",
"default": 0.0,
"min": -120.0,
"max": 120.0,
"step": 0.1
},
{
"id": "baseRollDegrees",
"label": "Base Roll",
"type": "float",
"default": 0.0,
"min": -180.0,
"max": 180.0,
"step": 0.1
},
{
"id": "panDegrees",
"label": "Pan",

View File

@@ -71,6 +71,9 @@ float4 shadeVideo(ShaderContext context)
? buildCylindricalRay(screen, outputAspect, tanHalfFov)
: buildRectilinearRay(screen, outputAspect, tanHalfFov);
ray = rotateZ(ray, radiansFromDegrees(baseRollDegrees));
ray = rotateX(ray, radiansFromDegrees(-baseTiltDegrees));
ray = rotateY(ray, radiansFromDegrees(basePanDegrees));
ray = rotateZ(ray, radiansFromDegrees(rollDegrees));
ray = rotateX(ray, radiansFromDegrees(-tiltDegrees));
ray = rotateY(ray, radiansFromDegrees(panDegrees));

View File

@@ -43,6 +43,14 @@ void AppendFloat32(std::vector<char>& packet, float value)
AppendInt32(packet, static_cast<int>(bits));
}
void AppendFloat64(std::vector<char>& packet, double value)
{
uint64_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
for (int shift = 56; shift >= 0; shift -= 8)
packet.push_back(static_cast<char>((bits >> shift) & 0xff));
}
std::vector<char> BuildOscPacket(const std::string& address, const std::string& typeTags)
{
std::vector<char> packet;
@@ -89,6 +97,19 @@ void TestDecodeFloatMessage()
Expect(message.valueJson.find("0.75") == 0, "float OSC value becomes JSON number");
}
void TestDecodeDoubleMessage()
{
OscServer server;
std::vector<char> packet = BuildOscPacket("/VideoShaderToys/fisheye-reproject/panDegrees", ",d");
AppendFloat64(packet, 51.5);
OscServerTestAccess::Message message;
std::string error;
Expect(OscServerTestAccess::Decode(server, packet, message, error), "double OSC message decodes");
Expect(message.address == "/VideoShaderToys/fisheye-reproject/panDegrees", "double OSC address is preserved");
Expect(message.valueJson.find("51.5") == 0, "double OSC value becomes JSON number");
}
void TestDecodeIntStringAndBoolMessages()
{
OscServer server;
@@ -161,6 +182,7 @@ void TestRejectsUnsupportedAddress()
int main()
{
TestDecodeFloatMessage();
TestDecodeDoubleMessage();
TestDecodeIntStringAndBoolMessages();
TestDispatchValidAddress();
TestRejectsUnsupportedAddress();

View File

@@ -2,6 +2,7 @@ import { GripVertical, Trash2 } from "lucide-react";
import { postJson } from "../api/controlApi";
import { ParameterField } from "./ParameterField";
import { ShaderPicker } from "./ShaderPicker";
export function LayerCard({
layer,
@@ -90,23 +91,17 @@ export function LayerCard({
{expanded ? (
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor={`shader-${layer.id}`}>Shader</label>
<select
<ShaderPicker
id={`shader-${layer.id}`}
shaders={shaders}
value={layer.shaderId}
onChange={(event) =>
onChange={(shaderId) =>
postJson("/api/layers/set-shader", {
layerId: layer.id,
shaderId: event.target.value,
shaderId,
})
}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
/>
</div>
{layer.temporal?.enabled ? (
@@ -139,6 +134,7 @@ export function LayerCard({
{layer.parameters.map((parameter) => (
<ParameterField
key={`${layer.id}:${parameter.id}`}
layer={layer}
parameter={parameter}
onParameterChange={(parameterId, value) => onLayerParameterChange(layer.id, parameterId, value)}
/>

View File

@@ -1,5 +1,6 @@
import { postJson } from "../api/controlApi";
import { LayerCard } from "./LayerCard";
import { ShaderPicker } from "./ShaderPicker";
function moveItem(array, fromIndex, toIndex) {
if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
@@ -131,18 +132,12 @@ export function LayerStack({
</div>
<div className="layer-card__body">
<div className="layer-card__field">
<label htmlFor="add-layer-select">Shader</label>
<select
id="add-layer-select"
<ShaderPicker
id="add-layer"
shaders={shaders}
value={pendingShaderId}
onChange={(event) => setPendingShaderId(event.target.value)}
>
{shaders.map((shader) => (
<option key={shader.id} value={shader.id}>
{shader.name}
</option>
))}
</select>
onChange={setPendingShaderId}
/>
</div>
</div>
</div>

View File

@@ -1,7 +1,64 @@
import { Copy } from "lucide-react";
import { useThrottledParameterValue } from "../hooks/useThrottledParameterValue";
import { ParameterValueDisplay } from "./ParameterValueDisplay";
export function ParameterField({ parameter, onParameterChange }) {
function ParameterHeader({ layer, parameter }) {
const layerKey = layer.shaderId || layer.shaderName || layer.id;
const oscRoute = `/VideoShaderToys/${layerKey}/${parameter.id}`;
function copyRoute() {
if (navigator.clipboard) {
navigator.clipboard.writeText(oscRoute);
}
}
return (
<div className="parameter__header">
<label>{parameter.label}</label>
<button
type="button"
className="parameter__osc"
title="Copy OSC route"
aria-label={`Copy OSC route ${oscRoute}`}
onClick={copyRoute}
>
<span>{oscRoute}</span>
<Copy size={13} strokeWidth={1.75} aria-hidden="true" />
</button>
</div>
);
}
function clamp01(value) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
function colorComponentToHex(value) {
return Math.round(clamp01(value) * 255)
.toString(16)
.padStart(2, "0");
}
function colorValueToHex(value) {
const values = [...(value ?? [])];
while (values.length < 3) {
values.push(0);
}
return `#${colorComponentToHex(values[0])}${colorComponentToHex(values[1])}${colorComponentToHex(values[2])}`;
}
function hexToColorValue(hex, alpha) {
const sanitized = /^#[0-9a-fA-F]{6}$/.test(hex) ? hex.slice(1) : "000000";
return [
parseInt(sanitized.slice(0, 2), 16) / 255,
parseInt(sanitized.slice(2, 4), 16) / 255,
parseInt(sanitized.slice(4, 6), 16) / 255,
clamp01(alpha ?? 1),
];
}
export function ParameterField({ layer, parameter, onParameterChange }) {
const {
appliedValue,
beginInteraction,
@@ -12,12 +69,12 @@ export function ParameterField({ parameter, onParameterChange }) {
sendValue,
} = useThrottledParameterValue(parameter, onParameterChange);
const label = <label>{parameter.label}</label>;
const header = <ParameterHeader layer={layer} parameter={parameter} />;
if (parameter.type === "float") {
return (
<section className="parameter">
{label}
{header}
<div className="parameter__pair">
<input
type="range"
@@ -52,18 +109,17 @@ export function ParameterField({ parameter, onParameterChange }) {
);
}
if (parameter.type === "vec2" || parameter.type === "color") {
const componentCount = parameter.type === "color" ? 4 : 2;
if (parameter.type === "vec2") {
const values = [...(draftValue ?? [])];
while (values.length < componentCount) {
while (values.length < 2) {
values.push(0);
}
return (
<section className="parameter">
{label}
{header}
<div className="parameter__pair">
{Array.from({ length: componentCount }, (_, index) => (
{Array.from({ length: 2 }, (_, index) => (
<input
key={index}
type="number"
@@ -86,10 +142,50 @@ export function ParameterField({ parameter, onParameterChange }) {
);
}
if (parameter.type === "color") {
const values = [...(draftValue ?? [])];
while (values.length < 4) {
values.push(values.length === 3 ? 1 : 0);
}
return (
<section className="parameter">
{header}
<div className="parameter__color-row">
<input
type="color"
value={colorValueToHex(values)}
onFocus={beginInteraction}
onChange={(event) => sendValue(hexToColorValue(event.target.value, values[3]))}
onBlur={endInteraction}
/>
<label className="parameter__alpha">
<span>Alpha</span>
<input
type="number"
min={parameter.min?.[3] ?? 0}
max={parameter.max?.[3] ?? 1}
step={parameter.step?.[3] ?? 0.01}
value={values[3]}
onFocus={beginInteraction}
onChange={(event) => {
const next = [...values];
next[3] = Number(event.target.value);
sendValue(next);
}}
onBlur={endInteraction}
/>
</label>
</div>
<ParameterValueDisplay parameterType={parameter.type} value={appliedValue} pending={isPending} />
</section>
);
}
if (parameter.type === "bool") {
return (
<section className="parameter">
{label}
{header}
<label className="toggle toggle--field">
<input
type="checkbox"
@@ -108,7 +204,7 @@ export function ParameterField({ parameter, onParameterChange }) {
if (parameter.type === "enum") {
return (
<section className="parameter">
{label}
{header}
<select
value={draftValue}
onFocus={beginInteraction}

View File

@@ -0,0 +1,94 @@
import { ChevronDown, Search } from "lucide-react";
import { useMemo, useState } from "react";
function matchesShader(shader, query) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return true;
}
return [shader.name, shader.id, shader.category, shader.description]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedQuery));
}
export function ShaderPicker({ id, label = "Shader", shaders, value, onChange }) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const filteredShaders = useMemo(
() => shaders.filter((shader) => matchesShader(shader, query)),
[query, shaders],
);
const selectedShader = shaders.find((shader) => shader.id === value);
return (
<div className="shader-picker">
<div className="shader-picker__topline">
<label id={`${id}-label`}>{label}</label>
{selectedShader ? <span className="shader-picker__selected">{selectedShader.name}</span> : null}
</div>
<button
type="button"
className="shader-picker__trigger"
aria-labelledby={`${id}-label`}
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span>
<span className="shader-picker__name">{selectedShader?.name ?? "Choose shader"}</span>
<span className="shader-picker__meta">
{selectedShader
? `${selectedShader.category ? `${selectedShader.category} / ` : ""}${selectedShader.id}`
: "Search available shaders"}
</span>
</span>
<ChevronDown size={16} strokeWidth={1.75} aria-hidden="true" />
</button>
{open ? (
<div className="shader-picker__popover">
<div className="shader-picker__search">
<Search size={16} strokeWidth={1.75} aria-hidden="true" />
<input
id={`${id}-search`}
type="text"
value={query}
placeholder="Search shaders"
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className="shader-picker__list" role="listbox" aria-label={label}>
{filteredShaders.length > 0 ? (
filteredShaders.map((shader) => (
<button
key={shader.id}
type="button"
className={`shader-picker__option${shader.id === value ? " shader-picker__option--selected" : ""}`}
role="option"
aria-selected={shader.id === value}
onClick={() => {
onChange(shader.id);
setOpen(false);
setQuery("");
}}
>
<span className="shader-picker__name">{shader.name}</span>
<span className="shader-picker__meta">
{shader.category ? `${shader.category} / ` : ""}
{shader.id}
</span>
</button>
))
) : (
<div className="shader-picker__empty">No shaders found</div>
)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -29,8 +29,9 @@ export function StatusPanels({ app, performance, runtime, video }) {
<KvList
values={[
["Signal", video.hasSignal ? "Present" : "Missing"],
["Mode", video.modeName || "Unknown"],
["Resolution", `${video.width || 0} x ${video.height || 0}`],
["Input Mode", video.modeName || "Unknown"],
["Input Resolution", `${video.width || 0} x ${video.height || 0}`],
["Output Mode", `${app.outputVideoFormat || "Unknown"}${app.outputFrameRate ? ` ${app.outputFrameRate}` : ""}`],
]}
/>
</div>

View File

@@ -48,6 +48,15 @@ input[type="range"] {
width: 100%;
}
input[type="color"] {
width: 100%;
min-height: 38px;
border-radius: 6px;
border: 1px solid #303a4d;
background: #101722;
padding: 4px;
}
input[type="number"],
input[type="text"],
select,
@@ -347,6 +356,128 @@ pre {
gap: 8px;
}
.shader-picker {
display: grid;
gap: 8px;
min-width: 0;
}
.shader-picker__topline {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.shader-picker__selected {
min-width: 0;
color: #98aad0;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shader-picker__search {
position: relative;
display: flex;
align-items: center;
}
.shader-picker__search svg {
position: absolute;
left: 10px;
color: #98aad0;
pointer-events: none;
}
.shader-picker__search input {
padding-left: 34px;
}
.shader-picker__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 54px;
padding: 8px 10px;
text-align: left;
background: #121b28;
border-color: #26364e;
}
.shader-picker__trigger > span {
display: grid;
gap: 2px;
min-width: 0;
}
.shader-picker__trigger svg {
flex: 0 0 auto;
color: #98aad0;
}
.shader-picker__popover {
display: grid;
gap: 8px;
padding: 8px;
border: 1px solid #273246;
border-radius: 8px;
background: #0d141f;
}
.shader-picker__list {
display: grid;
gap: 6px;
max-height: 220px;
overflow-y: auto;
padding: 6px;
border: 1px solid #273246;
border-radius: 6px;
background: #0c121b;
}
.shader-picker__option {
display: grid;
gap: 2px;
min-height: 58px;
padding: 8px 10px;
text-align: left;
background: #121b28;
border-color: #26364e;
align-content: center;
line-height: 1.25;
}
.shader-picker__option--selected {
background: #233b5f;
border-color: #6d95d8;
box-shadow: inset 0 0 0 1px rgba(109, 149, 216, 0.25);
}
.shader-picker__name,
.shader-picker__meta {
min-width: 0;
overflow: hidden;
overflow-wrap: anywhere;
}
.shader-picker__name {
font-weight: 700;
}
.shader-picker__meta,
.shader-picker__empty {
color: #98aad0;
font-size: 12px;
}
.shader-picker__empty {
padding: 10px;
}
.layer-card__subheader button {
width: auto;
min-width: 96px;
@@ -354,19 +485,58 @@ pre {
.parameter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 10px;
}
.parameter {
display: grid;
gap: 8px;
padding: 12px;
gap: 7px;
padding: 10px;
border: 1px solid #273246;
border-radius: 8px;
background: #0f151f;
}
.parameter__header {
display: grid;
grid-template-columns: minmax(90px, auto) minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.parameter__osc {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
width: auto;
min-width: 0;
min-height: 24px;
padding: 2px 0;
border: 0;
background: transparent;
color: #98aad0;
font-size: 11px;
font-weight: 500;
}
.parameter__osc:hover:not(:disabled) {
background: transparent;
color: #c4d6f7;
}
.parameter__osc span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parameter__osc svg {
flex: 0 0 auto;
}
.parameter__value {
color: #98aad0;
font-size: 12px;
@@ -378,8 +548,28 @@ pre {
.parameter__pair {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(auto-fit, minmax(82px, 1fr));
gap: 8px;
align-items: center;
}
.parameter__pair input[type="range"] {
min-width: 120px;
}
.parameter__color-row {
display: grid;
grid-template-columns: minmax(92px, 0.42fr) minmax(120px, 0.58fr);
gap: 8px;
align-items: end;
}
.parameter__alpha {
display: grid;
gap: 4px;
color: #98aad0;
font-size: 11px;
font-weight: 600;
}
.toggle {