Added bad shader warning instead of hard fail
Some checks failed
CI / React UI Build (push) Successful in 11s
CI / Native Windows Build And Tests (push) Successful in 1m32s
CI / Windows Release Package (push) Failing after 2m15s

This commit is contained in:
2026-05-06 12:44:22 +10:00
parent 414ef62479
commit ff1b7519a0
11 changed files with 170 additions and 23 deletions

View File

@@ -249,9 +249,9 @@ If neither variable is set, the workflow falls back to the repo-local defaults u
- Audio. - Audio.
- Improve text rendering. - Improve text rendering.
- Genlock. - Genlock.
- Don't hardfail on shader fail
- Find a better UI library. - Find a better UI library.
- Logs. - Logs.
- Continue source cleanup/refactoring. Pass 1 done - Continue source cleanup/refactoring. Pass 1 done
- Display the control URL in the Windows app, ideally clickable, without rendering it on the video output. - Display the control URL in the Windows app, ideally clickable, without rendering it on the video output.
- Support a separate sound shader `.slang` file in shader packages. - Support a separate sound shader `.slang` file in shader packages.
![alt text](image.png)

View File

@@ -1602,12 +1602,14 @@ bool RuntimeHost::ScanShaderPackages(std::string& error)
{ {
std::map<std::string, ShaderPackage> packagesById; std::map<std::string, ShaderPackage> packagesById;
std::vector<std::string> packageOrder; std::vector<std::string> packageOrder;
std::vector<ShaderPackageStatus> packageStatuses;
ShaderPackageRegistry registry(mConfig.maxTemporalHistoryFrames); ShaderPackageRegistry registry(mConfig.maxTemporalHistoryFrames);
if (!registry.Scan(mShaderRoot, packagesById, packageOrder, error)) if (!registry.Scan(mShaderRoot, packagesById, packageOrder, packageStatuses, error))
return false; return false;
mPackagesById.swap(packagesById); mPackagesById.swap(packagesById);
mPackageOrder.swap(packageOrder); mPackageOrder.swap(packageOrder);
mPackageStatuses.swap(packageStatuses);
for (auto it = mPersistentState.layers.begin(); it != mPersistentState.layers.end();) for (auto it = mPersistentState.layers.begin(); it != mPersistentState.layers.end();)
{ {
@@ -1854,18 +1856,19 @@ JsonValue RuntimeHost::BuildStateValue() const
root.set("performance", performance); root.set("performance", performance);
JsonValue shaderLibrary = JsonValue::MakeArray(); JsonValue shaderLibrary = JsonValue::MakeArray();
for (const std::string& shaderId : mPackageOrder) for (const ShaderPackageStatus& status : mPackageStatuses)
{ {
auto shaderIt = mPackagesById.find(shaderId);
if (shaderIt == mPackagesById.end())
continue;
JsonValue shader = JsonValue::MakeObject(); JsonValue shader = JsonValue::MakeObject();
shader.set("id", JsonValue(shaderIt->second.id)); shader.set("id", JsonValue(status.id));
shader.set("name", JsonValue(shaderIt->second.displayName)); shader.set("name", JsonValue(status.displayName));
shader.set("description", JsonValue(shaderIt->second.description)); shader.set("description", JsonValue(status.description));
shader.set("category", JsonValue(shaderIt->second.category)); shader.set("category", JsonValue(status.category));
if (shaderIt->second.temporal.enabled) shader.set("available", JsonValue(status.available));
if (!status.available)
shader.set("error", JsonValue(status.error));
auto shaderIt = mPackagesById.find(status.id);
if (status.available && shaderIt != mPackagesById.end() && shaderIt->second.temporal.enabled)
{ {
JsonValue temporal = JsonValue::MakeObject(); JsonValue temporal = JsonValue::MakeObject();
temporal.set("enabled", JsonValue(true)); temporal.set("enabled", JsonValue(true));

View File

@@ -151,6 +151,7 @@ private:
std::filesystem::path mPatchedGlslPath; std::filesystem::path mPatchedGlslPath;
std::map<std::string, ShaderPackage> mPackagesById; std::map<std::string, ShaderPackage> mPackagesById;
std::vector<std::string> mPackageOrder; std::vector<std::string> mPackageOrder;
std::vector<ShaderPackageStatus> mPackageStatuses;
bool mReloadRequested; bool mReloadRequested;
bool mCompileSucceeded; bool mCompileSucceeded;
std::string mCompileMessage; std::string mCompileMessage;

View File

@@ -554,6 +554,36 @@ bool ParseParameterDefinitions(const JsonValue& manifestJson, ShaderPackage& sha
return true; return true;
} }
std::string UniqueUnavailableShaderId(const std::filesystem::path& manifestPath, const std::string& parsedId)
{
const std::string fallbackId = manifestPath.parent_path().filename().string();
const std::string baseId = parsedId.empty() ? fallbackId : parsedId;
return baseId + "@invalid:" + fallbackId;
}
ShaderPackageStatus BuildUnavailableStatus(const std::filesystem::path& manifestPath, const ShaderPackage& partialPackage, const std::string& packageError)
{
ShaderPackageStatus status;
status.id = UniqueUnavailableShaderId(manifestPath, partialPackage.id);
status.displayName = !partialPackage.displayName.empty() ? partialPackage.displayName : manifestPath.parent_path().filename().string();
status.description = partialPackage.description;
status.category = !partialPackage.category.empty() ? partialPackage.category : "Unavailable";
status.available = false;
status.error = packageError;
return status;
}
ShaderPackageStatus BuildAvailableStatus(const ShaderPackage& shaderPackage)
{
ShaderPackageStatus status;
status.id = shaderPackage.id;
status.displayName = shaderPackage.displayName;
status.description = shaderPackage.description;
status.category = shaderPackage.category;
status.available = true;
return status;
}
} }
ShaderPackageRegistry::ShaderPackageRegistry(unsigned maxTemporalHistoryFrames) ShaderPackageRegistry::ShaderPackageRegistry(unsigned maxTemporalHistoryFrames)
@@ -561,10 +591,16 @@ ShaderPackageRegistry::ShaderPackageRegistry(unsigned maxTemporalHistoryFrames)
{ {
} }
bool ShaderPackageRegistry::Scan(const std::filesystem::path& shaderRoot, std::map<std::string, ShaderPackage>& packagesById, std::vector<std::string>& packageOrder, std::string& error) const bool ShaderPackageRegistry::Scan(
const std::filesystem::path& shaderRoot,
std::map<std::string, ShaderPackage>& packagesById,
std::vector<std::string>& packageOrder,
std::vector<ShaderPackageStatus>& packageStatuses,
std::string& error) const
{ {
packagesById.clear(); packagesById.clear();
packageOrder.clear(); packageOrder.clear();
packageStatuses.clear();
if (!std::filesystem::exists(shaderRoot)) if (!std::filesystem::exists(shaderRoot))
{ {
@@ -583,19 +619,27 @@ bool ShaderPackageRegistry::Scan(const std::filesystem::path& shaderRoot, std::m
ShaderPackage shaderPackage; ShaderPackage shaderPackage;
if (!ParseManifest(manifestPath, shaderPackage, error)) if (!ParseManifest(manifestPath, shaderPackage, error))
return false; {
packageStatuses.push_back(BuildUnavailableStatus(manifestPath, shaderPackage, error));
error.clear();
continue;
}
if (packagesById.find(shaderPackage.id) != packagesById.end()) if (packagesById.find(shaderPackage.id) != packagesById.end())
{ {
error = "Duplicate shader id found: " + shaderPackage.id; packageStatuses.push_back(BuildUnavailableStatus(manifestPath, shaderPackage, "Duplicate shader id found: " + shaderPackage.id));
return false; continue;
} }
packageOrder.push_back(shaderPackage.id); packageOrder.push_back(shaderPackage.id);
packageStatuses.push_back(BuildAvailableStatus(shaderPackage));
packagesById[shaderPackage.id] = shaderPackage; packagesById[shaderPackage.id] = shaderPackage;
} }
std::sort(packageOrder.begin(), packageOrder.end()); std::sort(packageOrder.begin(), packageOrder.end());
std::sort(packageStatuses.begin(), packageStatuses.end(), [](const ShaderPackageStatus& left, const ShaderPackageStatus& right) {
return left.displayName < right.displayName;
});
return true; return true;
} }

View File

@@ -12,7 +12,12 @@ class ShaderPackageRegistry
public: public:
explicit ShaderPackageRegistry(unsigned maxTemporalHistoryFrames); explicit ShaderPackageRegistry(unsigned maxTemporalHistoryFrames);
bool Scan(const std::filesystem::path& shaderRoot, std::map<std::string, ShaderPackage>& packagesById, std::vector<std::string>& packageOrder, std::string& error) const; bool Scan(
const std::filesystem::path& shaderRoot,
std::map<std::string, ShaderPackage>& packagesById,
std::vector<std::string>& packageOrder,
std::vector<ShaderPackageStatus>& packageStatuses,
std::string& error) const;
bool ParseManifest(const std::filesystem::path& manifestPath, ShaderPackage& shaderPackage, std::string& error) const; bool ParseManifest(const std::filesystem::path& manifestPath, ShaderPackage& shaderPackage, std::string& error) const;
private: private:

View File

@@ -93,6 +93,16 @@ struct ShaderPackage
std::filesystem::file_time_type manifestWriteTime; std::filesystem::file_time_type manifestWriteTime;
}; };
struct ShaderPackageStatus
{
std::string id;
std::string displayName;
std::string description;
std::string category;
bool available = false;
std::string error;
};
struct RuntimeRenderState struct RuntimeRenderState
{ {
std::string layerId; std::string layerId;

View File

@@ -0,0 +1,15 @@
{
"id": "broken-shader-example",
"name": "Broken Shader Example",
"description": "Intentionally invalid shader package used to verify that bad shaders appear as errors without blocking the app.",
"category": "Diagnostics",
"entryPoint": "shadeVideo",
"parameters": [
{
"id": "badToggle",
"label": "Bad Toggle",
"type": "boolean",
"default": true
}
]
}

View File

@@ -0,0 +1,4 @@
float4 shadeVideo(ShaderContext context)
{
return context.sourceColor;
}

View File

@@ -1,5 +1,6 @@
#include "ShaderPackageRegistry.h" #include "ShaderPackageRegistry.h"
#include <algorithm>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@@ -187,9 +188,39 @@ void TestDuplicateScan()
ShaderPackageRegistry registry(4); ShaderPackageRegistry registry(4);
std::map<std::string, ShaderPackage> packages; std::map<std::string, ShaderPackage> packages;
std::vector<std::string> order; std::vector<std::string> order;
std::vector<ShaderPackageStatus> statuses;
std::string error; std::string error;
Expect(!registry.Scan(root, packages, order, error), "duplicate package ids are rejected"); Expect(registry.Scan(root, packages, order, statuses, error), "duplicate package ids do not fail the whole scan");
Expect(error.find("Duplicate shader id") != std::string::npos, "duplicate scan error is clear"); Expect(packages.size() == 1, "first duplicate package remains available");
Expect(statuses.size() == 2, "duplicate package is surfaced in shader status list");
Expect(std::any_of(statuses.begin(), statuses.end(), [](const ShaderPackageStatus& status) {
return !status.available && status.error.find("Duplicate shader id") != std::string::npos;
}), "duplicate scan error is shown on unavailable package");
std::filesystem::remove_all(root);
}
void TestInvalidPackageDoesNotFailScan()
{
const std::filesystem::path root = MakeTestRoot();
WriteShaderPackage(root, "good", R"({ "id": "good", "name": "Good", "parameters": [] })");
WriteShaderPackage(root, "bad", R"({
"id": "bad",
"name": "Bad",
"parameters": [{ "id": "enabled", "label": "Enabled", "type": "boolean" }]
})");
ShaderPackageRegistry registry(4);
std::map<std::string, ShaderPackage> packages;
std::vector<std::string> order;
std::vector<ShaderPackageStatus> statuses;
std::string error;
Expect(registry.Scan(root, packages, order, statuses, error), "invalid package does not fail the whole scan");
Expect(packages.find("good") != packages.end(), "valid package remains available");
Expect(packages.find("bad") == packages.end(), "invalid package is not available for rendering");
Expect(std::any_of(statuses.begin(), statuses.end(), [](const ShaderPackageStatus& status) {
return status.id.find("bad") != std::string::npos && !status.available && status.error.find("Unsupported parameter type") != std::string::npos;
}), "invalid package is surfaced with its parse error");
std::filesystem::remove_all(root); std::filesystem::remove_all(root);
} }
@@ -203,6 +234,7 @@ int main()
TestInvalidTemporalSettings(); TestInvalidTemporalSettings();
TestDisabledTemporalSettingsAreIgnored(); TestDisabledTemporalSettingsAreIgnored();
TestDuplicateScan(); TestDuplicateScan();
TestInvalidPackageDoesNotFailScan();
if (gFailures != 0) if (gFailures != 0)
{ {

View File

@@ -7,7 +7,7 @@ function matchesShader(shader, query) {
return true; return true;
} }
return [shader.name, shader.id, shader.category, shader.description] return [shader.name, shader.id, shader.category, shader.description, shader.error]
.filter(Boolean) .filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedQuery)); .some((value) => value.toLowerCase().includes(normalizedQuery));
} }
@@ -17,6 +17,10 @@ function shaderSummary(shader) {
return "Search available shaders"; return "Search available shaders";
} }
if (shader.available === false) {
return shader.error || "Shader is unavailable";
}
return shader.description || "No description"; return shader.description || "No description";
} }
@@ -25,7 +29,8 @@ function ShaderOptionContent({ shader }) {
<> <>
<span className="shader-picker__option-head"> <span className="shader-picker__option-head">
<span className="shader-picker__name">{shader.name}</span> <span className="shader-picker__name">{shader.name}</span>
{shader.category ? <span className="shader-picker__category">{shader.category}</span> : null} {shader.available === false ? <span className="shader-picker__category shader-picker__category--error">Error</span> : null}
{shader.available !== false && shader.category ? <span className="shader-picker__category">{shader.category}</span> : null}
</span> </span>
<span className="shader-picker__meta">{shaderSummary(shader)}</span> <span className="shader-picker__meta">{shaderSummary(shader)}</span>
</> </>
@@ -60,7 +65,9 @@ export function ShaderPicker({ id, label = "Shader", shaders, value, onChange })
<span> <span>
<span className="shader-picker__option-head"> <span className="shader-picker__option-head">
<span className="shader-picker__name">{selectedShader?.name ?? "Choose shader"}</span> <span className="shader-picker__name">{selectedShader?.name ?? "Choose shader"}</span>
{selectedShader?.category ? ( {selectedShader?.available === false ? (
<span className="shader-picker__category shader-picker__category--error">Error</span>
) : selectedShader?.category ? (
<span className="shader-picker__category">{selectedShader.category}</span> <span className="shader-picker__category">{selectedShader.category}</span>
) : null} ) : null}
</span> </span>
@@ -88,10 +95,14 @@ export function ShaderPicker({ id, label = "Shader", shaders, value, onChange })
<button <button
key={shader.id} key={shader.id}
type="button" type="button"
className={`shader-picker__option${shader.id === value ? " shader-picker__option--selected" : ""}`} className={`shader-picker__option${shader.id === value ? " shader-picker__option--selected" : ""}${shader.available === false ? " shader-picker__option--unavailable" : ""}`}
role="option" role="option"
aria-selected={shader.id === value} aria-selected={shader.id === value}
disabled={shader.available === false}
onClick={() => { onClick={() => {
if (shader.available === false) {
return;
}
onChange(shader.id); onChange(shader.id);
setOpen(false); setOpen(false);
setQuery(""); setQuery("");

View File

@@ -749,6 +749,22 @@ pre {
box-shadow: inset 0 0 0 1px var(--app-primary-soft); box-shadow: inset 0 0 0 1px var(--app-primary-soft);
} }
.shader-picker__option--unavailable {
border-color: rgba(217, 83, 79, 0.45);
background: rgba(217, 83, 79, 0.08);
opacity: 1;
}
.shader-picker__option:disabled {
cursor: not-allowed;
opacity: 1;
}
.shader-picker__option--unavailable .shader-picker__name,
.shader-picker__option--unavailable .shader-picker__meta {
color: #ffd0cf;
}
.shader-picker__name, .shader-picker__name,
.shader-picker__meta { .shader-picker__meta {
min-width: 0; min-width: 0;
@@ -788,6 +804,12 @@ pre {
white-space: nowrap; white-space: nowrap;
} }
.shader-picker__category--error {
border-color: rgba(217, 83, 79, 0.45);
background: var(--app-error-soft);
color: #ffd0cf;
}
.shader-picker__meta { .shader-picker__meta {
display: -webkit-box; display: -webkit-box;
overflow: hidden; overflow: hidden;