diff --git a/CMakeLists.txt b/CMakeLists.txt index 8228721..b1bc11c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -87,6 +87,7 @@ target_link_libraries(LoopThroughWithOpenGLCompositing PRIVATE Ws2_32 Crypt32 Advapi32 + Gdiplus ) target_compile_definitions(LoopThroughWithOpenGLCompositing PRIVATE diff --git a/README.md b/README.md index 8c1dab5..8ca92cf 100644 --- a/README.md +++ b/README.md @@ -203,9 +203,10 @@ Each shader package lives under: shaders// shader.json shader.slang + optional-font-or-texture-assets ``` -See `SHADER_CONTRACT.md` for the manifest schema, parameter types, texture assets, temporal history support, and the Slang entry point contract. +See `SHADER_CONTRACT.md` for the manifest schema, parameter types, texture assets, font/text assets, temporal history support, and the Slang entry point contract. `shaders/text-overlay/` is the reference live text package and bundles Roboto Regular with its OFL license. ## Generated Files @@ -234,4 +235,8 @@ Audio Fonts genlock Logs -anamorphic desqueeze \ No newline at end of file +anamorphic desqueeze +refactor, cleanup of source files +display URL (Maybe clicakable) for control in the windows app (Not on the output) +Sound shader as seperate .slang in shader package? +runtime date time diff --git a/SHADER_CONTRACT.md b/SHADER_CONTRACT.md index fa1c460..58b2305 100644 --- a/SHADER_CONTRACT.md +++ b/SHADER_CONTRACT.md @@ -73,6 +73,7 @@ Optional fields: - `category`: UI grouping label. - `entryPoint`: Slang function to call. Defaults to `shadeVideo`. - `textures`: texture assets to load and expose as samplers. +- `fonts`: packaged font assets for live text parameters. - `temporal`: history-buffer requirements. Shader-visible identifiers must be valid Slang-style identifiers: @@ -80,6 +81,7 @@ Shader-visible identifiers must be valid Slang-style identifiers: - `entryPoint` - parameter `id` - texture `id` +- font `id` Use letters, numbers, and underscores only, and start with a letter or underscore. For example, `logoTexture` is valid; `logo-texture` is not valid as a shader-visible texture ID. @@ -180,6 +182,7 @@ Supported types: | `color` | `float4` | `[r, g, b, a]` | | `bool` | `bool` | `true` or `false` | | `enum` | `int` | selected option index | +| `text` | generated texture/helper | string | Float example: @@ -278,12 +281,42 @@ else if (mode == 2) } ``` +Text example: + +```json +{ + "fonts": [ + { "id": "inter", "path": "fonts/Inter-Regular.ttf" } + ], + "parameters": [ + { + "id": "titleText", + "label": "Title", + "type": "text", + "default": "LIVE", + "font": "inter", + "maxLength": 64 + } + ] +} +``` + +Text parameters are runtime-owned strings. They are not emitted as uniform values. Instead, the runtime renders the current string into a single-line SDF mask texture and the shader wrapper exposes helpers based on the parameter id: + +```slang +float mask = sampleTitleText(textUv); +float4 premultipliedText = drawTitleText(textUv, float4(1.0, 1.0, 1.0, 1.0)); +``` + +Text is currently limited to printable ASCII. `maxLength` defaults to `64` and is clamped to `1..256`. The optional `font` field references a packaged font declared in `fonts`; if no font is specified, the runtime uses its fallback sans-serif renderer. + Parameter validation: - Float values are clamped to `min`/`max` if provided. - `vec2` must have exactly 2 numbers. - `color` must have exactly 4 numbers. - Enum defaults must match one of the declared option values. +- Text defaults must be strings. Non-printable characters are dropped and values are clamped to `maxLength`. - Non-finite numeric values are rejected. ## Texture Assets @@ -323,6 +356,31 @@ return float4(logo.rgb * alpha, alpha); See `shaders/dvd-bounce/` for a complete texture-driven example. +## Font Assets + +Declare packaged font assets in the manifest: + +```json +{ + "fonts": [ + { + "id": "inter", + "path": "fonts/Inter-Regular.ttf" + } + ] +} +``` + +Rules: + +- `id` must be a valid shader identifier. +- `path` is relative to the shader package directory. +- The file must exist when the manifest is loaded. +- Font asset changes trigger shader reload. +- V1 text layout is single-line; shaders position and scale the generated text texture themselves. + +See `shaders/text-overlay/` for a complete live text example. The sample bundles Roboto Regular and includes its OFL license beside the font file. + ## Temporal Shaders Temporal shaders can request access to previous frames. @@ -401,6 +459,7 @@ These files are ignored by git and are useful for debugging compiler output. If - Do not write a `[shader("fragment")]` entry point in `shader.slang`; the runtime provides it. - Remember enum globals are integer indexes, not strings. - Declare every texture in `shader.json`; undeclared texture samplers will not be bound. +- Declare packaged fonts in `shader.json` when text parameters should use a specific font. - Keep temporal history requests modest. They consume texture units and memory and are capped by runtime config. - If a parameter appears in the UI but not in Slang, the shader may still compile, but the control has no effect. - If a Slang name collides with a generated global, rename your parameter or local symbol. @@ -414,6 +473,7 @@ Before committing a new shader package: - `entryPoint`, parameter IDs, and texture IDs are valid identifiers. - `shader.slang` implements the configured entry point. - Texture files referenced by `textures` exist. +- Font files referenced by `fonts` exist. - Enum defaults are present in their `options`. - Temporal shaders handle short or empty history gracefully. - The app can reload and compile the shader without errors. diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp index eb1caf6..cd8f1fa 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.cpp @@ -47,7 +47,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -64,6 +67,9 @@ constexpr GLuint kSourceHistoryTextureUnitBase = 2; constexpr GLuint kPackedVideoTextureUnit = 2; constexpr GLuint kGlobalParamsBindingPoint = 0; constexpr unsigned kPrerollFrameCount = 8; +constexpr unsigned kTextTextureWidth = 1024; +constexpr unsigned kTextTextureHeight = 128; +constexpr int kTextSdfSpread = 10; const char* kVertexShaderSource = "#version 430 core\n" "out vec2 vTexCoord;\n" @@ -108,6 +114,156 @@ void CopyErrorMessage(const std::string& message, int errorMessageSize, char* er strncpy_s(errorMessage, errorMessageSize, message.c_str(), _TRUNCATE); } +std::wstring Utf8ToWide(const std::string& text) +{ + if (text.empty()) + return std::wstring(); + const int required = MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, NULL, 0); + if (required <= 1) + return std::wstring(); + std::wstring wide(static_cast(required - 1), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), -1, wide.data(), required); + return wide; +} + +std::string TextValueForBinding(const RuntimeRenderState& state, const std::string& parameterId) +{ + auto valueIt = state.parameterValues.find(parameterId); + return valueIt == state.parameterValues.end() ? std::string() : valueIt->second.textValue; +} + +const ShaderFontAsset* FindFontAssetForParameter(const RuntimeRenderState& state, const ShaderParameterDefinition& definition) +{ + if (!definition.fontId.empty()) + { + for (const ShaderFontAsset& fontAsset : state.fontAssets) + { + if (fontAsset.id == definition.fontId) + return &fontAsset; + } + } + return state.fontAssets.empty() ? nullptr : &state.fontAssets.front(); +} + +std::vector BuildLocalSdf(const std::vector& alpha, unsigned width, unsigned height) +{ + std::vector sdf(static_cast(width) * height * 4, 0); + for (unsigned y = 0; y < height; ++y) + { + for (unsigned x = 0; x < width; ++x) + { + const bool inside = alpha[static_cast(y) * width + x] > 127; + int bestDistanceSq = kTextSdfSpread * kTextSdfSpread; + for (int oy = -kTextSdfSpread; oy <= kTextSdfSpread; ++oy) + { + const int sy = static_cast(y) + oy; + if (sy < 0 || sy >= static_cast(height)) + continue; + for (int ox = -kTextSdfSpread; ox <= kTextSdfSpread; ++ox) + { + const int sx = static_cast(x) + ox; + if (sx < 0 || sx >= static_cast(width)) + continue; + const bool sampleInside = alpha[static_cast(sy) * width + sx] > 127; + if (sampleInside == inside) + continue; + const int distanceSq = ox * ox + oy * oy; + if (distanceSq < bestDistanceSq) + bestDistanceSq = distanceSq; + } + } + + const float distance = std::sqrt(static_cast(bestDistanceSq)); + const float signedDistance = (inside ? 1.0f : -1.0f) * distance; + float normalized = 0.5f + signedDistance / static_cast(kTextSdfSpread * 2); + if (normalized < 0.0f) + normalized = 0.0f; + if (normalized > 1.0f) + normalized = 1.0f; + const unsigned char value = static_cast(normalized * 255.0f + 0.5f); + const std::size_t out = (static_cast(y) * width + x) * 4; + sdf[out + 0] = value; + sdf[out + 1] = value; + sdf[out + 2] = value; + sdf[out + 3] = value; + } + } + return sdf; +} + +bool RasterizeTextSdf(const std::string& text, const std::filesystem::path& fontPath, std::vector& sdf, std::string& error) +{ + ULONG_PTR gdiplusToken = 0; + Gdiplus::GdiplusStartupInput startupInput; + if (Gdiplus::GdiplusStartup(&gdiplusToken, &startupInput, NULL) != Gdiplus::Ok) + { + error = "Could not start GDI+ for text rendering."; + return false; + } + + Gdiplus::PrivateFontCollection fontCollection; + Gdiplus::FontFamily fallbackFamily(L"Arial"); + Gdiplus::FontFamily* fontFamily = &fallbackFamily; + std::unique_ptr families; + const std::wstring wideFontPath = fontPath.empty() ? std::wstring() : fontPath.wstring(); + if (!wideFontPath.empty()) + { + if (fontCollection.AddFontFile(wideFontPath.c_str()) != Gdiplus::Ok) + { + Gdiplus::GdiplusShutdown(gdiplusToken); + error = "Could not load packaged font file for text rendering: " + fontPath.string(); + return false; + } + + const INT familyCount = fontCollection.GetFamilyCount(); + if (familyCount <= 0) + { + Gdiplus::GdiplusShutdown(gdiplusToken); + error = "Packaged font did not contain a usable font family: " + fontPath.string(); + return false; + } + + families.reset(new Gdiplus::FontFamily[familyCount]); + INT found = 0; + if (fontCollection.GetFamilies(familyCount, families.get(), &found) != Gdiplus::Ok || found <= 0) + { + Gdiplus::GdiplusShutdown(gdiplusToken); + error = "Could not read the packaged font family: " + fontPath.string(); + return false; + } + fontFamily = &families[0]; + } + + Gdiplus::Bitmap bitmap(kTextTextureWidth, kTextTextureHeight, PixelFormat32bppARGB); + Gdiplus::Graphics graphics(&bitmap); + graphics.Clear(Gdiplus::Color(0, 0, 0, 0)); + graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeHighQuality); + Gdiplus::Font font(fontFamily, 72.0f, Gdiplus::FontStyleRegular, Gdiplus::UnitPixel); + Gdiplus::SolidBrush brush(Gdiplus::Color(255, 255, 255, 255)); + Gdiplus::StringFormat format; + format.SetAlignment(Gdiplus::StringAlignmentNear); + format.SetLineAlignment(Gdiplus::StringAlignmentCenter); + format.SetFormatFlags(Gdiplus::StringFormatFlagsNoWrap | Gdiplus::StringFormatFlagsMeasureTrailingSpaces); + const Gdiplus::RectF layout(24.0f, 0.0f, static_cast(kTextTextureWidth - 48), static_cast(kTextTextureHeight)); + const std::wstring wideText = Utf8ToWide(text); + graphics.DrawString(wideText.c_str(), -1, &font, layout, &format, &brush); + + std::vector alpha(static_cast(kTextTextureWidth) * kTextTextureHeight, 0); + for (unsigned y = 0; y < kTextTextureHeight; ++y) + { + for (unsigned x = 0; x < kTextTextureWidth; ++x) + { + Gdiplus::Color pixel; + bitmap.GetPixel(x, y, &pixel); + alpha[static_cast(y) * kTextTextureWidth + x] = pixel.GetAlpha(); + } + } + sdf = BuildLocalSdf(alpha, kTextTextureWidth, kTextTextureHeight); + Gdiplus::GdiplusShutdown(gdiplusToken); + return true; +} + std::string NormalizeModeToken(const std::string& value) { std::string normalized; @@ -1488,6 +1644,26 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state, } textureBindings.push_back(textureBinding); } + std::vector textBindings; + for (const ShaderParameterDefinition& definition : state.parameterDefinitions) + { + if (definition.type != ShaderParameterType::Text) + continue; + LayerProgram::TextBinding textBinding; + textBinding.parameterId = definition.id; + textBinding.samplerName = definition.id + "Texture"; + textBinding.fontId = definition.fontId; + glGenTextures(1, &textBinding.texture); + glBindTexture(GL_TEXTURE_2D, textBinding.texture); + 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); + std::vector empty(static_cast(kTextTextureWidth) * kTextTextureHeight * 4, 0); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, kTextTextureWidth, kTextTextureHeight, 0, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, empty.data()); + glBindTexture(GL_TEXTURE_2D, 0); + textBindings.push_back(textBinding); + } const GLuint globalParamsIndex = glGetUniformBlockIndex(newProgram.get(), "GlobalParams"); if (globalParamsIndex != GL_INVALID_INDEX) @@ -1517,6 +1693,13 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state, if (textureSamplerLocation >= 0) glUniform1i(textureSamplerLocation, static_cast(shaderTextureBase + static_cast(index))); } + const GLuint textTextureBase = shaderTextureBase + static_cast(textureBindings.size()); + for (std::size_t index = 0; index < textBindings.size(); ++index) + { + const GLint textSamplerLocation = glGetUniformLocation(newProgram.get(), textBindings[index].samplerName.c_str()); + if (textSamplerLocation >= 0) + glUniform1i(textSamplerLocation, static_cast(textTextureBase + static_cast(index))); + } glUseProgram(0); layerProgram.layerId = state.layerId; @@ -1525,6 +1708,7 @@ bool OpenGLComposite::compileSingleLayerProgram(const RuntimeRenderState& state, layerProgram.vertexShader = newVertexShader.release(); layerProgram.fragmentShader = newFragmentShader.release(); layerProgram.textureBindings.swap(textureBindings); + layerProgram.textBindings.swap(textBindings); return true; } @@ -1626,6 +1810,15 @@ void OpenGLComposite::destroySingleLayerProgram(LayerProgram& layerProgram) } } layerProgram.textureBindings.clear(); + for (LayerProgram::TextBinding& textBinding : layerProgram.textBindings) + { + if (textBinding.texture != 0) + { + glDeleteTextures(1, &textBinding.texture); + textBinding.texture = 0; + } + } + layerProgram.textBindings.clear(); if (layerProgram.program != 0) { @@ -1759,6 +1952,35 @@ bool OpenGLComposite::loadTextureAsset(const ShaderTextureAsset& textureAsset, G return true; } +bool OpenGLComposite::renderTextBindingTexture(const RuntimeRenderState& state, LayerProgram::TextBinding& textBinding, std::string& error) +{ + const std::string text = TextValueForBinding(state, textBinding.parameterId); + if (text == textBinding.renderedText && textBinding.renderedWidth == kTextTextureWidth && textBinding.renderedHeight == kTextTextureHeight) + return true; + + auto definitionIt = std::find_if(state.parameterDefinitions.begin(), state.parameterDefinitions.end(), + [&textBinding](const ShaderParameterDefinition& definition) { return definition.id == textBinding.parameterId; }); + if (definitionIt == state.parameterDefinitions.end()) + return true; + + const ShaderFontAsset* fontAsset = FindFontAssetForParameter(state, *definitionIt); + std::filesystem::path fontPath; + if (fontAsset) + fontPath = fontAsset->path; + + std::vector sdf; + if (!RasterizeTextSdf(text, fontPath, sdf, error)) + return false; + + glBindTexture(GL_TEXTURE_2D, textBinding.texture); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kTextTextureWidth, kTextTextureHeight, GL_BGRA, GL_UNSIGNED_INT_8_8_8_8_REV, sdf.data()); + glBindTexture(GL_TEXTURE_2D, 0); + textBinding.renderedText = text; + textBinding.renderedWidth = kTextTextureWidth; + textBinding.renderedHeight = kTextTextureHeight; + return true; +} + void OpenGLComposite::bindLayerTextureAssets(const LayerProgram& layerProgram) { const unsigned historyCap = mRuntimeHost ? mRuntimeHost->GetMaxTemporalHistoryFrames() : 0; @@ -1768,6 +1990,12 @@ void OpenGLComposite::bindLayerTextureAssets(const LayerProgram& layerProgram) glActiveTexture(GL_TEXTURE0 + shaderTextureBase + static_cast(index)); glBindTexture(GL_TEXTURE_2D, layerProgram.textureBindings[index].texture); } + const GLuint textTextureBase = shaderTextureBase + static_cast(layerProgram.textureBindings.size()); + for (std::size_t index = 0; index < layerProgram.textBindings.size(); ++index) + { + glActiveTexture(GL_TEXTURE0 + textTextureBase + static_cast(index)); + glBindTexture(GL_TEXTURE_2D, layerProgram.textBindings[index].texture); + } glActiveTexture(GL_TEXTURE0); } @@ -1798,8 +2026,15 @@ bool OpenGLComposite::validateTemporalTextureUnitBudget(const std::vector maxAssetTextures) - maxAssetTextures = static_cast(state.textureAssets.size()); + unsigned textTextureCount = 0; + for (const ShaderParameterDefinition& definition : state.parameterDefinitions) + { + if (definition.type == ShaderParameterType::Text) + ++textTextureCount; + } + const unsigned totalShaderTextures = static_cast(state.textureAssets.size()) + textTextureCount; + if (totalShaderTextures > maxAssetTextures) + maxAssetTextures = totalShaderTextures; } GLint maxTextureUnits = 0; glGetIntegerv(GL_MAX_TEXTURE_IMAGE_UNITS, &maxTextureUnits); @@ -2062,8 +2297,15 @@ void OpenGLComposite::renderEffect() VideoFrameTransfer::endTextureInUse(VideoFrameTransfer::CPUtoGPU); } -void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, const LayerProgram& layerProgram, const RuntimeRenderState& state) +void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, LayerProgram& layerProgram, const RuntimeRenderState& state) { + for (LayerProgram::TextBinding& textBinding : layerProgram.textBindings) + { + std::string textError; + if (!renderTextBindingTexture(state, textBinding, textError)) + OutputDebugStringA((textError + "\n").c_str()); + } + glBindFramebuffer(GL_FRAMEBUFFER, destinationFrameBuffer); glViewport(0, 0, mInputFrameWidth, mInputFrameHeight); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); @@ -2086,7 +2328,7 @@ void OpenGLComposite::renderShaderProgram(GLuint sourceTexture, GLuint destinati glBindTexture(GL_TEXTURE_2D, 0); } const GLuint shaderTextureBase = kSourceHistoryTextureUnitBase + historyCap + historyCap; - for (std::size_t index = 0; index < layerProgram.textureBindings.size(); ++index) + for (std::size_t index = 0; index < layerProgram.textureBindings.size() + layerProgram.textBindings.size(); ++index) { glActiveTexture(GL_TEXTURE0 + shaderTextureBase + static_cast(index)); glBindTexture(GL_TEXTURE_2D, 0); @@ -2223,6 +2465,8 @@ bool OpenGLComposite::updateGlobalParamsBuffer(const RuntimeRenderState& state, AppendStd140Int(buffer, selectedIndex); break; } + case ShaderParameterType::Text: + break; } } diff --git a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h index c5f74ed..2936f62 100644 --- a/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h +++ b/apps/LoopThroughWithOpenGLCompositing/OpenGLComposite.h @@ -167,12 +167,24 @@ private: GLuint texture = 0; }; + struct TextBinding + { + std::string parameterId; + std::string samplerName; + std::string fontId; + GLuint texture = 0; + std::string renderedText; + unsigned renderedWidth = 0; + unsigned renderedHeight = 0; + }; + std::string layerId; std::string shaderId; GLuint program = 0; GLuint vertexShader = 0; GLuint fragmentShader = 0; std::vector textureBindings; + std::vector textBindings; }; std::vector mLayerPrograms; @@ -203,8 +215,9 @@ private: void destroySingleLayerProgram(LayerProgram& layerProgram); void destroyDecodeShaderProgram(); void renderDecodePass(); - void renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, const LayerProgram& layerProgram, const RuntimeRenderState& state); + void renderShaderProgram(GLuint sourceTexture, GLuint destinationFrameBuffer, LayerProgram& layerProgram, const RuntimeRenderState& state); bool loadTextureAsset(const ShaderTextureAsset& textureAsset, GLuint& textureId, std::string& error); + bool renderTextBindingTexture(const RuntimeRenderState& state, LayerProgram::TextBinding& textBinding, std::string& error); void bindLayerTextureAssets(const LayerProgram& layerProgram); void renderEffect(); bool PollRuntimeChanges(); diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp index b95f6ee..8272b21 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeHost.cpp @@ -133,6 +133,7 @@ std::string ShaderParameterTypeToString(ShaderParameterType type) case ShaderParameterType::Color: return "color"; case ShaderParameterType::Boolean: return "bool"; case ShaderParameterType::Enum: return "enum"; + case ShaderParameterType::Text: return "text"; } return "unknown"; } @@ -179,6 +180,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType& type = ShaderParameterType::Enum; return true; } + if (typeName == "text") + { + type = ShaderParameterType::Text; + return true; + } return false; } @@ -200,6 +206,24 @@ bool TextureAssetsEqual(const std::vector& left, const std:: return true; } +bool FontAssetsEqual(const std::vector& left, const std::vector& right) +{ + if (left.size() != right.size()) + return false; + + for (std::size_t index = 0; index < left.size(); ++index) + { + if (left[index].id != right[index].id || + left[index].path != right[index].path || + left[index].writeTime != right[index].writeTime) + { + return false; + } + } + + return true; +} + std::string ManifestPathMessage(const std::filesystem::path& manifestPath) { return manifestPath.string(); @@ -379,6 +403,49 @@ bool ParseTextureAssets(const JsonValue& manifestJson, ShaderPackage& shaderPack return true; } +bool ParseFontAssets(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* fontsValue = nullptr; + if (!OptionalArrayField(manifestJson, "fonts", fontsValue, manifestPath, error)) + return false; + if (!fontsValue) + return true; + + for (const JsonValue& fontJson : fontsValue->asArray()) + { + if (!fontJson.isObject()) + { + error = "Shader font entry must be an object in: " + ManifestPathMessage(manifestPath); + return false; + } + + std::string fontId; + std::string fontPath; + if (!RequireNonEmptyStringField(fontJson, "id", fontId, manifestPath, error) || + !RequireNonEmptyStringField(fontJson, "path", fontPath, manifestPath, error)) + { + error = "Shader font is missing required 'id' or 'path' in: " + ManifestPathMessage(manifestPath); + return false; + } + if (!ValidateShaderIdentifier(fontId, "fonts[].id", manifestPath, error)) + return false; + + ShaderFontAsset fontAsset; + fontAsset.id = fontId; + fontAsset.path = shaderPackage.directoryPath / fontPath; + if (!std::filesystem::exists(fontAsset.path)) + { + error = "Shader font asset not found for package " + shaderPackage.id + ": " + fontAsset.path.string(); + return false; + } + + fontAsset.writeTime = std::filesystem::last_write_time(fontAsset.path); + shaderPackage.fontAssets.push_back(fontAsset); + } + + return true; +} + bool ParseTemporalSettings(const JsonValue& manifestJson, ShaderPackage& shaderPackage, unsigned maxTemporalHistoryFrames, const std::filesystem::path& manifestPath, std::string& error) { const JsonValue* temporalValue = nullptr; @@ -461,6 +528,17 @@ bool ParseParameterDefault(const JsonValue& parameterJson, ShaderParameterDefini return true; } + if (definition.type == ShaderParameterType::Text) + { + if (!defaultValue->isString()) + { + error = "Text parameter default must be a string for: " + definition.id; + return false; + } + definition.defaultTextValue = defaultValue->asString(); + return true; + } + return NumberListFromJsonValue(*defaultValue, definition.defaultNumbers, "default", manifestPath, error); } @@ -543,6 +621,30 @@ bool ParseParameterDefinition(const JsonValue& parameterJson, ShaderParameterDef return false; } + if (definition.type == ShaderParameterType::Text) + { + if (const JsonValue* fontValue = parameterJson.find("font")) + { + if (!fontValue->isString()) + { + error = "Text parameter 'font' must be a string for: " + definition.id; + return false; + } + definition.fontId = fontValue->asString(); + if (!definition.fontId.empty() && !ValidateShaderIdentifier(definition.fontId, "parameters[].font", manifestPath, error)) + return false; + } + if (const JsonValue* maxLengthValue = parameterJson.find("maxLength")) + { + if (!maxLengthValue->isNumber() || maxLengthValue->asNumber() < 1.0 || maxLengthValue->asNumber() > 256.0) + { + error = "Text parameter 'maxLength' must be a number from 1 to 256 for: " + definition.id; + return false; + } + definition.maxLength = static_cast(maxLengthValue->asNumber()); + } + } + if (definition.type == ShaderParameterType::Enum) return ParseParameterOptions(parameterJson, definition, manifestPath, error); @@ -693,7 +795,8 @@ bool RuntimeHost::PollFileChanges(bool& registryChanged, bool& reloadRequested, } if (previous->second.shaderWriteTime != item.second.shaderWriteTime || previous->second.manifestWriteTime != item.second.manifestWriteTime || - !TextureAssetsEqual(previous->second.textureAssets, item.second.textureAssets)) + !TextureAssetsEqual(previous->second.textureAssets, item.second.textureAssets) || + !FontAssetsEqual(previous->second.fontAssets, item.second.fontAssets)) { registryChanged = true; break; @@ -714,7 +817,8 @@ bool RuntimeHost::PollFileChanges(bool& registryChanged, bool& reloadRequested, if (previous->second.first != active->second.shaderWriteTime || previous->second.second != active->second.manifestWriteTime || (previousPackage != previousPackages.end() && - !TextureAssetsEqual(previousPackage->second.textureAssets, active->second.textureAssets))) + (!TextureAssetsEqual(previousPackage->second.textureAssets, active->second.textureAssets) || + !FontAssetsEqual(previousPackage->second.fontAssets, active->second.fontAssets)))) { mReloadRequested = true; } @@ -1143,6 +1247,7 @@ std::vector RuntimeHost::GetLayerRenderStates(unsigned outpu state.outputHeight = outputHeight; state.parameterDefinitions = shaderIt->second.parameters; state.textureAssets = shaderIt->second.textureAssets; + state.fontAssets = shaderIt->second.fontAssets; state.isTemporal = shaderIt->second.temporal.enabled; state.temporalHistorySource = shaderIt->second.temporal.historySource; state.requestedTemporalHistoryLength = shaderIt->second.temporal.requestedHistoryLength; @@ -1447,6 +1552,7 @@ bool RuntimeHost::ParseShaderManifest(const std::filesystem::path& manifestPath, shaderPackage.manifestWriteTime = std::filesystem::last_write_time(shaderPackage.manifestPath); return ParseTextureAssets(manifestJson, shaderPackage, manifestPath, error) && + ParseFontAssets(manifestJson, shaderPackage, manifestPath, error) && ParseTemporalSettings(manifestJson, shaderPackage, mConfig.maxTemporalHistoryFrames, manifestPath, error) && ParseParameterDefinitions(manifestJson, shaderPackage, manifestPath, error); } @@ -1692,6 +1798,12 @@ JsonValue RuntimeHost::SerializeLayerStackLocked() const } parameter.set("options", options); } + if (definition.type == ShaderParameterType::Text) + { + parameter.set("maxLength", JsonValue(static_cast(definition.maxLength))); + if (!definition.fontId.empty()) + parameter.set("font", JsonValue(definition.fontId)); + } ShaderParameterValue value = DefaultValueForDefinition(definition); auto valueIt = layer.parameterValues.find(definition.id); @@ -1830,6 +1942,8 @@ JsonValue RuntimeHost::SerializeParameterValue(const ShaderParameterDefinition& return JsonValue(value.booleanValue); case ShaderParameterType::Enum: return JsonValue(value.enumValue); + case ShaderParameterType::Text: + return JsonValue(value.textValue); case ShaderParameterType::Float: return JsonValue(value.numberValues.empty() ? 0.0 : value.numberValues.front()); case ShaderParameterType::Vec2: diff --git a/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.cpp b/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.cpp index 59de344..006560d 100644 --- a/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/RuntimeParameterUtils.cpp @@ -36,6 +36,21 @@ std::vector JsonArrayToNumbers(const JsonValue& value) } return numbers; } + +std::string NormalizeTextValue(const std::string& text, unsigned maxLength) +{ + std::string normalized; + normalized.reserve(std::min(text.size(), maxLength)); + for (unsigned char ch : text) + { + if (ch < 32 || ch > 126) + continue; + if (normalized.size() >= maxLength) + break; + normalized.push_back(static_cast(ch)); + } + return normalized; +} } std::string MakeSafePresetFileStem(const std::string& presetName) @@ -82,6 +97,9 @@ ShaderParameterValue DefaultValueForDefinition(const ShaderParameterDefinition& case ShaderParameterType::Enum: value.enumValue = definition.defaultEnumValue; break; + case ShaderParameterType::Text: + value.textValue = NormalizeTextValue(definition.defaultTextValue, definition.maxLength); + break; } return value; } @@ -164,6 +182,14 @@ bool NormalizeAndValidateParameterValue(const ShaderParameterDefinition& definit error = "Enum parameter '" + definition.id + "' received unsupported option '" + selectedValue + "'."; return false; } + case ShaderParameterType::Text: + if (!value.isString()) + { + error = "Expected string value for text parameter '" + definition.id + "'."; + return false; + } + normalizedValue.textValue = NormalizeTextValue(value.asString(), definition.maxLength); + return true; } return false; diff --git a/apps/LoopThroughWithOpenGLCompositing/ShaderCompiler.cpp b/apps/LoopThroughWithOpenGLCompositing/ShaderCompiler.cpp index dfa41d9..a015ccc 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ShaderCompiler.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/ShaderCompiler.cpp @@ -4,6 +4,7 @@ #include "NativeHandles.h" #include +#include #include #include #include @@ -30,15 +31,29 @@ std::string SlangCBufferTypeForParameter(ShaderParameterType type) case ShaderParameterType::Color: return "float4"; case ShaderParameterType::Boolean: return "bool"; case ShaderParameterType::Enum: return "int"; + case ShaderParameterType::Text: return ""; } return "float"; } +std::string CapitalizeIdentifier(const std::string& identifier) +{ + if (identifier.empty()) + return identifier; + std::string text = identifier; + text[0] = static_cast(std::toupper(static_cast(text[0]))); + return text; +} + std::string BuildParameterUniforms(const std::vector& parameters) { std::ostringstream source; for (const ShaderParameterDefinition& definition : parameters) + { + if (definition.type == ShaderParameterType::Text) + continue; source << "\t" << SlangCBufferTypeForParameter(definition.type) << " " << definition.id << ";\n"; + } return source.str(); } @@ -60,6 +75,42 @@ std::string BuildTextureSamplerDeclarations(const std::vector& parameters) +{ + std::ostringstream source; + for (const ShaderParameterDefinition& definition : parameters) + { + if (definition.type != ShaderParameterType::Text) + continue; + source << "Sampler2D " << definition.id << "Texture;\n"; + } + if (source.tellp() > 0) + source << "\n"; + return source.str(); +} + +std::string BuildTextHelpers(const std::vector& parameters) +{ + std::ostringstream source; + for (const ShaderParameterDefinition& definition : parameters) + { + if (definition.type != ShaderParameterType::Text) + continue; + const std::string suffix = CapitalizeIdentifier(definition.id); + source + << "float sample" << suffix << "(float2 uv)\n" + << "{\n" + << "\treturn " << definition.id << "Texture.Sample(uv).r;\n" + << "}\n\n" + << "float4 draw" << suffix << "(float2 uv, float4 fillColor)\n" + << "{\n" + << "\tfloat alpha = sample" << suffix << "(uv) * fillColor.a;\n" + << "\treturn float4(fillColor.rgb * alpha, alpha);\n" + << "}\n\n"; + } + return source.str(); +} + std::string BuildHistorySwitchCases(const std::string& samplerPrefix, unsigned historyLength) { std::ostringstream source; @@ -118,6 +169,8 @@ bool ShaderCompiler::BuildWrapperSlangSource(const ShaderPackage& shaderPackage, wrapperSource = ReplaceAll(wrapperSource, "{{SOURCE_HISTORY_SAMPLERS}}", BuildHistorySamplerDeclarations("gSourceHistory", mMaxTemporalHistoryFrames)); wrapperSource = ReplaceAll(wrapperSource, "{{TEMPORAL_HISTORY_SAMPLERS}}", BuildHistorySamplerDeclarations("gTemporalHistory", mMaxTemporalHistoryFrames)); wrapperSource = ReplaceAll(wrapperSource, "{{TEXTURE_SAMPLERS}}", BuildTextureSamplerDeclarations(shaderPackage.textureAssets)); + wrapperSource = ReplaceAll(wrapperSource, "{{TEXT_SAMPLERS}}", BuildTextSamplerDeclarations(shaderPackage.parameters)); + wrapperSource = ReplaceAll(wrapperSource, "{{TEXT_HELPERS}}", BuildTextHelpers(shaderPackage.parameters)); wrapperSource = ReplaceAll(wrapperSource, "{{SOURCE_HISTORY_SWITCH_CASES}}", BuildHistorySwitchCases("gSourceHistory", mMaxTemporalHistoryFrames)); wrapperSource = ReplaceAll(wrapperSource, "{{TEMPORAL_HISTORY_SWITCH_CASES}}", BuildHistorySwitchCases("gTemporalHistory", mMaxTemporalHistoryFrames)); wrapperSource = ReplaceAll(wrapperSource, "{{USER_SHADER_INCLUDE}}", shaderPackage.shaderPath.generic_string()); diff --git a/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.cpp b/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.cpp index de84943..b3322b7 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.cpp +++ b/apps/LoopThroughWithOpenGLCompositing/ShaderPackageRegistry.cpp @@ -67,6 +67,11 @@ bool ParseShaderParameterType(const std::string& typeName, ShaderParameterType& type = ShaderParameterType::Enum; return true; } + if (typeName == "text") + { + type = ShaderParameterType::Text; + return true; + } return false; } @@ -283,6 +288,49 @@ bool ParseTextureAssets(const JsonValue& manifestJson, ShaderPackage& shaderPack return true; } +bool ParseFontAssets(const JsonValue& manifestJson, ShaderPackage& shaderPackage, const std::filesystem::path& manifestPath, std::string& error) +{ + const JsonValue* fontsValue = nullptr; + if (!OptionalArrayField(manifestJson, "fonts", fontsValue, manifestPath, error)) + return false; + if (!fontsValue) + return true; + + for (const JsonValue& fontJson : fontsValue->asArray()) + { + if (!fontJson.isObject()) + { + error = "Shader font entry must be an object in: " + ManifestPathMessage(manifestPath); + return false; + } + + std::string fontId; + std::string fontPath; + if (!RequireNonEmptyStringField(fontJson, "id", fontId, manifestPath, error) || + !RequireNonEmptyStringField(fontJson, "path", fontPath, manifestPath, error)) + { + error = "Shader font is missing required 'id' or 'path' in: " + ManifestPathMessage(manifestPath); + return false; + } + if (!ValidateShaderIdentifier(fontId, "fonts[].id", manifestPath, error)) + return false; + + ShaderFontAsset fontAsset; + fontAsset.id = fontId; + fontAsset.path = shaderPackage.directoryPath / fontPath; + if (!std::filesystem::exists(fontAsset.path)) + { + error = "Shader font asset not found for package " + shaderPackage.id + ": " + fontAsset.path.string(); + return false; + } + + fontAsset.writeTime = std::filesystem::last_write_time(fontAsset.path); + shaderPackage.fontAssets.push_back(fontAsset); + } + + return true; +} + bool ParseTemporalSettings(const JsonValue& manifestJson, ShaderPackage& shaderPackage, unsigned maxTemporalHistoryFrames, const std::filesystem::path& manifestPath, std::string& error) { const JsonValue* temporalValue = nullptr; @@ -365,6 +413,17 @@ bool ParseParameterDefault(const JsonValue& parameterJson, ShaderParameterDefini return true; } + if (definition.type == ShaderParameterType::Text) + { + if (!defaultValue->isString()) + { + error = "Text parameter default must be a string for: " + definition.id; + return false; + } + definition.defaultTextValue = defaultValue->asString(); + return true; + } + return NumberListFromJsonValue(*defaultValue, definition.defaultNumbers, "default", manifestPath, error); } @@ -447,6 +506,30 @@ bool ParseParameterDefinition(const JsonValue& parameterJson, ShaderParameterDef return false; } + if (definition.type == ShaderParameterType::Text) + { + if (const JsonValue* fontValue = parameterJson.find("font")) + { + if (!fontValue->isString()) + { + error = "Text parameter 'font' must be a string for: " + definition.id; + return false; + } + definition.fontId = fontValue->asString(); + if (!definition.fontId.empty() && !ValidateShaderIdentifier(definition.fontId, "parameters[].font", manifestPath, error)) + return false; + } + if (const JsonValue* maxLengthValue = parameterJson.find("maxLength")) + { + if (!maxLengthValue->isNumber() || maxLengthValue->asNumber() < 1.0 || maxLengthValue->asNumber() > 256.0) + { + error = "Text parameter 'maxLength' must be a number from 1 to 256 for: " + definition.id; + return false; + } + definition.maxLength = static_cast(maxLengthValue->asNumber()); + } + } + if (definition.type == ShaderParameterType::Enum) return ParseParameterOptions(parameterJson, definition, manifestPath, error); @@ -544,6 +627,7 @@ bool ShaderPackageRegistry::ParseManifest(const std::filesystem::path& manifestP shaderPackage.manifestWriteTime = std::filesystem::last_write_time(shaderPackage.manifestPath); return ParseTextureAssets(manifestJson, shaderPackage, manifestPath, error) && + ParseFontAssets(manifestJson, shaderPackage, manifestPath, error) && ParseTemporalSettings(manifestJson, shaderPackage, mMaxTemporalHistoryFrames, manifestPath, error) && ParseParameterDefinitions(manifestJson, shaderPackage, manifestPath, error); } diff --git a/apps/LoopThroughWithOpenGLCompositing/ShaderTypes.h b/apps/LoopThroughWithOpenGLCompositing/ShaderTypes.h index 19fa473..58b2c13 100644 --- a/apps/LoopThroughWithOpenGLCompositing/ShaderTypes.h +++ b/apps/LoopThroughWithOpenGLCompositing/ShaderTypes.h @@ -11,7 +11,8 @@ enum class ShaderParameterType Vec2, Color, Boolean, - Enum + Enum, + Text }; struct ShaderParameterOption @@ -31,6 +32,9 @@ struct ShaderParameterDefinition std::vector stepNumbers; bool defaultBoolean = false; std::string defaultEnumValue; + std::string defaultTextValue; + std::string fontId; + unsigned maxLength = 64; std::vector enumOptions; }; @@ -39,6 +43,7 @@ struct ShaderParameterValue std::vector numberValues; bool booleanValue = false; std::string enumValue; + std::string textValue; }; enum class TemporalHistorySource @@ -63,6 +68,13 @@ struct ShaderTextureAsset std::filesystem::file_time_type writeTime; }; +struct ShaderFontAsset +{ + std::string id; + std::filesystem::path path; + std::filesystem::file_time_type writeTime; +}; + struct ShaderPackage { std::string id; @@ -75,6 +87,7 @@ struct ShaderPackage std::filesystem::path manifestPath; std::vector parameters; std::vector textureAssets; + std::vector fontAssets; TemporalSettings temporal; std::filesystem::file_time_type shaderWriteTime; std::filesystem::file_time_type manifestWriteTime; @@ -87,6 +100,7 @@ struct RuntimeRenderState std::vector parameterDefinitions; std::map parameterValues; std::vector textureAssets; + std::vector fontAssets; double timeSeconds = 0.0; double frameCount = 0.0; double mixAmount = 1.0; diff --git a/runtime/templates/shader_wrapper.slang.in b/runtime/templates/shader_wrapper.slang.in index c6a6557..1b7836a 100644 --- a/runtime/templates/shader_wrapper.slang.in +++ b/runtime/templates/shader_wrapper.slang.in @@ -32,6 +32,7 @@ cbuffer GlobalParams Sampler2D gVideoInput; {{SOURCE_HISTORY_SAMPLERS}}{{TEMPORAL_HISTORY_SAMPLERS}}{{TEXTURE_SAMPLERS}} +{{TEXT_SAMPLERS}} float4 sampleVideo(float2 tc) { return gVideoInput.Sample(tc); @@ -67,6 +68,7 @@ float4 sampleTemporalHistory(int framesAgo, float2 tc) } } +{{TEXT_HELPERS}} #include "{{USER_SHADER_INCLUDE}}" [shader("fragment")] diff --git a/shaders/studio-color/shader.json b/shaders/studio-color/shader.json deleted file mode 100644 index 4e9c6b7..0000000 --- a/shaders/studio-color/shader.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "id": "studio-color", - "name": "Studio Color", - "description": "A built-in sample shader package that demonstrates the runtime parameter contract.", - "category": "Color", - "entryPoint": "shadeVideo", - "parameters": [ - { - "id": "brightness", - "label": "Brightness", - "type": "float", - "default": 1.0, - "min": 0.0, - "max": 2.0, - "step": 0.01 - }, - { - "id": "offset", - "label": "Offset", - "type": "vec2", - "default": [0.0, 0.0], - "min": [-0.2, -0.2], - "max": [0.2, 0.2], - "step": [0.001, 0.001] - }, - { - "id": "tint", - "label": "Tint", - "type": "color", - "default": [1.0, 1.0, 1.0, 1.0] - }, - { - "id": "invert", - "label": "Invert", - "type": "bool", - "default": false - }, - { - "id": "mode", - "label": "Mode", - "type": "enum", - "default": "normal", - "options": [ - { "value": "normal", "label": "Normal" }, - { "value": "luma", "label": "Luma" }, - { "value": "posterize", "label": "Posterize" } - ] - } - ] -} diff --git a/shaders/studio-color/shader.slang b/shaders/studio-color/shader.slang deleted file mode 100644 index bb8b084..0000000 --- a/shaders/studio-color/shader.slang +++ /dev/null @@ -1,23 +0,0 @@ -float4 shadeVideo(ShaderContext context) -{ - float2 uv = clamp(context.uv + offset, float2(0.0, 0.0), float2(1.0, 1.0)); - float4 color = sampleVideo(uv); - - color.rgb *= brightness; - color *= tint; - - if (invert) - color.rgb = 1.0 - color.rgb; - - if (mode == 1) - { - float luma = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); - color.rgb = float3(luma, luma, luma); - } - else if (mode == 2) - { - color.rgb = floor(color.rgb * 4.0) / 4.0; - } - - return saturate(color); -} diff --git a/shaders/text-overlay/fonts/OFL.txt b/shaders/text-overlay/fonts/OFL.txt new file mode 100644 index 0000000..9c48e05 --- /dev/null +++ b/shaders/text-overlay/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/shaders/text-overlay/fonts/Roboto-Regular.ttf b/shaders/text-overlay/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..fa1485f Binary files /dev/null and b/shaders/text-overlay/fonts/Roboto-Regular.ttf differ diff --git a/shaders/text-overlay/shader.json b/shaders/text-overlay/shader.json new file mode 100644 index 0000000..b90f4fa --- /dev/null +++ b/shaders/text-overlay/shader.json @@ -0,0 +1,62 @@ +{ + "id": "text-overlay", + "name": "Text Overlay", + "description": "Single-line live text overlay using the runtime text SDF helper functions.", + "category": "Scopes & Guides", + "entryPoint": "shadeVideo", + "fonts": [ + { + "id": "roboto", + "path": "fonts/Roboto-Regular.ttf" + } + ], + "parameters": [ + { + "id": "titleText", + "label": "Text", + "type": "text", + "default": "VIDEO SHADER", + "font": "roboto", + "maxLength": 64 + }, + { + "id": "position", + "label": "Position", + "type": "vec2", + "default": [0.08, 0.12], + "min": [0.0, 0.0], + "max": [1.0, 1.0], + "step": [0.001, 0.001] + }, + { + "id": "scale", + "label": "Scale", + "type": "float", + "default": 0.42, + "min": 0.1, + "max": 1.5, + "step": 0.01 + }, + { + "id": "fillColor", + "label": "Fill", + "type": "color", + "default": [1.0, 1.0, 1.0, 1.0] + }, + { + "id": "outlineColor", + "label": "Outline", + "type": "color", + "default": [0.0, 0.0, 0.0, 0.8] + }, + { + "id": "outlineWidth", + "label": "Outline Width", + "type": "float", + "default": 0.12, + "min": 0.0, + "max": 0.5, + "step": 0.01 + } + ] +} diff --git a/shaders/text-overlay/shader.slang b/shaders/text-overlay/shader.slang new file mode 100644 index 0000000..abfdebb --- /dev/null +++ b/shaders/text-overlay/shader.slang @@ -0,0 +1,28 @@ +float alphaOver(float baseAlpha, float overAlpha) +{ + return overAlpha + baseAlpha * (1.0 - overAlpha); +} + +float4 compositeOver(float4 baseColor, float4 overColor) +{ + float outAlpha = alphaOver(baseColor.a, overColor.a); + float3 outRgb = overColor.rgb + baseColor.rgb * (1.0 - overColor.a); + return float4(outRgb, outAlpha); +} + +float4 shadeVideo(ShaderContext context) +{ + float2 resolution = max(context.outputResolution, float2(1.0, 1.0)); + float aspect = resolution.x / resolution.y; + float2 textSize = float2(0.72 * scale, 0.09 * scale * aspect); + float2 textUv = (context.uv - position) / max(textSize, float2(0.0001, 0.0001)); + + float mask = sampleTitleText(textUv); + float fill = smoothstep(0.48, 0.54, mask); + float outline = smoothstep(0.48 - outlineWidth, 0.54 - outlineWidth, mask); + + float4 base = context.sourceColor; + float4 outlineLayer = float4(outlineColor.rgb * outlineColor.a, outline * outlineColor.a); + float4 fillLayer = float4(fillColor.rgb * fillColor.a, fill * fillColor.a); + return saturate(compositeOver(compositeOver(base, outlineLayer), fillLayer)); +} diff --git a/tests/RuntimeParameterUtilsTests.cpp b/tests/RuntimeParameterUtilsTests.cpp index 7a6ea57..b3a7a9a 100644 --- a/tests/RuntimeParameterUtilsTests.cpp +++ b/tests/RuntimeParameterUtilsTests.cpp @@ -100,6 +100,26 @@ void TestEnumAndDefaults() error.clear(); Expect(!NormalizeAndValidateParameterValue(definition, JsonValue("other"), value, error), "enum rejects unknown options"); } + +void TestTextNormalization() +{ + ShaderParameterDefinition definition; + definition.id = "titleText"; + definition.type = ShaderParameterType::Text; + definition.defaultTextValue = "DEFAULT"; + definition.maxLength = 6; + + ShaderParameterValue defaultValue = DefaultValueForDefinition(definition); + Expect(defaultValue.textValue == "DEFAUL", "text default is clamped to max length"); + + ShaderParameterValue value; + std::string error; + Expect(NormalizeAndValidateParameterValue(definition, JsonValue("ABC\tDEF\x01GHI"), value, error), "text accepts string values"); + Expect(value.textValue == "ABCDEF", "text drops non-printable characters and clamps length"); + + error.clear(); + Expect(!NormalizeAndValidateParameterValue(definition, JsonValue(12.0), value, error), "text rejects non-string values"); +} } int main() @@ -108,6 +128,7 @@ int main() TestFloatNormalization(); TestVectorNormalization(); TestEnumAndDefaults(); + TestTextNormalization(); if (gFailures != 0) { diff --git a/tests/ShaderPackageRegistryTests.cpp b/tests/ShaderPackageRegistryTests.cpp index 1804dab..d0c3bf0 100644 --- a/tests/ShaderPackageRegistryTests.cpp +++ b/tests/ShaderPackageRegistryTests.cpp @@ -47,6 +47,7 @@ void TestValidManifest() { const std::filesystem::path root = MakeTestRoot(); WriteFile(root / "look" / "mask.png", "not a real png, but enough for existence checks"); + WriteFile(root / "look" / "Inter.ttf", "not a real font, but enough for existence checks"); WriteShaderPackage(root, "look", R"({ "id": "look-01", "name": "Look 01", @@ -54,9 +55,11 @@ void TestValidManifest() "category": "Tests", "entryPoint": "shadeVideo", "textures": [{ "id": "maskTex", "path": "mask.png" }], + "fonts": [{ "id": "inter", "path": "Inter.ttf" }], "temporal": { "enabled": true, "historySource": "source", "historyLength": 8 }, "parameters": [ { "id": "gain", "label": "Gain", "type": "float", "default": 0.5, "min": 0, "max": 1 }, + { "id": "titleText", "label": "Title", "type": "text", "default": "LIVE", "font": "inter", "maxLength": 32 }, { "id": "mode", "label": "Mode", "type": "enum", "default": "soft", "options": [ { "value": "soft", "label": "Soft" }, { "value": "hard", "label": "Hard" } @@ -70,8 +73,29 @@ void TestValidManifest() Expect(registry.ParseManifest(root / "look" / "shader.json", package, error), "valid manifest parses"); Expect(package.id == "look-01", "manifest id is preserved"); Expect(package.textureAssets.size() == 1 && package.textureAssets[0].id == "maskTex", "texture assets parse"); + Expect(package.fontAssets.size() == 1 && package.fontAssets[0].id == "inter", "font assets parse"); Expect(package.temporal.enabled && package.temporal.effectiveHistoryLength == 4, "temporal history is capped"); - Expect(package.parameters.size() == 2, "parameters parse"); + Expect(package.parameters.size() == 3, "parameters parse"); + Expect(package.parameters[1].type == ShaderParameterType::Text && package.parameters[1].defaultTextValue == "LIVE", "text parameter parses"); + + std::filesystem::remove_all(root); +} + +void TestMissingFontAsset() +{ + const std::filesystem::path root = MakeTestRoot(); + WriteShaderPackage(root, "bad-font", R"({ + "id": "bad-font", + "name": "Bad Font", + "fonts": [{ "id": "missingFont", "path": "missing.ttf" }], + "parameters": [] + })"); + + ShaderPackageRegistry registry(4); + ShaderPackage package; + std::string error; + Expect(!registry.ParseManifest(root / "bad-font" / "shader.json", package, error), "missing font asset is rejected"); + Expect(error.find("font asset not found") != std::string::npos, "missing font error is clear"); std::filesystem::remove_all(root); } @@ -115,6 +139,7 @@ void TestDuplicateScan() int main() { TestValidManifest(); + TestMissingFontAsset(); TestInvalidManifest(); TestDuplicateScan(); diff --git a/ui/src/components/ParameterField.jsx b/ui/src/components/ParameterField.jsx index 6d392ec..946815a 100644 --- a/ui/src/components/ParameterField.jsx +++ b/ui/src/components/ParameterField.jsx @@ -273,5 +273,22 @@ export function ParameterField({ layer, parameter, onParameterChange }) { ); } + if (parameter.type === "text") { + return ( +
+ {header} + sendValue(event.target.value)} + onBlur={endInteraction} + /> + +
+ ); + } + return null; }