diff --git a/CMakeLists.txt b/CMakeLists.txt index 6727b9a..fb37f8b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -417,7 +417,7 @@ endif() # --------------------------------------------------------------------------- option(PINOL_TESTS "Build the engine tests" ON) if(PINOL_TESTS) - add_executable(pinol_tests EXCLUDE_FROM_ALL test/engine_test.cpp test/operator_state.cpp test/video_test.cpp test/cue_capture.cpp test/cue_lifecycle.cpp test/gpu_targets.cpp test/image_precision.cpp test/show_hold.cpp test/golden_render.cpp test/timeline_repeat.cpp test/preview_sources.cpp test/recall_delay.cpp test/recall_integrity.cpp test/emergency_stop.cpp src/ui/Commands.cpp src/ui/LogModel.cpp) + add_executable(pinol_tests EXCLUDE_FROM_ALL test/engine_test.cpp test/operator_state.cpp test/video_test.cpp test/cue_capture.cpp test/cue_lifecycle.cpp test/gpu_targets.cpp test/image_precision.cpp test/show_hold.cpp test/output_color.cpp test/golden_render.cpp test/timeline_repeat.cpp test/preview_sources.cpp test/recall_delay.cpp test/recall_integrity.cpp test/emergency_stop.cpp src/ui/Commands.cpp src/ui/LogModel.cpp) target_include_directories(pinol_tests PRIVATE src/ui) target_link_libraries(pinol_tests PRIVATE pinol_engine) target_compile_options(pinol_tests PRIVATE ${PINOL_WARNINGS}) @@ -425,6 +425,10 @@ if(PINOL_TESTS) # CTest: each targeted area in a process of its own (a new engine). # Test media first: sh test/make_media.sh enable_testing() + add_executable(pinol_output_color_state_test test/output_color_state.cpp src/ui/Commands.cpp) + target_include_directories(pinol_output_color_state_test PRIVATE src/ui) + target_link_libraries(pinol_output_color_state_test PRIVATE pinol_engine Qt6::Widgets) + add_test(NAME output_color_state COMMAND pinol_output_color_state_test) add_executable(pinol_image_import_test test/image_import.cpp) target_include_directories(pinol_image_import_test PRIVATE src/engine) target_link_libraries(pinol_image_import_test PRIVATE Qt6::Gui) @@ -520,7 +524,7 @@ if(PINOL_TESTS) target_link_libraries(pinol_cue_dispatch_test PRIVATE pinol_engine Qt6::Widgets) add_test(NAME cue_dispatch COMMAND pinol_cue_dispatch_test) set_tests_properties(cue_dispatch PROPERTIES TIMEOUT 15) - foreach(area show_hold recall_integrity image_precision operator_state preview_sources emergency_stop cue_capture cue_lifecycle gpu_targets golden timeline_repeat recall_delay isf_safe video project playback undo structure compositing osc snapshots masks rendering thread publishing + foreach(area output_color show_hold recall_integrity image_precision operator_state preview_sources emergency_stop cue_capture cue_lifecycle gpu_targets golden timeline_repeat recall_delay isf_safe video project playback undo structure compositing osc snapshots masks rendering thread publishing media sound layersource timecode interactions) add_test(NAME ${area} COMMAND pinol_tests --area ${area}) endforeach() diff --git a/docs/README.md b/docs/README.md index 98a437a..4fcefd4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,65 +1,28 @@ -# Engineering status and roadmap - -Compositing, timelines/cues, audio/MIDI/OSC interactions, recall delays and undoable -mask editing are implemented. Audio snapshots now save input, measure and frequency -bounds: recapture older snapshots to include these controls. Recall Undo restores -meshes, mattes and animation transport, and cancels pending transitions/delays. -Integer image sources retain up to 16-bit channels through layer and ISF uploads. -Pause/speed zero hold source/effect frames, including feedback and live inputs. -Escape also cancels pending show work; explicit GO/Play/recall releases its hold, -while an operator Pause requires releasing Pause. Manual controls remain active. - -## Known limitations - -- **Show control:** running show/media clocks and shader TIME catch up after stalls; - shader TIMEDELTA stays capped at 250 ms before speed scaling. Missed feedback - simulation frames are not replayed. Snapshot composition/layer fades share show - time; manual output faders and blackout use wall time and remain available while - paused. External receivers keep receiving the held picture; acquisition may - continue in input threads. Physical A/V recovery still needs measurement. -- **Color:** RGBA16 UNORM is the internal default; ISF FLOAT targets use RGBA32F. - CPU fallback/readback and some outputs remain 8-bit; HAP BC6 CPU fallback - clips HDR. No verified end-to-end 10-bit output, linear-light working space, - ICC/gamut conversion or PQ/HLG tone mapping. -- **Resources:** budgets are soft and exclude some FFmpeg/driver/GPU allocations. - Existing decoders retain their threads. Rendering holds a shared engine lock; - interaction overflow drops new events beyond 8192. Readback waits are per output. -- **Audio/video:** the 4096-sample FFT needs 85.3 ms initially at 48 kHz, updating - every 21.3 ms. macOS ProRes 4444/XQ uses software decoding to preserve alpha, - increasing CPU demand. -- **Editing:** independent recall delays apply to Parameter addresses, not - source/topology changes or creation/deletion. -- **Hardware/distribution:** receiver integrity, hot-unplug recovery and endurance - need physical validation; slow media IO can delay shutdown. macOS packages are - ad-hoc signed without Developer ID/notarization; Gatekeeper may block downloads. +# Limitations and remaining work + +## Known problems + +- **Color:** compositing and FX remain in encoded sRGB, not linear light. + Wide-gamut sources are clipped to sRGB even when the final output is P3. + Unknown/live profiles require a declaration; unsupported video tags and HDR + have no automatic normalization/tone mapping. ISF image inputs are not individually + color-managed. Some decoder/readback paths lose + precision. Output conversion clips SDR gamut and does not configure display + ICC profiles or signal encoding; physical 10-bit output remains unverified. +- **Timing:** long stalls skip feedback/simulation steps. Physical A/V recovery and + clock stability remain unmeasured. Input acquisition continues while paused. +- **Resources:** soft budgets miss some FFmpeg/driver/GPU allocations. Render-lock + contention, retained decoder threads and control-event drops under load need + measurement. Readback waits accumulate per output; slow IO can delay shutdown. +- **Media/editing:** FFT startup adds 85 ms at 48 kHz. ProRes 4444/XQ software + decoding increases CPU load. Recall delays do not cover source/topology changes. +- **Distribution:** macOS packages lack Developer ID signing and notarization. ## Roadmap -| Priority | Work | Acceptance | +| Priority | Work | Done when | |---|---|---| -| P1 | Measure callback, lock contention, memory and control floods on target hardware; automated report/threshold checks are available. | 18 × 1080p layers at 60 Hz, two outputs; zero control drops. Callback p99 ≤1 ms and ≤25% of its period, zero overruns; lock wait p99 ≤1 ms, hold p99 ≤8 ms. | -| P1 | Validate sustained shows and real receivers. | Eight hours without crash/stuck transport or unexpected decoder errors; A/V offset <40 ms without growth. Twenty reconnect cycles: recover within 5 s or show an actionable error. Verify receiver frame IDs under load. | -| P2 | Add explicit color/output transforms. | Reference gradients survive compositing; measure compatible physical outputs before claiming 10-bit/HDR. | - -## Validation and diagnostics - -CI retains targeted engine/render/widget tests, package smoke tests, ASan, TSan and -fuzzing; Valgrind runs nightly/manually. Timing failures gate on the first attempt. -The exhaustive ISF library render is excluded from CTest/CI; manual use remains: -`pinol_tests --check-isf isf`. - -Record build, hardware/driver, media hashes and outputs when comparing versions: - -```sh -Pinol --endurance-csv session.csv show.pinol -python3 scripts/analyze_timeline_endurance.py session.csv --fps 60 --check -``` - -CSV v2 reports sharing skips; analysis correlates late frames with cues and flags -lost events. Correlation does not prove causality. OSC `/composition/timing` and -`/composition/budget` expose metrics; `_lifetime` values survive timing resets. -`--check` fails on missing/invalid/inactive telemetry, callback/lock threshold -violations, control drops, decoder errors or lost telemetry. It evaluates lifetime -histograms: start a fresh Pinol process for each comparison. A PASS does not certify -the workload, duration or physical receiver; record these separately. -Software-rendering/sanitizer CI does not establish hardware acceptance. +| P1 | Profile load and fix measured bottlenecks. | 18 × 1080p layers, 60 Hz, two outputs; zero control drops/overruns. Callback p99 ≤1 ms and ≤25% of its period; lock wait p99 ≤1 ms, hold p99 ≤8 ms. | +| P1 | Validate endurance, receivers and reconnection. | Eight hours without crash/stuck transport/decoder errors; A/V offset <40 ms without growth. Twenty reconnects recover within 5 s or report an actionable error. Receiver frame IDs remain correct under load. | +| P2 | Extend color management to linear compositing, HDR and output signaling. | Reference patches agree through FX/blending and on physical receivers; verify gamut, alpha and 10-bit precision. | +| P2 | Sign and notarize macOS releases. | A downloaded release opens on a clean Mac without a Gatekeeper workaround. | diff --git a/src/engine/ColorInfo.h b/src/engine/ColorInfo.h new file mode 100644 index 0000000..4462ed9 --- /dev/null +++ b/src/engine/ColorInfo.h @@ -0,0 +1,80 @@ +#pragma once +#include +#include +#include +#include +#include + +// Describe tags, never infer a profile from sample depth or file extension. +inline QString imageColorSpaceName(const QColorSpace &space) +{ + if (!space.isValid()) return QStringLiteral("Unknown (untagged)"); + const QString description = space.description().simplified(); + if (!description.isEmpty()) return description; + const auto primaries = QMetaEnum::fromType(); + const char *name = primaries.valueToKey(int(space.primaries())); + const char *curve = QMetaEnum::fromType().valueToKey(int(space.transferFunction())); + return QStringLiteral("%1 / %2").arg(name ? QString::fromLatin1(name) : QStringLiteral("Custom primaries"), + curve ? QString::fromLatin1(curve) : QStringLiteral("Custom transfer")); +} +inline QString imageColorDescription(const QColorSpace &space) +{ + if (!space.isValid()) return QStringLiteral("Color space: Unknown (untagged)"); + auto name = [](auto value) { + const char *key = QMetaEnum::fromType().valueToKey(int(value)); + return key ? QString::fromLatin1(key) : QStringLiteral("Custom"); + }; + QString transfer = name(space.transferFunction()); + if (space.transferFunction() == QColorSpace::TransferFunction::Gamma) + transfer += QStringLiteral(" %1").arg(space.gamma(), 0, 'g', 4); + return QStringLiteral("Color space: %1 · primaries: %2 · transfer: %3") + .arg(imageColorSpaceName(space), name(space.primaries()), transfer); +} + +// Order matches the output shader's transfer/gamut modes. "same" is resolved +// by the caller; existing projects default to unchanged output with an explicit +// sRGB assumption for their rendered RGB if a conversion is later requested. +inline QStringList outputColorSpaceKeys() { return {"srgb", "linear709", "displayp3", "rec709-g24"}; } +inline QStringList outputColorSpaceLabels() { + return {"sRGB", "Linear Rec.709", "Display P3", "Rec.709 / Gamma 2.4"}; +} +inline int outputColorSpaceIndex(const QString &key) { + if (key == QLatin1String("linear709")) return 1; + if (key == QLatin1String("displayp3")) return 2; + if (key == QLatin1String("rec709-g24")) return 3; + return 0; +} + +inline QStringList sourceColorSpaceKeys() { + auto keys = outputColorSpaceKeys(); + keys.prepend("auto"); + keys.append("rec709"); + return keys; +} +inline QStringList sourceColorSpaceLabels() { + auto labels = outputColorSpaceLabels(); + labels.prepend("Auto (source profile)"); + labels.append("Rec.709 / BT.709 transfer"); + return labels; +} +inline int sourceColorSpaceIndex(const QString &key) { + return key == QLatin1String("rec709") ? 4 : outputColorSpaceIndex(key); +} +// Common image profiles use the GPU path. Other SDR ICC profiles are converted +// once on load with Qt; retain the original samples for a later manual override. +inline QString imageSourceColorKey(const QColorSpace &space) { + if (space == QColorSpace(QColorSpace::SRgb)) return "srgb"; + if (space == QColorSpace(QColorSpace::SRgbLinear)) return "linear709"; + if (space == QColorSpace(QColorSpace::DisplayP3)) return "displayp3"; + return {}; +} +inline bool hdrImageColorSpace(const QColorSpace &space) { +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + return space.transferFunction() == QColorSpace::TransferFunction::St2084 || + space.transferFunction() == QColorSpace::TransferFunction::Hlg; +#else + // Earlier supported Qt versions do not expose PQ/HLG profiles. + Q_UNUSED(space); + return false; +#endif +} diff --git a/src/engine/Engine.cpp b/src/engine/Engine.cpp index c5d9d86..7955c67 100644 --- a/src/engine/Engine.cpp +++ b/src/engine/Engine.cpp @@ -158,6 +158,30 @@ bool Engine::initialize(QString *err) "#version 330 core\nuniform sampler2D u_tex; in vec2 v_uv; out vec4 o;\n" "void main(){ o = texture(u_tex, v_uv); }\n", &log); + // IEC sRGB transfer functions (Khronos Data Format, section 13.3). + // Shared by native-source normalization (straight alpha) and opaque output. + m_outputColorProgram = compileProgram(quadVs, + "#version 330 core\nuniform sampler2D u_tex; uniform float u_opacity, u_gain;\n" + "uniform int u_decode, u_encode, u_source; in vec2 v_uv; out vec4 o;\n" + "vec3 decodeSrgb(vec3 c){ return mix(pow((c+0.055)/1.055,vec3(2.4)),c/12.92,lessThanEqual(c,vec3(0.04045))); }\n" + "vec3 encodeSrgb(vec3 c){ return mix(1.055*pow(c,vec3(1.0/2.4))-0.055,12.92*c,lessThanEqual(c,vec3(0.0031308))); }\n" + // D65 linear Display P3 <-> Rec.709 matrices (CSS Color 4 reference primaries). + "const mat3 p3To709=mat3(1.224940176,-0.042056955,-0.019637555,-0.224940176,1.042056955,-0.078636046,0.0,0.0,1.098273601);\n" + "const mat3 rec709ToP3=mat3(0.822461968,0.033194199,0.017082631,0.177538032,0.966805801,0.072397441,0.0,0.0,0.910519929);\n" + "void main(){ vec4 c=texture(u_tex,v_uv); vec3 rgb=max(c.rgb*(u_source!=0?1.0:c.a*u_opacity),vec3(0.0));\n" + " if(u_decode==0 || u_decode==2) rgb=decodeSrgb(rgb); else if(u_decode==3) rgb=pow(rgb,vec3(2.4));\n" + // ITU-R BT.709-6 inverse OETF: distinct from sRGB and display Gamma 2.4. + " else if(u_decode==4) rgb=mix(pow((rgb+0.099)/1.099,vec3(1.0/0.45)),rgb/4.5,lessThan(rgb,vec3(0.081)));\n" + " if(u_decode==2) rgb=p3To709*rgb; rgb*=u_gain; if(u_encode==2) rgb=rec709ToP3*rgb;\n" + " rgb=max(rgb,vec3(0.0)); if(u_encode==0 || u_encode==2) rgb=encodeSrgb(rgb); else if(u_encode==3) rgb=pow(rgb,vec3(1.0/2.4));\n" + " o=vec4(clamp(rgb,0.0,1.0),u_source!=0?c.a:1.0); }\n", &log); + if (!m_outputColorProgram) { if (err) *err = log; return false; } + m_outputTexLoc = f->glGetUniformLocation(m_outputColorProgram, "u_tex"); + m_outputOpacityLoc = f->glGetUniformLocation(m_outputColorProgram, "u_opacity"); + m_outputDecodeLoc = f->glGetUniformLocation(m_outputColorProgram, "u_decode"); + m_outputEncodeLoc = f->glGetUniformLocation(m_outputColorProgram, "u_encode"); + m_outputGainLoc = f->glGetUniformLocation(m_outputColorProgram, "u_gain"); + m_outputSourceLoc = f->glGetUniformLocation(m_outputColorProgram, "u_source"); m_presentProgram = compileProgram(quadVs, "#version 330 core\nuniform sampler2D u_tex; in vec2 v_uv; out vec4 o;\n" "void main(){ o = vec4(texture(u_tex, v_uv).rgb, 1.0); }\n", @@ -540,7 +564,7 @@ void Engine::releaseAll() m_videoConv.release(); m_testCard.release(); for (GLuint p : {m_blitProgram, m_compProgram, m_diffProgram, m_presentProgram, m_prepProgram, m_maskProgram, - m_fadeProgram, m_shapeMaskProgram, m_matteProgram}) + m_fadeProgram, m_shapeMaskProgram, m_matteProgram, m_outputColorProgram}) if (p) f->glDeleteProgram(p); GLuint bufs[] = {m_quadVbo, m_meshVbo, m_meshIbo}; f->glDeleteBuffers(3, bufs); @@ -549,7 +573,7 @@ void Engine::releaseAll() f->glDeleteTextures(1, &m_blackTex); m_quadVao = m_meshVao = m_pointsVao = 0; m_blitProgram = m_compProgram = m_diffProgram = m_presentProgram = m_prepProgram = m_maskProgram = 0; - m_fadeProgram = m_shapeMaskProgram = m_matteProgram = 0; + m_fadeProgram = m_shapeMaskProgram = m_matteProgram = m_outputColorProgram = 0; } // --------------------------------------------------------------------------- @@ -840,6 +864,7 @@ void Engine::fadeCompositionOpacity(double target, double seconds) m_compFade.opacity = false; // the fader takes over from a snapshot's fade m_compositionOpacityTarget = std::clamp(target, 0.0, 1.0); m_compositionOpacitySpeed = seconds > 0.0 ? 1.0 / seconds : 0.0; + m_compositionFaderClock.reset(RealtimeTiming::nowNs()); } void Engine::setBlackout(bool on, double seconds) @@ -848,6 +873,7 @@ void Engine::setBlackout(bool on, double seconds) Lock lk(&m_mutex); m_blackTarget = on ? 0.0 : 1.0; m_blackSpeed = seconds > 0.0 ? 1.0 / seconds : 0.0; + m_blackoutClock.reset(RealtimeTiming::nowNs()); if (m_blackSpeed <= 0.0) m_blackLevel = m_blackTarget; } m_audio->fadeTo(on ? 0.0f : 1.0f, seconds); diff --git a/src/engine/Engine.h b/src/engine/Engine.h index 26884bf..5b7f80a 100644 --- a/src/engine/Engine.h +++ b/src/engine/Engine.h @@ -853,6 +853,9 @@ class Engine : public QObject GLuint m_pointsVao = 0; // no attribute: a particle's vertex finds its data from gl_VertexID GLuint m_meshVao = 0, m_meshVbo = 0, m_meshIbo = 0; GLsizei m_meshIndexCount = 0; + GLuint m_outputColorProgram = 0; + GLint m_outputTexLoc = -1, m_outputOpacityLoc = -1, m_outputDecodeLoc = -1, m_outputEncodeLoc = -1, m_outputGainLoc = -1; + GLint m_outputSourceLoc = -1; GLuint m_diffProgram = 0, m_blitProgram = 0, m_compProgram = 0, m_presentProgram = 0, m_prepProgram = 0; GLint m_compRotLoc = -1, m_compAspLoc = -1, m_diffRotLoc = -1, m_diffAspLoc = -1; GLint m_diffTexLoc = -1, m_diffDstLoc = -1, m_diffOpacityLoc = -1, m_diffViewLoc = -1; @@ -887,6 +890,7 @@ class Engine : public QObject void updateTimeScale(); std::atomic m_compositionOpacity{1.0}; double m_compositionOpacityTarget = 1.0, m_compositionOpacitySpeed = 0.0; // units per second (0 = immediate) + FaderClock m_compositionFaderClock, m_blackoutClock; struct CompositionFade { double opacityElapsed = 0, volumeElapsed = 0; // each on its own clock: two snapshots can drive them bool opacity = false, volume = false; diff --git a/src/engine/EngineInternal.h b/src/engine/EngineInternal.h index b9322a5..c2c05ba 100644 --- a/src/engine/EngineInternal.h +++ b/src/engine/EngineInternal.h @@ -35,7 +35,7 @@ struct Engine::Garbage { std::unique_ptr video; std::unique_ptr videoTex; std::shared_ptr audio; - Texture2D tex, textTex; + Texture2D tex, textTex, imageAutoTex; std::unique_ptr generator; RenderTarget generatorTarget; std::vector> effects; diff --git a/src/engine/FrameTiming.h b/src/engine/FrameTiming.h index f95dfc9..c79c732 100644 --- a/src/engine/FrameTiming.h +++ b/src/engine/FrameTiming.h @@ -30,6 +30,18 @@ struct FrameStep { } }; +// Manual fader/blackout commands can arrive in the middle of a slow frame. +// Their first step starts at the command, not at the previous frame's start. +struct FaderClock { + quint64 lastNs = 0; + void reset(quint64 now) { lastNs = now; } + double step(quint64 now, bool threaded) { + const double elapsed = lastNs && now >= lastNs ? double(now - lastNs) / 1e9 : 0; + lastNs = now; + return FrameStep::fromElapsed(elapsed, threaded).elapsed; + } +}; + // The render thread's pace without a vertical sync to wait for: absolute deadlines, one period apart struct FramePacer { // A frame started at `start`, done at `now`: how long to sleep (ns) before the next one is due. `next`: when it is diff --git a/src/engine/Layer.h b/src/engine/Layer.h index 77c131e..7313824 100644 --- a/src/engine/Layer.h +++ b/src/engine/Layer.h @@ -13,6 +13,7 @@ #include "VideoTexture.h" #include +#include #include #include #include @@ -232,6 +233,10 @@ struct Layer { QString vpScreen; // screen it is shown on (empty: the main one) int vpMode = 0; // 0 hidden, 1 windowed, 2 fullscreen bool testCard = false; // a viewport shows its test card instead (a setup aid: not saved) + // Output-only calibration, after viewport effects/opacity and before the + // composition fader/blackout. Raw defaults preserve legacy pixel values. + QString outputInputSpace = QStringLiteral("srgb"), outputSpace = QStringLiteral("same"); + double outputExposure = 0; // stops, applied between decode and encode PublishSettings vpPublish; // NDI, OMT, Syphon, Spout of this viewport // How much of this item each viewport shows (an item at the top of the list; inside a group, the group decides). // A viewport that is not in the map shows it fully, so a viewport created later shows everything. @@ -291,7 +296,15 @@ struct Layer { // Video / image / live input: source texture (fed in the render thread) Texture2D sourceTex; - QString imageColorInfo; // tagged source profile and original depth, before 8-bit upload + QString imageColorInfo; // tagged source profile and original depth, before upload conversion + QColorSpace imageProfile; + QString sourceColorSpace = QStringLiteral("auto"); + // Only uncommon SDR ICC profiles need a second upload; common spaces use + // the source shader. Original pixels remain available for manual overrides. + Texture2D imageAutoTex; + QImage pendingAutoImage; + RenderTarget sourceColorTarget; + QString sourceColorCachedKey; std::unique_ptr input; // a live input (its URI in sourcePath): frames come in on its own thread QImage pendingImage; // image to upload to the GPU on the next frame int srcWidth = 0, srcHeight = 0; diff --git a/src/engine/LayerParams.cpp b/src/engine/LayerParams.cpp index 1b2563c..242be3d 100644 --- a/src/engine/LayerParams.cpp +++ b/src/engine/LayerParams.cpp @@ -1,6 +1,7 @@ // The parameters a layer instantiates (Layer::parameters) #include "EngineInternal.h" #include "Osc.h" +#include "ColorInfo.h" #include #include @@ -200,6 +201,28 @@ const std::vector &Layer::parameters() flag("fx/enable", "FX › Enable", true, &effectsEnabled); } + if (!isViewport && !isGroup && type != SourceType::Layer && picture) { + add("source/color_space", "Source › Color Space", T::Choice) + .choices(sourceColorSpaceKeys()).byDefault("auto").animatable(false) + .bind([self] { return QVariant(self->sourceColorSpace); }, + [self](const QVariant &v) { self->sourceColorSpace = v.toString(); }); + } + if (isViewport) { + add("output/input_space", "Output › Rendered Input Space", T::Choice) + .choices(outputColorSpaceKeys()).byDefault("srgb") + .bind([self] { return QVariant(self->outputInputSpace); }, + [self](const QVariant &v) { self->outputInputSpace = v.toString(); }); + auto targets = outputColorSpaceKeys(); + targets.prepend("same"); + add("output/space", "Output › Color Space", T::Choice) + .choices(targets).byDefault("same") + .bind([self] { return QVariant(self->outputSpace); }, + [self](const QVariant &v) { self->outputSpace = v.toString(); }); + num("output/exposure", "Output › Exposure (EV)", -10, 10, 0, + [self] { return self->outputExposure; }, [self](double v) { self->outputExposure = v; }) + .step(.01).animatable(false); // calibration belongs to the viewport, independent of cues + } + // Playback: the media's (backwards below 0: it turns round where it is), or the pace of the shader's TIME if (audio) { num("volume", "Source › Volume", 0, 2, 1.0, [self] { return double(self->volume); }, diff --git a/src/engine/Layers.cpp b/src/engine/Layers.cpp index 31479c1..a755a85 100644 --- a/src/engine/Layers.cpp +++ b/src/engine/Layers.cpp @@ -8,6 +8,7 @@ #include #include #include +#include "ColorInfo.h" #include #include @@ -422,6 +423,8 @@ void Engine::releaseLayer(Layer &l) l.videoTex.reset(); releaseAudio(*m_audio, l.audio); l.sourceTex.destroy(); + l.imageAutoTex.destroy(); + l.sourceColorTarget.destroy(); l.textTex.destroy(); if (l.generator) l.generator->releaseGl(); l.generator.reset(); @@ -543,7 +546,7 @@ int Engine::partOf(const QString &path) under("scale/") || under("corner/")) return PartSpatial; if (under("fx/")) return PartEffects; - if (path == "opacity" || path == "blend_mode") return PartCompositing; + if (path == "opacity" || path == "blend_mode" || under("output/")) return PartCompositing; if (path == "speed" || path == "play_mode" || path == "in" || path == "out" || path == "play" || path == "restart" || path == "volume" || path == "mute") return PartTransport; @@ -691,6 +694,13 @@ std::shared_ptr Engine::detachSource(Layer &l) g->audio = std::move(l.audio); g->tex = l.sourceTex; l.sourceTex = Texture2D{}; + g->imageAutoTex = l.imageAutoTex; + l.imageAutoTex = Texture2D{}; + l.imageProfile = QColorSpace(); + l.imageColorInfo.clear(); + l.sourceColorSpace = QStringLiteral("auto"); + l.pendingAutoImage = QImage(); + l.sourceColorCachedKey.clear(); g->textTex = l.textTex; l.textTex = Texture2D{}; l.textKey.clear(); @@ -720,6 +730,7 @@ void Engine::releaseGarbage(const std::shared_ptr &g) releaseAudio(*m_audio, g->audio); runGl([g] { g->tex.destroy(); + g->imageAutoTex.destroy(); g->textTex.destroy(); if (g->videoTex) g->videoTex->destroy(); if (g->generator) g->generator->releaseGl(); @@ -1089,11 +1100,17 @@ bool Engine::setLayerImage(int i, const QString &path, QString *err) return false; } const QColorSpace profile = img.colorSpace(); - const QString colorInfo = QStringLiteral("%1 source · %2").arg( - imageSourceDepth(img.format()), - profile.isValid() ? QStringLiteral("ICC: ") + - (profile.description().isEmpty() ? QStringLiteral("embedded profile") : profile.description()) - : QStringLiteral("color profile: untagged/unknown")); + const QString colorInfo = imageSourceDepth(img.format()) + QStringLiteral(" source · ") + imageColorDescription(profile); + QImage autoImage; + if (profile.isValid() && imageSourceColorKey(profile).isEmpty() && !hdrImageColorSpace(profile)) { + // Outside the render lock/thread. Preserve integer precision and alpha; + // Qt handles ICC matrices, curves and chromatic adaptation here. + autoImage = prepareImageUpload(img.convertToFormat(imageUploadFormat(img)).convertedToColorSpace(QColorSpace::SRgb)); + if (autoImage.isNull()) { + if (err) *err = QStringLiteral("Unable to convert the embedded image profile to sRGB"); + return false; + } + } img = prepareImageUpload(img); std::shared_ptr g; { @@ -1103,6 +1120,8 @@ bool Engine::setLayerImage(int i, const QString &path, QString *err) g = detachSource(*l); l->pendingImage = img; // uploaded to the GPU by the render thread l->imageColorInfo = colorInfo; + l->imageProfile = profile; + l->pendingAutoImage = autoImage; l->srcWidth = img.width(); l->srcHeight = img.height(); l->type = SourceType::Image; diff --git a/src/engine/Render.cpp b/src/engine/Render.cpp index 4332d77..cf6b304 100644 --- a/src/engine/Render.cpp +++ b/src/engine/Render.cpp @@ -3,6 +3,8 @@ #include #include "EngineInternal.h" #include "TimecodeClock.h" +#include "ColorInfo.h" +#include "SourceColor.h" #include #include @@ -196,6 +198,10 @@ void Engine::updateSource(Layer &l, double dt) if (timeScale() > 0 && !l.pendingImage.isNull()) { l.sourceTex.uploadImage(l.pendingImage); l.pendingImage = QImage(); + if (!l.pendingAutoImage.isNull()) { + l.imageAutoTex.uploadImage(l.pendingAutoImage); + l.pendingAutoImage = QImage(); + } } (void)dt; if ((l.type != SourceType::Video && l.type != SourceType::Audio) || !l.hasTransport()) return; @@ -394,7 +400,7 @@ void Engine::renderLayer(Layer &l, const IsfRenderContext &rc) } break; case SourceType::Image: - tex = l.sourceTex.tex; + tex = l.sourceColorSpace == QLatin1String("auto") && l.imageAutoTex.tex ? l.imageAutoTex.tex : l.sourceTex.tex; w = l.sourceTex.w; h = l.sourceTex.h; break; @@ -445,6 +451,31 @@ void Engine::renderLayer(Layer &l, const IsfRenderContext &rc) default: break; } if (!tex) return; + const QString colorKey = sourceColorKey(l); + if (colorKey != QLatin1String("srgb")) { + const bool changed = l.sourceColorTarget.ensure(w, h); + if (!l.sourceColorTarget.ready(w, h)) return; // fail closed, never display incorrectly interpreted pixels + if (l.type != SourceType::Image || changed || l.sourceColorCachedKey != colorKey) { + auto f = gl(); + l.sourceColorTarget.bind(); + f->glDisable(GL_BLEND); + f->glUseProgram(m_outputColorProgram); + f->glActiveTexture(GL_TEXTURE0); + f->glBindTexture(GL_TEXTURE_2D, tex); + f->glUniform1i(m_outputTexLoc, 0); + f->glUniform1i(m_outputSourceLoc, 1); + f->glUniform1i(m_outputDecodeLoc, sourceColorSpaceIndex(colorKey)); + f->glUniform1i(m_outputEncodeLoc, 0); + f->glUniform1f(m_outputGainLoc, 1); + f->glUniform1f(m_outputOpacityLoc, 1); + drawQuad(); + l.sourceColorCachedKey = colorKey; + } + tex = l.sourceColorTarget.tex; + } else if (l.sourceColorTarget.tex) { + l.sourceColorTarget.destroy(); + l.sourceColorCachedKey.clear(); + } processLayer(l, tex, w, h, false, rc); } @@ -1015,26 +1046,45 @@ void Engine::composite() if (!o.fbo) continue; // keep the previously published viewport frame o.clear(0, 0, 0, 1); if (v.finalTex && v.opacity > 0.0f) { - // The viewport's own picture fills its output, at its opacity - o.bind(); - f->glEnable(GL_BLEND); - f->glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); - f->glUseProgram(m_compProgram); - f->glUniform1i(m_compTexLoc, 0); - f->glUniform4f(m_compViewLoc, 1, 1, 0, 0); - f->glUniform1f(m_compOpacityLoc, v.opacity); - // Its picture as it is: none of what the last layer composited left set in the program (its composition - // masks, its matte, a turned view) - f->glUniform1i(m_compMaskOnLoc, 0); - f->glUniform1i(m_compMatteModeLoc, 0); - f->glUniform4f(m_compRotLoc, 1, 0, 0, 0); - f->glActiveTexture(GL_TEXTURE0); - f->glBindVertexArray(m_meshVao); - bindMeshBuffer(m_meshVbo); // the whole frame, built once - f->glBindTexture(GL_TEXTURE_2D, v.finalTex); - f->glDrawElements(GL_TRIANGLES, m_meshIndexCount, GL_UNSIGNED_INT, nullptr); - f->glDisable(GL_BLEND); - f->glBindVertexArray(0); + const QString &targetSpace = v.outputSpace == QLatin1String("same") ? v.outputInputSpace : v.outputSpace; + const bool transform = targetSpace != v.outputInputSpace || v.outputExposure != 0; + if (transform) { + // Black-matte the viewport first, then decode/expose/encode once. + // All consumers (screen, preview and publishers) use this same target. + o.bind(); + f->glDisable(GL_BLEND); + f->glUseProgram(m_outputColorProgram); + f->glActiveTexture(GL_TEXTURE0); + f->glBindTexture(GL_TEXTURE_2D, v.finalTex); + f->glUniform1i(m_outputTexLoc, 0); + f->glUniform1f(m_outputOpacityLoc, v.opacity); + f->glUniform1i(m_outputDecodeLoc, outputColorSpaceIndex(v.outputInputSpace)); + f->glUniform1i(m_outputSourceLoc, 0); + f->glUniform1i(m_outputEncodeLoc, outputColorSpaceIndex(targetSpace)); + f->glUniform1f(m_outputGainLoc, float(std::exp2(v.outputExposure))); + drawQuad(); + } else { + // The viewport's own picture fills its output, at its opacity + o.bind(); + f->glEnable(GL_BLEND); + f->glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); + f->glUseProgram(m_compProgram); + f->glUniform1i(m_compTexLoc, 0); + f->glUniform4f(m_compViewLoc, 1, 1, 0, 0); + f->glUniform1f(m_compOpacityLoc, v.opacity); + // Its picture as it is: none of what the last layer composited left set in the program (its composition + // masks, its matte, a turned view) + f->glUniform1i(m_compMaskOnLoc, 0); + f->glUniform1i(m_compMatteModeLoc, 0); + f->glUniform4f(m_compRotLoc, 1, 0, 0, 0); + f->glActiveTexture(GL_TEXTURE0); + f->glBindVertexArray(m_meshVao); + bindMeshBuffer(m_meshVbo); // the whole frame, built once + f->glBindTexture(GL_TEXTURE_2D, v.finalTex); + f->glDrawElements(GL_TRIANGLES, m_meshIndexCount, GL_UNSIGNED_INT, nullptr); + f->glDisable(GL_BLEND); + f->glBindVertexArray(0); + } } o.bind(); applyOpacity(); @@ -1072,12 +1122,15 @@ void Engine::frame(double dt) m_renderLockWait.record(lockAcquired - lockRequested); setRenderBits(effectiveRender().depth); + // Manual faders use command-relative wall time, including render-lock waits. + const double faderDt = m_compositionFaderClock.step(lockAcquired, m_threaded); + const double blackoutDt = m_blackoutClock.step(lockAcquired, m_threaded); // Composition opacity double lvl = m_compositionOpacity.load(); if (m_compositionOpacitySpeed <= 0.0) { lvl = m_compositionOpacityTarget; } else { - const double step = m_realDt * m_compositionOpacitySpeed; + const double step = faderDt * m_compositionOpacitySpeed; lvl = lvl < m_compositionOpacityTarget ? std::min(m_compositionOpacityTarget, lvl + step) : std::max(m_compositionOpacityTarget, lvl - step); } m_compositionOpacity = lvl; @@ -1085,7 +1138,7 @@ void Engine::frame(double dt) if (m_blackSpeed <= 0.0) { black = m_blackTarget; } else { - const double step = m_realDt * m_blackSpeed; + const double step = blackoutDt * m_blackSpeed; black = black < m_blackTarget ? std::min(m_blackTarget, black + step) : std::max(m_blackTarget, black - step); } m_blackLevel = black; diff --git a/src/engine/SourceColor.h b/src/engine/SourceColor.h new file mode 100644 index 0000000..880d630 --- /dev/null +++ b/src/engine/SourceColor.h @@ -0,0 +1,65 @@ +#pragma once +#include "ColorInfo.h" +#include "Layer.h" + +// sRGB is the explicit SDR working space of the existing color tools and ISF +// effects. Decode only native producers: groups and layer taps already contain +// working RGB and must never be converted a second time. +inline QString videoSourceColorKey(const QString &primaries, const QString &transfer) +{ + if (primaries == QLatin1String("bt709")) { + if (transfer == QLatin1String("iec61966-2-1")) return "srgb"; + if (transfer == QLatin1String("linear")) return "linear709"; + if (transfer == QLatin1String("bt709")) return "rec709"; + } + if (primaries == QLatin1String("smpte432") && transfer == QLatin1String("iec61966-2-1")) + return "displayp3"; + return {}; +} +inline QString videoSourceColorKey(const VideoDecoder::Info &info) { + return videoSourceColorKey(info.primaries, info.transfer); +} +inline VideoDecoder::Info effectiveVideoColorInfo(const Layer &layer) { + auto info = layer.video ? layer.video->sourceInfo() : VideoDecoder::Info{}; + if (layer.frame.layout) { + if (!layer.frame.layout->primaries.isEmpty()) info.primaries = layer.frame.layout->primaries; + if (!layer.frame.layout->transfer.isEmpty()) info.transfer = layer.frame.layout->transfer; + } + return info; +} +inline QString sourceColorKey(const Layer &layer) +{ + if (layer.type == SourceType::Layer || layer.isGroup || layer.isViewport) return "srgb"; + if (layer.sourceColorSpace != QLatin1String("auto")) return layer.sourceColorSpace; + if (layer.type == SourceType::Image) { + const auto key = imageSourceColorKey(layer.imageProfile); + return key.isEmpty() ? QStringLiteral("srgb") : key; + } + if (layer.video) { + const auto &info = layer.video->sourceInfo(); + const auto *frame = layer.frame.layout.get(); + const auto key = videoSourceColorKey(frame && !frame->primaries.isEmpty() ? frame->primaries : info.primaries, + frame && !frame->transfer.isEmpty() ? frame->transfer : info.transfer); + if (!key.isEmpty()) return key; + } + return "srgb"; // explicitly reported as an assumption for unknown/unsupported sources +} +inline QString sourceColorStatus(const Layer &layer) +{ + if (layer.sourceColorSpace != QLatin1String("auto")) + return QStringLiteral("Declared %1 → sRGB working space").arg( + sourceColorSpaceLabels().value(sourceColorSpaceKeys().indexOf(layer.sourceColorSpace))); + if (layer.type == SourceType::Image && layer.imageProfile.isValid()) { + if (hdrImageColorSpace(layer.imageProfile)) + return QStringLiteral("HDR profile unsupported: no tone mapping; RGB left unchanged"); + return QStringLiteral("Embedded %1 → sRGB working space").arg(imageColorSpaceName(layer.imageProfile)); + } + if (layer.video) { + const auto info = effectiveVideoColorInfo(layer); + if (!videoSourceColorKey(info).isEmpty()) + return QStringLiteral("Tagged %1 → sRGB working space").arg(info.colorSpaceSummary()); + return QStringLiteral("Unknown or unsupported video profile: RGB left unchanged; declare its SDR space if known"); + } + if (layer.type == SourceType::Text) return QStringLiteral("Generated sRGB → sRGB working space"); + return QStringLiteral("Unknown profile: assumed sRGB; declare the source space if known"); +} diff --git a/src/engine/VideoDecoder.cpp b/src/engine/VideoDecoder.cpp index f82b382..806a9a0 100644 --- a/src/engine/VideoDecoder.cpp +++ b/src/engine/VideoDecoder.cpp @@ -273,6 +273,12 @@ static void readSourceColor(const AVCodecParameters *par, VideoDecoder::Info *ou else if (par->color_range == AVCOL_RANGE_JPEG) out->range = QStringLiteral("full (PC)"); } +QString VideoDecoder::Info::colorSpaceSummary() const +{ + return (primaries.isEmpty() ? QStringLiteral("Unknown primaries") : primaries) + QStringLiteral(" / ") + + (transfer.isEmpty() ? QStringLiteral("Unknown transfer") : transfer); +} + QString VideoDecoder::Info::colorDescription() const { QStringList fields; @@ -647,6 +653,8 @@ std::shared_ptr VideoDecoder::surfaceLayout(const AVFrame *hw probe->colorspace = hw->colorspace; probe->color_range = hw->color_range; probe->chroma_location = hw->chroma_location; + probe->color_primaries = hw->color_primaries; + probe->color_trc = hw->color_trc; const std::shared_ptr base = layoutFor(probe); av_frame_free(&probe); if (!base || base->mode != VideoLayout::Mode::Yuv) return nullptr; diff --git a/src/engine/VideoDecoder.h b/src/engine/VideoDecoder.h index 5983e3f..6551092 100644 --- a/src/engine/VideoDecoder.h +++ b/src/engine/VideoDecoder.h @@ -59,6 +59,7 @@ class VideoDecoder QString transfer; // EOTF / gamma tag QString range; // full / limited QString colorDescription() const; + QString colorSpaceSummary() const; }; static bool probe(const QString &path, Info *info, QString *err = nullptr); void close(); @@ -198,10 +199,11 @@ class VideoDecoder // Layouts already made (decode thread) struct LayoutKey { - int format, w, h, space, range, loc; + int format, w, h, space, range, loc, primaries = 0, transfer = 0; bool operator<(const LayoutKey &o) const { - return std::tie(format, w, h, space, range, loc) < std::tie(o.format, o.w, o.h, o.space, o.range, o.loc); + return std::tie(format, w, h, space, range, loc, primaries, transfer) < + std::tie(o.format, o.w, o.h, o.space, o.range, o.loc, o.primaries, o.transfer); } }; std::map> m_layouts; diff --git a/src/engine/VideoDecoderDelivery.cpp b/src/engine/VideoDecoderDelivery.cpp index 4c278ee..f29e919 100644 --- a/src/engine/VideoDecoderDelivery.cpp +++ b/src/engine/VideoDecoderDelivery.cpp @@ -65,8 +65,15 @@ bool VideoDecoder::deliver(AVFrame *src, VideoFrame &f, bool mapped) const int w = src->width, h = src->height; const AVPixFmtDescriptor *d = av_pix_fmt_desc_get(AVPixelFormat(src->format)); const bool alpha = d && (d->flags & AV_PIX_FMT_FLAG_ALPHA); - if (!m_rgbaLayout || m_rgbaLayout->width != w || m_rgbaLayout->height != h || m_rgbaLayout->alpha != alpha) { + if (!m_rgbaLayout || m_rgbaLayout->width != w || m_rgbaLayout->height != h || m_rgbaLayout->alpha != alpha || + m_rgbaLayout->primariesTag != int(src->color_primaries) || m_rgbaLayout->transferTag != int(src->color_trc)) { auto L = std::make_shared(); + L->primariesTag = int(src->color_primaries); + L->transferTag = int(src->color_trc); + if (src->color_primaries != AVCOL_PRI_UNSPECIFIED) + if (const char *name = av_color_primaries_name(src->color_primaries)) L->primaries = QString::fromLatin1(name); + if (src->color_trc != AVCOL_TRC_UNSPECIFIED) + if (const char *name = av_color_transfer_name(src->color_trc)) L->transfer = QString::fromLatin1(name); L->mode = VideoLayout::Mode::Rgb; L->width = w; L->height = h; diff --git a/src/engine/VideoDecoderLayout.cpp b/src/engine/VideoDecoderLayout.cpp index ff22a72..e576b16 100644 --- a/src/engine/VideoDecoderLayout.cpp +++ b/src/engine/VideoDecoderLayout.cpp @@ -39,7 +39,8 @@ void videoDecoderYuvMatrix(AVColorSpace space, int height, float m[9], QString * std::shared_ptr VideoDecoder::layoutFor(const AVFrame *fr) { - const LayoutKey key{fr->format, fr->width, fr->height, int(fr->colorspace), int(fr->color_range), int(fr->chroma_location)}; + const LayoutKey key{fr->format, fr->width, fr->height, int(fr->colorspace), int(fr->color_range), int(fr->chroma_location), + int(fr->color_primaries), int(fr->color_trc)}; const auto found = m_layouts.find(key); if (found != m_layouts.end()) return found->second; return m_layouts[key] = layoutOf(fr); @@ -86,6 +87,12 @@ std::shared_ptr VideoDecoder::layoutOf(const AVFrame *fr) } L = std::make_shared(); + L->primariesTag = int(fr->color_primaries); + L->transferTag = int(fr->color_trc); + if (fr->color_primaries != AVCOL_PRI_UNSPECIFIED) + if (const char *name = av_color_primaries_name(fr->color_primaries)) L->primaries = QString::fromLatin1(name); + if (fr->color_trc != AVCOL_TRC_UNSPECIFIED) + if (const char *name = av_color_transfer_name(fr->color_trc)) L->transfer = QString::fromLatin1(name); L->width = fr->width; L->height = fr->height; L->planeCount = planes; diff --git a/src/engine/VideoFrame.h b/src/engine/VideoFrame.h index 13ac93e..84e29c4 100644 --- a/src/engine/VideoFrame.h +++ b/src/engine/VideoFrame.h @@ -50,6 +50,8 @@ struct VideoLayout { float matrix[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; float chromaShift[2] = {0, 0}; // chroma siting, in texels of the chroma planes QString description; // "yuv420p · BT.709", "Hap Q"… + QString primaries, transfer; // frame tags, independent of the YUV matrix/range + int primariesTag = -1, transferTag = -1; // The frames stay in the hardware decoder's surfaces (macOS: IOSurface), sampled there by the GPU: no copy. // Their planes are described as for the copy path; the frame is `av` (the hardware frame). bool surface = false; diff --git a/src/ui/LayerInspector.cpp b/src/ui/LayerInspector.cpp index 8ca626d..15b47bc 100644 --- a/src/ui/LayerInspector.cpp +++ b/src/ui/LayerInspector.cpp @@ -15,6 +15,7 @@ #include "SettingsPanel.h" #include "ViewportOutput.h" #include "Widgets.h" +#include "SourceColor.h" #include #include @@ -88,6 +89,7 @@ struct LayerValues { QString codec; VideoDecoder::Info sourceVideoInfo; QString imageColorInfo; + QString sourceColorSpace, sourceColorStatus; bool hasAudio = false, muted = false; float volume = 1; AudioStream::Info audio; @@ -146,7 +148,7 @@ struct LayerValues { s.videoH = l->video->height(); s.fps = l->video->fps(); s.codec = l->video->codecName(); - s.sourceVideoInfo = l->video->sourceInfo(); + s.sourceVideoInfo = effectiveVideoColorInfo(*l); s.duration = l->duration(); } if (l->audio) { @@ -168,6 +170,8 @@ struct LayerValues { s.srcW = l->srcWidth; s.srcH = l->srcHeight; s.imageColorInfo = l->imageColorInfo; + s.sourceColorSpace = l->sourceColorSpace; + s.sourceColorStatus = ::sourceColorStatus(*l); s.sourceLayer = l->sourceLayer; s.sourceTap = l->sourceTap; s.transition = l->transition; @@ -493,6 +497,8 @@ void LayerInspector::rebuild() { m_layoutKey = layoutKey(); m_tcTransport.reset(); + m_sourceColorSpace = nullptr; + m_sourceColorStatus = nullptr; if (m_content) { m_content->hide(); m_content->deleteLater(); @@ -653,7 +659,7 @@ void LayerInspector::rebuild() m_output = nullptr; if (s.isViewport) m_routeFields.clear(); if (s.isViewport) { // its size, screen and publishing - m_output = new ViewportOutputPanel(m_engine, s.id); + m_output = new ViewportOutputPanel(m_engine, s.id, m_undo); connect(m_output, &ViewportOutputPanel::edited, this, &LayerInspector::projectEdited); connect(m_output, &ViewportOutputPanel::edited, this, &LayerInspector::layerChanged); tabs->addTab(page(m_output), QStringLiteral("Output")); @@ -754,7 +760,8 @@ QWidget *LayerInspector::buildSource(const LayerValues &s) kind = QStringLiteral("Video"); name = QFileInfo(s.sourcePath).fileName(); d << QStringLiteral("%1 × %2").arg(s.videoW).arg(s.videoH) << QStringLiteral("%1 fps").arg(s.fps, 0, 'f', 2) - << fmtTime(s.duration) << s.codec << s.sourceVideoInfo.colorDescription() + << fmtTime(s.duration) << s.codec << (QStringLiteral("Color space: ") + s.sourceVideoInfo.colorSpaceSummary()) + << s.sourceVideoInfo.colorDescription() << (s.hasAudio ? QStringLiteral("sound: ") + audioText(s.audio) : QStringLiteral("no sound")); break; case SourceType::Audio: @@ -769,22 +776,26 @@ QWidget *LayerInspector::buildSource(const LayerValues &s) << s.imageColorInfo; break; case SourceType::Isf: + d << QStringLiteral("Color space: Unknown (shader RGB)"); kind = QStringLiteral("ISF Generator"); name = QFileInfo(s.sourcePath).completeBaseName(); d << QStringLiteral("%1 × %2").arg(s.genW).arg(s.genH); break; case SourceType::Input: + d << QStringLiteral("Color space: Unknown (live input)"); kind = liveInputKind(s.sourcePath) + QStringLiteral(" Input"); name = liveInputName(s.sourcePath); if (s.srcW > 0) d << QStringLiteral("%1 × %2").arg(s.srcW).arg(s.srcH); else d << QStringLiteral("waiting for its first picture"); break; case SourceType::Text: + d << QStringLiteral("Color space: Unspecified (generated)"); kind = s.timecode ? QStringLiteral("Timecode") : QStringLiteral("Text"); name = s.timecode ? TimecodeSettings::sourceName(s.timecode->source) : s.text.content.section('\n', 0, 0).left(40); d << QStringLiteral("%1 × %2").arg(s.text.width).arg(s.text.height); break; case SourceType::Layer: { + d << QStringLiteral("Color space: Inherited (not tagged)"); kind = QStringLiteral("Layer"); name = QStringLiteral("(gone)"); for (const auto &c : s.candidates) @@ -830,6 +841,38 @@ QWidget *LayerInspector::buildSource(const LayerValues &s) }); if (!s.error.isEmpty()) v->addWidget(errorLabel(s.error)); + if (s.type == SourceType::Image || s.type == SourceType::Video || s.type == SourceType::Input || + s.type == SourceType::Isf || s.type == SourceType::Text) { + auto *row = new QHBoxLayout; + auto *pick = new QComboBox; + pick->setObjectName(QStringLiteral("sourceColorSpace")); + m_sourceColorSpace = pick; + const auto keys = sourceColorSpaceKeys(), labels = sourceColorSpaceLabels(); + for (int i = 0; i < keys.size(); ++i) pick->addItem(labels[i], keys[i]); + pick->setCurrentIndex(std::max(0, pick->findData(s.sourceColorSpace))); + pick->setEnabled(!m_locked); + pick->setToolTip(QStringLiteral("Interpret the source RGB in this space, then convert to sRGB before Color, FX and compositing. " + "This changes pixels; it does not edit the file's profile. SDR only.")); + auto *label = new ResetLabel(QStringLiteral("Source color space"), [this] { + setValue("source/color_space", "auto", "Reset Source Color Space"); + rebuild(); + }); + row->addWidget(label); + row->addWidget(pick, 1); + v->addLayout(row); + auto *status = new QLabel(s.sourceColorStatus); + m_sourceColorStatus = status; + status->setObjectName(QStringLiteral("sourceColorStatus")); + status->setWordWrap(true); + status->setTextFormat(Qt::PlainText); + v->addWidget(status); + connect(pick, &QComboBox::activated, this, [this, pick](int i) { + setValue("source/color_space", pick->itemData(i).toString(), "Change Source Color Space"); + refreshDynamic(); + emit layerChanged(); + }); + } + // Transition when a snapshot gives this layer another source (a sound: its levels cross, nothing to choose) if (s.type != SourceType::Audio) { auto *row = new QHBoxLayout; @@ -2716,6 +2759,21 @@ void LayerInspector::refreshTimecodeTransport() void LayerInspector::refreshDynamic() { + if (m_sourceColorSpace || m_sourceColorStatus) { + QString space, status; + { + Engine::Lock lock(&m_engine->mutex()); + if (const Layer *l = m_engine->layer(m_engine->indexOfId(m_layerId))) { + space = l->sourceColorSpace; + status = sourceColorStatus(*l); + } + } + if (m_sourceColorSpace && !m_sourceColorSpace->view()->isVisible()) { + const QSignalBlocker blocker(m_sourceColorSpace); + m_sourceColorSpace->setCurrentIndex(m_sourceColorSpace->findData(space)); + } + if (m_sourceColorStatus) m_sourceColorStatus->setText(status); + } // The fields show the values as they move (a snapshot's fade), unless Settings keeps them still until it is over const bool follow = SettingsPanel::followFades() || !m_engine->isFading(); if (follow) refreshSpatial(); diff --git a/src/ui/LayerInspector.h b/src/ui/LayerInspector.h index 8cb121b..60e311e 100644 --- a/src/ui/LayerInspector.h +++ b/src/ui/LayerInspector.h @@ -118,6 +118,8 @@ class LayerInspector : public QWidget QPointer m_fxDetail; QPointer m_position, m_speed; + QPointer m_sourceColorSpace; + QPointer m_sourceColorStatus; QPointer m_loop; QPointer m_playButtons; QPointer m_roi; diff --git a/src/ui/MediaBin.cpp b/src/ui/MediaBin.cpp index a373156..c3496da 100644 --- a/src/ui/MediaBin.cpp +++ b/src/ui/MediaBin.cpp @@ -15,6 +15,7 @@ #include #include #include +#include "ColorInfo.h" #include #include #include @@ -140,8 +141,8 @@ MediaBin::MediaBin(Engine *engine, QWidget *parent) : QWidget(parent), m_engine( auto *tree = new BinTree; m_tree = tree; - m_tree->setColumnCount(3); - m_tree->setHeaderLabels({QStringLiteral("File"), QStringLiteral("Info"), QStringLiteral("Layers")}); + m_tree->setColumnCount(4); + m_tree->setHeaderLabels({QStringLiteral("File"), QStringLiteral("Info"), QStringLiteral("Layers"), QStringLiteral("Color space")}); m_tree->setRootIsDecorated(true); m_tree->setSelectionMode(QAbstractItemView::ExtendedSelection); m_tree->setDragEnabled(true); @@ -157,6 +158,8 @@ MediaBin::MediaBin(Engine *engine, QWidget *parent) : QWidget(parent), m_engine( m_tree->header()->setSectionResizeMode(2, QHeaderView::Fixed); m_tree->setColumnWidth(1, 120); m_tree->setColumnWidth(2, 56); + m_tree->header()->setSectionResizeMode(3, QHeaderView::Interactive); + m_tree->setColumnWidth(3, 170); m_tree->setTextElideMode(Qt::ElideMiddle); m_tree->setToolTip(QStringLiteral("Drag an item onto a layer (layer list, or the Source tab of the layer) to load it.\n" "ISF FX dragged onto a layer join its FX chain.\n" @@ -275,12 +278,15 @@ void MediaBin::refresh() it->setText(0, QFileInfo(r.path).fileName()); if (r.missing) { it->setText(1, QStringLiteral("missing")); + it->setText(3, QStringLiteral("Unavailable")); m_info.remove(r.path); } else { if (!m_info.contains(r.path)) probe(r.path); const QString info = m_info.value(r.path, QStringLiteral("…")); it->setText(1, info.section('\t', 0, 0)); - it->setToolTip(1, info.contains('\t') ? info.section('\t', 0, 0) + QStringLiteral(" · ") + info.section('\t', 1) : info); + it->setText(3, info.contains('\t') ? info.section('\t', 2, 2) : QStringLiteral("…")); + it->setToolTip(3, info.section('\t', 1, 1)); + it->setToolTip(1, info.contains('\t') ? info.section('\t', 0, 0) + QStringLiteral(" · ") + info.section('\t', 1, 1) : info); } if (r.missing) it->setToolTip(1, QStringLiteral("File not found: ") + r.path); // Layer count; details are in the tooltip @@ -291,7 +297,7 @@ void MediaBin::refresh() it->setToolTip(0, r.path + QStringLiteral("\n\n") + usedBy); it->setToolTip(2, usedBy); const QColor fg = r.missing ? QColor(255, 110, 95) : (r.users.isEmpty() ? QColor(140, 140, 145) : QColor(225, 225, 228)); - for (int c = 0; c < 3; ++c) it->setForeground(c, fg); + for (int c = 0; c < 4; ++c) it->setForeground(c, fg); } for (auto e = existing.begin(); e != existing.end(); ++e) if (!seen.contains(e.key())) delete e.value(); @@ -405,6 +411,7 @@ void MediaBin::refreshIsf() it->setData(0, UsedRole, g->inLibrary || !g->users.isEmpty()); // library shaders stay; imported ones can be removed it->setData(0, UsersRole, int(g->users.size())); it->setText(0, g->name); + it->setText(3, QStringLiteral("Unknown (shader RGB)")); it->setText(1, missing ? QStringLiteral("missing") : g->inLibrary ? QString() : g->imported ? QStringLiteral("imported") : QStringLiteral("outside library")); it->setText(2, g->users.isEmpty() ? QStringLiteral("—") : QString::number(g->users.size())); it->setTextAlignment(2, Qt::AlignCenter); @@ -415,7 +422,7 @@ void MediaBin::refreshIsf() it->setToolTip(1, g->description); it->setToolTip(2, usedBy); const QColor fg = missing ? QColor(255, 110, 95) : (g->users.isEmpty() ? QColor(170, 170, 175) : QColor(225, 225, 228)); - for (int c = 0; c < 3; ++c) it->setForeground(c, fg); + for (int c = 0; c < 4; ++c) it->setForeground(c, fg); } qDeleteAll(existing); for (auto c = categories.begin(); c != categories.end(); ++c) @@ -478,6 +485,7 @@ void MediaBin::refreshInputs() it->setData(0, UsedRole, true); // not removable: it is there or it is not it->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsDragEnabled); it->setText(0, r.name); + it->setText(3, QStringLiteral("Unknown (live input)")); it->setText(1, r.present ? r.kind : r.kind + QStringLiteral(" — not found")); if (!r.present) it->setForeground(1, QColor(255, 110, 95)); const QStringList u = users.value(r.uri); @@ -508,6 +516,7 @@ void MediaBin::refreshText() const QString usedBy = users.isEmpty() ? QStringLiteral("Not used by any layer") : QStringLiteral("Used by:\n") + users.join('\n'); it->setToolTip(0, what + QStringLiteral("\n\n") + usedBy); it->setToolTip(2, usedBy); + it->setText(3, QStringLiteral("Unspecified (generated)")); }; show(m_text, texts, QStringLiteral("Text generator: type a text; a snapshot that holds another text types it (typewriter)")); show(m_timecode, timecodes, @@ -525,6 +534,7 @@ void MediaBin::probe(const QString &path) // Read metadata off the UI thread (files on network drives, etc.) QThreadPool::globalInstance()->start([self, path, image] { QString info; + QString color = QStringLiteral("Unknown"); AudioStream::Info ai; const bool sound = !image && AudioStream::probe(path, &ai); const QString soundText = sound ? QStringLiteral("%1 %2 kHz %3") @@ -538,6 +548,7 @@ void MediaBin::probe(const QString &path) VideoDecoder::Info vi; if (Engine::isAudioFile(path) || !VideoDecoder::probe(path, &vi)) { // Sound only + if (sound) color = QStringLiteral("Not applicable"); info = sound ? (ai.duration > 0 ? fmtDuration(ai.duration) + QStringLiteral(" · ") : QString()) + QStringLiteral("%1 kHz").arg(ai.sampleRate / 1000.0, 0, 'g', 3) + QStringLiteral("\t") + soundText @@ -550,6 +561,7 @@ void MediaBin::probe(const QString &path) if (vi.fps > 0) more << QStringLiteral("%1 fps").arg(vi.fps, 0, 'g', 4); if (!vi.codec.isEmpty()) more << vi.codec; more << vi.colorDescription(); + color = vi.colorSpaceSummary(); more << (sound ? QStringLiteral("sound: ") + soundText : QStringLiteral("no sound")); if (vi.bitDepth > 0) info += QStringLiteral(" · %1-bit").arg(vi.bitDepth); if (!vi.matrix.isEmpty()) info += QStringLiteral(" · ") + vi.matrix; @@ -570,15 +582,18 @@ void MediaBin::probe(const QString &path) r.setScaledSize(QSize(1, 1)); const QImage sample = r.read(); const QColorSpace space = sample.colorSpace(); - tags << (space.isValid() ? QStringLiteral("ICC: ") + - (space.description().isEmpty() ? QStringLiteral("embedded profile") : space.description()) - : QStringLiteral("color profile: untagged/unknown")); + tags << imageColorDescription(space); + color = imageColorSpaceName(space); } else { tags << QStringLiteral("color profile: not probed (large image)"); + color = QStringLiteral("Not probed (large image)"); } info += QStringLiteral("\t") + tags.join(QStringLiteral(" · ")); } } + // Three fields: short info, tooltip details, explicit source color tags. + if (!info.contains('\t')) info += '\t'; + info += '\t' + color; QMetaObject::invokeMethod( QCoreApplication::instance(), [self, path, info] { diff --git a/src/ui/ViewportOutput.cpp b/src/ui/ViewportOutput.cpp index 6bf2612..6c3cf16 100644 --- a/src/ui/ViewportOutput.cpp +++ b/src/ui/ViewportOutput.cpp @@ -3,6 +3,9 @@ #include "Engine.h" #include "OutputWindow.h" #include "Widgets.h" +#include "Commands.h" +#include "ColorInfo.h" +#include #include #include @@ -31,8 +34,8 @@ static QLabel *note(const QString &t) static QSize nativeSize(const QScreen *sc) { return sc->geometry().size() * sc->devicePixelRatio(); } -ViewportOutputPanel::ViewportOutputPanel(Engine *engine, quint64 viewport, QWidget *parent) - : QWidget(parent), m_engine(engine), m_viewport(viewport) +ViewportOutputPanel::ViewportOutputPanel(Engine *engine, quint64 viewport, QUndoStack *undo, QWidget *parent) + : QWidget(parent), m_engine(engine), m_undo(undo), m_viewport(viewport) { QSize size; QString screen; @@ -114,6 +117,61 @@ ViewportOutputPanel::ViewportOutputPanel(Engine *engine, quint64 viewport, QWidg connect(m_width, qOverload(&QSpinBox::valueChanged), this, &ViewportOutputPanel::applySize); connect(m_height, qOverload(&QSpinBox::valueChanged), this, &ViewportOutputPanel::applySize); + auto *color = new QGroupBox(QStringLiteral("Output Color")); + auto *cf = new QFormLayout(color); + m_decode = new QComboBox; + m_decode->setObjectName(QStringLiteral("outputDecode")); + m_encode = new QComboBox; + m_encode->setObjectName(QStringLiteral("outputEncode")); + m_encode->addItem(QStringLiteral("Unchanged"), QStringLiteral("same")); + const auto keys = outputColorSpaceKeys(), labels = outputColorSpaceLabels(); + for (int i = 0; i < keys.size(); ++i) { + m_decode->addItem(labels[i], keys[i]); + m_encode->addItem(labels[i], keys[i]); + } + m_decode->setToolTip(QStringLiteral("Declare the space of the already composited RGB values. " + "Native sources are normalized to sRGB: keep sRGB unless a viewport shader deliberately changes its encoding.")); + m_encode->setToolTip(QStringLiteral("Convert the declared rendered input space to this space, including primaries and transfer. " + "Rec.709 / Gamma 2.4 assumes an ideal black display; values outside the target gamut are clipped.")); + m_exposure = new NumberBox; + m_exposure->setObjectName(QStringLiteral("outputExposure")); + m_exposure->setRange(-10, 10); + m_exposure->setDecimals(2); + m_exposure->setSingleStep(.1); + m_exposure->setSuffix(QStringLiteral(" EV")); + m_exposure->setKeyboardTracking(false); + m_exposure->setToolTip(QStringLiteral("Multiply decoded RGB by 2 to this power, before encoding; +1 doubles the values.")); + cf->addRow(QStringLiteral("Rendered input space"), m_decode); + cf->addRow(QStringLiteral("Exposure"), m_exposure); + cf->addRow(QStringLiteral("Output color space"), m_encode); + auto applyColor = [this](const QString &path, const QJsonValue &value) { + if (m_syncing || m_engine->isLocked(index())) return; + if (cmd::setValue(m_undo, m_engine, m_viewport, path, value, QStringLiteral("Change Output Color"))) emit edited(); + }; + connect(m_decode, qOverload(&QComboBox::activated), this, [this, applyColor] { + applyColor(QStringLiteral("output/input_space"), m_decode->currentData().toString()); + }); + connect(m_encode, qOverload(&QComboBox::activated), this, [this, applyColor] { + applyColor(QStringLiteral("output/space"), m_encode->currentData().toString()); + }); + connect(m_exposure, qOverload(&QDoubleSpinBox::valueChanged), this, [applyColor](double value) { + applyColor(QStringLiteral("output/exposure"), value); + }); + auto *resetColor = new QPushButton(QStringLiteral("Reset Output Color")); + resetColor->setObjectName(QStringLiteral("resetOutputColor")); + connect(resetColor, &QPushButton::clicked, this, [this] { + if (m_engine->isLocked(index())) return; + if (cmd::setValues(m_undo, m_engine, m_viewport, + {{"output/input_space", "srgb"}, {"output/space", "same"}, {"output/exposure", 0}}, + QStringLiteral("Reset Output Color"))) emit edited(); + refreshStatus(); + }); + cf->addRow(resetColor); + cf->addRow(note(QStringLiteral("Applied only to this viewport after its effects and opacity, before blackout. " + "Decode → gamut conversion and exposure → encode. Unchanged preserves existing values. " + "No automatic source-profile conversion or HDR tone mapping."))); + v->addWidget(color); + // --- Screen and how it is shown there auto *out = new QGroupBox(QStringLiteral("Screen")); auto *of = new QFormLayout(out); @@ -357,12 +415,23 @@ void ViewportOutputPanel::refreshStatus() { int mode = 0; bool testCard = false; + QString decode, encode; + double exposure = 0; { Engine::Lock lk(&m_engine->mutex()); const Layer *l = m_engine->layer(index()); if (!l) return; mode = l->vpMode; testCard = l->testCard; + decode = l->outputInputSpace; + encode = l->outputSpace; + exposure = l->outputExposure; + } + { + const QSignalBlocker a(m_decode), b(m_encode), c(m_exposure); + m_decode->setCurrentIndex(m_decode->findData(decode)); + m_encode->setCurrentIndex(m_encode->findData(encode)); + if (!m_exposure->hasFocus()) m_exposure->setValue(exposure); } if (QAbstractButton *b = m_mode->button(mode); b && !b->isChecked()) b->setChecked(true); if (m_testCard && m_testCard->isChecked() != testCard) { // set by OSC diff --git a/src/ui/ViewportOutput.h b/src/ui/ViewportOutput.h index 4dde025..a8d7587 100644 --- a/src/ui/ViewportOutput.h +++ b/src/ui/ViewportOutput.h @@ -11,6 +11,8 @@ class QLabel; class QLineEdit; class QPushButton; class QSpinBox; +class QDoubleSpinBox; +class QUndoStack; // Output tab of a viewport: its size in pixels, the screen it goes to and how it is shown there (hidden, // in a window, fullscreen), and what it publishes (NDI, OMT, Syphon, Spout), and the DeckLink port it plays out @@ -19,7 +21,7 @@ class ViewportOutputPanel : public QWidget { Q_OBJECT public: - ViewportOutputPanel(Engine *engine, quint64 viewport, QWidget *parent = nullptr); + ViewportOutputPanel(Engine *engine, quint64 viewport, QUndoStack *undo, QWidget *parent = nullptr); void refreshStatus(); // mode set elsewhere (⌘F, the window closed), publishing states signals: @@ -32,6 +34,9 @@ class ViewportOutputPanel : public QWidget void refreshDeckLink(); // the ports plugged in, and their modes int index() const; Engine *m_engine; + QUndoStack *m_undo; + QComboBox *m_decode = nullptr, *m_encode = nullptr; + QDoubleSpinBox *m_exposure = nullptr; quint64 m_viewport; QComboBox *m_preset = nullptr, *m_screen = nullptr, *m_omtQuality = nullptr; QSpinBox *m_width = nullptr, *m_height = nullptr; diff --git a/test/color_reference.h b/test/color_reference.h new file mode 100644 index 0000000..7e88898 --- /dev/null +++ b/test/color_reference.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include +#include +#include + +// Independent double-precision colorimetric oracle. Derive matrices from the +// published D65/chromaticity coordinates, not the shader's matrix constants. +// https://www.w3.org/TR/css-color-4/#color-conversion-code +// Gamma 2.4 is the ideal-black BT.1886 approximation, not the camera BT.709 OETF. +namespace colorReference { +using Vector = std::array; +using Matrix = std::array; +inline Vector multiply(const Matrix &m, const Vector &v) { + Vector result{}; + for (int i=0;i<3;++i) for (int j=0;j<3;++j) result[i]+=m[i][j]*v[j]; + return result; +} +inline Vector solve(const Matrix &m, const Vector &v) { + double a[3][4]{}; + for(int i=0;i<3;++i) { for(int j=0;j<3;++j) a[i][j]=m[i][j]; a[i][3]=v[i]; } + for(int k=0;k<3;++k) { + int pivot=k; + for(int i=k+1;i<3;++i) if(std::abs(a[i][k])>std::abs(a[pivot][k])) pivot=i; + for(int j=0;j<4;++j) std::swap(a[k][j],a[pivot][j]); + const double scale=a[k][k]; + for(int j=k;j<4;++j) a[k][j]/=scale; + for(int i=0;i<3;++i) if(i!=k) { + const double factor=a[i][k]; + for(int j=k;j<4;++j) a[i][j]-=factor*a[k][j]; + } + } + return {a[0][3],a[1][3],a[2][3]}; +} +inline Matrix rgbToXyz(bool p3) { + const double xy[3][2]={{p3?.68:.64,p3?.32:.33}, {p3?.265:.30,p3?.69:.60}, {.15,.06}}; + Matrix m{}; + for(int c=0;c<3;++c) { + m[0][c]=xy[c][0]/xy[c][1]; + m[1][c]=1; + m[2][c]=(1-xy[c][0]-xy[c][1])/xy[c][1]; + } + const auto scales=solve(m,{.3127/.3290,1,(1-.3127-.3290)/.3290}); + for(int c=0;c<3;++c) for(int r=0;r<3;++r) m[r][c]*=scales[c]; + return m; +} +inline Vector convert(Vector rgb, const QString &input, const QString &output) { + for(double &c:rgb) { + if(input=="rec709-g24") c=std::pow(c,2.4); + else if(input=="rec709") c=c<.081?c/4.5:std::pow((c+.099)/1.099,1/.45); + else if(input!="linear709") c=c<=.04045?c/12.92:std::pow((c+.055)/1.055,2.4); + } + rgb=solve(rgbToXyz(output=="displayp3"),multiply(rgbToXyz(input=="displayp3"),rgb)); + for(double &c:rgb) { + c=std::max(0.,c); + if(output=="rec709-g24") c=std::pow(c,1/2.4); + else if(output!="linear709") c=c<=.0031308?12.92*c:1.055*std::pow(c,1/2.4)-.055; + c=std::clamp(c,0.,1.); + } + return rgb; +} +} diff --git a/test/engine_test.cpp b/test/engine_test.cpp index 01ab3e1..e78c202 100644 --- a/test/engine_test.cpp +++ b/test/engine_test.cpp @@ -52,6 +52,7 @@ int runCueLifecycleTests(Engine &e); int runGpuTargetTests(Engine &e); // gpu_targets.cpp int runImagePrecisionTests(Engine &engine); int runShowHoldTests(Engine &engine); +int runOutputColorTests(Engine &engine); int runRecallIntegrityTests(Engine &engine); int runGoldenRenderTests(Engine &e, const QString &root, const QString &tmp); // golden_render.cpp int runOperatorStateTests(Engine &e); @@ -172,6 +173,7 @@ int main(int argc, char **argv) } // cue_capture if (area("cue_lifecycle")) failures += runCueLifecycleTests(e); if (area("recall_integrity")) failures += runRecallIntegrityTests(e); + if (area("output_color")) failures += runOutputColorTests(e); if (area("show_hold")) failures += runShowHoldTests(e); if (area("image_precision")) failures += runImagePrecisionTests(e); if (area("gpu_targets")) { diff --git a/test/output_color.cpp b/test/output_color.cpp new file mode 100644 index 0000000..b271b8d --- /dev/null +++ b/test/output_color.cpp @@ -0,0 +1,261 @@ +#include "Engine.h" +#include "ColorInfo.h" +#include "Commands.h" +#include +#include "color_reference.h" +#include +#include +#include +#include + +// Exercise the source boundary itself, before ROI/FX/compositing can hide +// a transfer error or accidentally premultiply alpha. +static int runSourceColorTests(Engine &engine) +{ + int failures = 0; + auto check = [&](bool ok, const char *why) { + if (!ok) { ++failures; std::printf("FAIL source_color: %s\n", why); } + }; + engine.newProject(); + constexpr int width = 1024, height = 16; + engine.setCompositionSize({width, height}); + engine.setViewportSize(engine.indexOfId(engine.mainViewportId()), {width, height}); + QTemporaryDir files; + QImage source(width, height, QImage::Format_RGBA64); + for (int y = 0; y < height; ++y) { + auto *row = reinterpret_cast(source.scanLine(y)); + for (int x = 0; x < width; ++x) + row[x] = QRgba64::fromRgba64(quint16(4000+x*54), quint16(60000-x*47), quint16(12000+x*29), 32768); + } + const QString path = files.path()+"/untagged16.png"; + check(source.save(path), "write an untagged 16-bit source"); + int row = engine.addLayer("Declared source"); + quint64 id = engine.layerId(row); + QString error; + check(engine.setLayerImage(row, path, &error), "load untagged source"); + auto read = [&](quint64 layerId) { + std::vector pixels(size_t(width)*4); + engine.runGl([&] { + auto f = gl(); + GLuint fbo = 0; + f->glGenFramebuffers(1, &fbo); + f->glBindFramebuffer(GL_FRAMEBUFFER, fbo); + const auto *layer = engine.layer(engine.indexOfId(layerId)); + f->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, layer->rawTex, 0); + check(f->glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE, "normalized source is readable"); + f->glReadPixels(0, height/2, width, 1, GL_RGBA, GL_UNSIGNED_SHORT, pixels.data()); + f->glBindFramebuffer(GL_FRAMEBUFFER, 0); + f->glDeleteFramebuffers(1, &fbo); + }); + return pixels; + }; + auto matches = [&](const std::vector &pixels, const QString &input) { + int worst = 0; + const auto *samples = reinterpret_cast(source.constScanLine(height/2)); + for (int x = 0; x < width; ++x) { + const auto sample = samples[x]; + const auto expected = colorReference::convert({sample.red()/65535.,sample.green()/65535.,sample.blue()/65535.}, input, "srgb"); + for (int c = 0; c < 3; ++c) + worst = std::max(worst, std::abs(int(pixels[size_t(x)*4+c])-int(std::lround(expected[c]*65535)))); + worst = std::max(worst, std::abs(int(pixels[size_t(x)*4+3])-int(sample.alpha()))); + } + if (worst > 16) std::printf("Source %s -> sRGB: worst error %d/65535\n", qPrintable(input), worst); + return worst <= 16; + }; + for (const QString &key : {QString("srgb"), QString("linear709"), QString("displayp3"), QString("rec709-g24"), QString("rec709")}) { + check(engine.setParameter(id, "source/color_space", key), "declare a native source space"); + engine.renderFrame(); + check(matches(read(id), key), "source conversion matches reference and preserves straight alpha at 16 bits"); + } + check(engine.setParameter(id, "source/color_space", "auto"), "restore Auto"); + engine.renderFrame(); + check(matches(read(id), "srgb"), "unknown Auto preserves legacy samples"); + source.setColorSpace(QColorSpace::DisplayP3); + check(source.save(path) && engine.setLayerImage(engine.indexOfId(id), path, &error), "replace with tagged P3 source"); + engine.renderFrame(); + const auto p3 = read(id); + check(matches(p3, "displayp3"), "Auto converts embedded P3 before effects/compositing"); + QUndoStack undo; + check(cmd::setValues(&undo, &engine, id, {{"source/color_space", "srgb"}}, "Declare source"), "source declaration supports Undo"); + engine.renderFrame(); + check(matches(read(id), "srgb"), "manual declaration overrides rather than double-converts the embedded profile"); + undo.undo(); engine.renderFrame(); + check(read(id) == p3, "Undo restores the same Auto-normalized pixels"); + // A layer reference is already in working RGB. It must not inherit another decode. + int tap = engine.addLayer("Working-space tap"); + const quint64 tapId = engine.layerId(tap); + check(engine.setLayerSourceLayer(tap, id, LayerTap::PostFx, &error), "tap the normalized layer"); + engine.renderFrame(); + check(read(tapId) == p3, "layer references are not normalized twice"); + engine.removeLayer(engine.indexOfId(tapId)); + + QImage background(width, height, QImage::Format_RGBA64); + background.setColorSpace(QColorSpace::SRgb); + background.fill(QColor::fromRgba64(12000, 24000, 36000, 65535)); + const QString backgroundPath = files.path()+"/srgb16.png"; + check(background.save(backgroundPath), "write sRGB background"); + const int backgroundRow = engine.addLayer("sRGB background", engine.layerCount()); + const quint64 backgroundId = engine.layerId(backgroundRow); + check(engine.setLayerImage(backgroundRow, backgroundPath, &error), "mix an sRGB source with P3"); + engine.renderFrame(); + const auto mixed = read(engine.mainViewportId()); + bool mixedCorrect = true; + const int bg[] = {12000, 24000, 36000}; + for (int x = 0; x < width; ++x) for (int c = 0; c < 3; ++c) { + const double a = p3[size_t(x)*4+3]/65535.; + const int expectedMixed = int(std::lround(p3[size_t(x)*4+c]*a+bg[c]*(1-a))); + mixedCorrect = mixedCorrect && std::abs(int(mixed[size_t(x)*4+c])-expectedMixed) <= 16; + } + check(mixedCorrect, "mixed P3/sRGB sources blend only after normalization with preserved alpha"); + engine.removeLayer(engine.indexOfId(backgroundId)); + + // An uncommon ICC profile uses Qt's one-time CPU conversion; original samples + // are retained so a later explicit declaration can still reinterpret them. + source.setColorSpace(QColorSpace::AdobeRgb); + check(source.save(path) && engine.setLayerImage(engine.indexOfId(id), path, &error), "load Adobe RGB ICC source"); + engine.renderFrame(); + const auto converted = QImage(path).convertToFormat(QImage::Format_RGBA64).convertedToColorSpace(QColorSpace::SRgb); + const auto pixels = read(id); + const auto *expected = reinterpret_cast(converted.constScanLine(height/2)); + bool equal = true; + for (int x = 0; x < width*4; ++x) equal = equal && std::abs(int(pixels[size_t(x)])-int(expected[x])) <= 1; + check(equal, "ICC conversion preserves the 16-bit result and alpha"); + engine.setParameter(id, "source/color_space", "srgb"); engine.renderFrame(); + check(matches(read(id), "srgb"), "ICC manual override still reads the original source samples"); + engine.setParameter(id, "source/color_space", "linear709"); + const QString project = files.path()+"/source-color.pinol"; + check(engine.saveProject(project, {}, &error) && engine.loadProject(project, nullptr, &error), "reload source color project"); + check(engine.parameterValue(id, "source/color_space") == "linear709", "project load restores the declaration"); + engine.renderFrame(); + check(matches(read(id), "linear709"), "reloaded declaration changes actual rendered pixels"); + return failures; +} + +int runOutputColorTests(Engine &engine) +{ + int failures = runSourceColorTests(engine); + auto check = [&](bool ok, const char *why) { + if (!ok) { ++failures; std::printf("FAIL output_color: %s\n", why); } + }; + engine.newProject(); + engine.fadeCompositionOpacity(1, 0); + engine.setBlackout(false, 0); + constexpr int width = 1024, height = 16; + engine.setCompositionSize({width,height}); + const quint64 first = engine.mainViewportId(); + engine.setViewportSize(engine.indexOfId(first), {width,height}); + QTemporaryDir files; + QImage source(width, height, QImage::Format_RGBA64); + source.setColorSpace(QColorSpace::SRgb); + for (int y=0; y(source.scanLine(y)); + for (int x=0; x pixels(size_t(width)*4); + engine.runGl([&] { + Engine::Lock lock(&engine.mutex()); + Layer *v = engine.layer(engine.indexOfId(viewport)); + auto f=gl(); + GLuint sourceFbo=0; + if(preOutput) { + f->glGenFramebuffers(1,&sourceFbo); + f->glBindFramebuffer(GL_FRAMEBUFFER,sourceFbo); + f->glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,v->finalTex,0); + } else v->vpOut[v->vpPublished.load()].bind(); + f->glReadPixels(0, height/2, width, 1, GL_RGBA, GL_UNSIGNED_SHORT,pixels.data()); + f->glBindFramebuffer(GL_FRAMEBUFFER,0); + if(sourceFbo) f->glDeleteFramebuffers(1,&sourceFbo); + }); + return pixels; + }; + const auto identity=read(first); + // Compare transforms against their actual input texture, before the neutral + // output mesh introduces any additional sampling/UNORM rounding. + const auto sourceSamples=read(first,true); + const QImage composition=engine.grabOutput(); + int levels=1; + for(int x=1;xidentity[size_t(x-1)*4]) ++levels; + check(levels>1000,"16-bit reference gradient retains more than 1000 levels through compositing"); + const int added = engine.addViewport("Independent output", {width,height}); + const quint64 second = engine.layerId(added); + // Analytical D65/chromaticity reference: older Qt versions approximate + // gamma conversions and are not a stable oracle at 16-bit precision. + for(const QString &input:outputColorSpaceKeys()) { + for(const QString &output:outputColorSpaceKeys()) { + engine.setParameter(first,"output/input_space",input); + engine.setParameter(first,"output/space",output); + engine.renderFrame(); + const auto actual=read(first); + int worst=0; + for(int x=0;x16) std::printf("Color transform %s -> %s: worst error %d/65535\n",qPrintable(input),qPrintable(output),worst); + check(worst<=16,"named output spaces match an independent color conversion across 1024 colors"); + check(read(second)==identity,"one viewport's transform does not change another viewport"); + check(engine.grabOutput()==composition,"output transform does not rewrite the composition preview"); + } + } + engine.setParameter(first,"output/input_space","linear709"); + engine.setParameter(first,"output/space","same"); + engine.setParameter(first,"output/exposure",1); + engine.renderFrame(); + const auto exposed=read(first); + bool doubled=true; + for(int x=0;x +#include +#include +#include + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + Engine engine; + const int viewport = engine.addViewport("Color output", {32,32}); + const quint64 id = engine.layerId(viewport); + int failures = 0; + auto check = [&](bool ok, const char *why) { + if (!ok) { ++failures; std::printf("FAIL output_color_state: %s\n", why); } + }; + check(engine.parameterValue(id, "output/input_space") == "srgb" && + engine.parameterValue(id, "output/space") == "same" && + engine.parameterValue(id, "output/exposure").toDouble() == 0, "legacy projects default to unchanged output"); + const auto gray=colorReference::convert({.5,.5,.5},"srgb","linear709"); + check(std::abs(gray[0]-.21404114048223255)<1e-12, "reference matches the standard sRGB gray value"); + const auto p3red=colorReference::solve(colorReference::rgbToXyz(false), + colorReference::multiply(colorReference::rgbToXyz(true),{1,0,0})); + check(std::abs(p3red[0]-1.22494017628056)<1e-10 && + std::abs(p3red[1]+.0420569547096882)<1e-10, + "reference derives known P3-to-Rec709 gamut coordinates from chromaticities"); + const auto gamma=colorReference::convert({.25,.25,.25},"linear709","rec709-g24"); + check(std::abs(gamma[0]-.5612310241546865)<1e-12, "reference uses the analytical Gamma 2.4 curve"); + QUndoStack undo; + check(cmd::setValues(&undo, &engine, id, + {{"output/input_space", "displayp3"}, {"output/space", "linear709"}, {"output/exposure", 1.25}}, + "Output color"), "output controls use ordinary undoable parameters"); + const auto saved = engine.layerJson(viewport).value("params").toObject(); + check(saved.value("output/input_space") == "displayp3" && saved.value("output/space") == "linear709" && + saved.value("output/exposure") == 1.25, "project serialization retains color settings"); + Layer restored; + restored.isViewport = true; + parametersFromJson(restored.parameters(), saved); + check(restored.outputInputSpace == "displayp3" && restored.outputSpace == "linear709" && + restored.outputExposure == 1.25, "ordinary project parameter restore restores output color"); + undo.undo(); + check(engine.parameterValue(id, "output/space") == "same" && + engine.parameterValue(id, "output/exposure").toDouble() == 0, "one Undo restores the whole color edit"); + undo.redo(); + check(engine.parameterValue(id, "output/space") == "linear709", "Redo restores the chosen output space"); + check(!engine.setParameter(id, "output/space", "invalid"), "unknown output spaces are rejected"); + check(engine.setParameter(id, "output/exposure", 100) && + engine.parameterValue(id, "output/exposure") == 10, "exposure is bounded"); + const int copiedViewport = engine.addViewport("Copied color", {32,32}); + check(engine.applyLayerParts(copiedViewport, engine.layerJson(engine.indexOfId(id)), Engine::PartCompositing), + "copy viewport compositing settings"); + check(engine.layer(copiedViewport)->outputSpace == "linear709" && + engine.layer(copiedViewport)->outputInputSpace == "displayp3" && + engine.layer(copiedViewport)->outputExposure == 10, "viewport paste carries output color settings"); + const int normal = engine.addLayer("Not a viewport"); + check(engine.parameterValue(engine.layerId(normal), "output/space").isUndefined(), "output calibration is viewport-only"); + + QTemporaryDir files; + QImage image(16, 16, QImage::Format_RGBA64); + image.fill(Qt::gray); + check(imageColorDescription(image.colorSpace()).contains("Unknown"), "untagged images remain explicitly unknown"); + for (auto named : {QColorSpace::SRgb, QColorSpace::DisplayP3, QColorSpace::SRgbLinear}) { + const QColorSpace profile(named); + image.setColorSpace(profile); + const QString path = files.path() + QStringLiteral("/%1.png").arg(int(named)); + check(image.save(path), "write tagged 16-bit PNG fixture"); + const QImage read(path); + check(read.colorSpace() == profile, "real PNG profile survives decoding"); + const QString description = imageColorDescription(read.colorSpace()); + check(description.contains("primaries:") && description.contains("transfer:") && !description.contains("Unknown"), + "tagged source details show primaries and transfer independently of depth"); + } + VideoDecoder::Info video; + video.matrix = "bt709"; + check(video.colorSpaceSummary().contains("Unknown primaries"), "a YUV matrix never implies primaries"); + video.primaries = "bt2020"; video.transfer = "smpte2084"; video.range = "limited (TV)"; + check(video.colorSpaceSummary().contains("bt2020") && video.colorSpaceSummary().contains("smpte2084") && + video.colorDescription().contains("limited"), "video tags expose HDR transfer, primaries and range without guessing"); + check(videoSourceColorKey(video).isEmpty(), "HDR video is never silently treated as an SDR profile"); + video.primaries = "bt709"; video.transfer = "bt709"; + check(videoSourceColorKey(video) == "rec709", "tagged Rec709 uses its own transfer, distinct from sRGB/gamma 2.4"); + video.primaries.clear(); + check(videoSourceColorKey(video).isEmpty(), "video Auto requires both primaries and transfer tags"); + Layer sourceLayer; + sourceLayer.type = SourceType::Image; + check(sourceColorStatus(sourceLayer).contains("assumed sRGB"), "untagged Auto reports its assumption"); + sourceLayer.imageProfile = QColorSpace::DisplayP3; + check(sourceColorKey(sourceLayer) == "displayp3", "Auto recognizes the embedded P3 profile"); + sourceLayer.parameter("source/color_space")->setValue("linear709"); + const auto sourceParams = parametersToJson(sourceLayer.parameters()); + Layer restoredSource; + restoredSource.type = SourceType::Image; + parametersFromJson(restoredSource.parameters(), sourceParams); + check(restoredSource.sourceColorSpace == "linear709", "source declarations survive parameter serialization"); + sourceLayer.type = SourceType::Layer; + check(!sourceLayer.parameter("source/color_space") && sourceColorKey(sourceLayer) == "srgb", + "layer taps cannot be decoded twice"); + sourceLayer.type = SourceType::Image; + sourceLayer.sourceColorSpace = "auto"; +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + sourceLayer.imageProfile = QColorSpace::Bt2100Pq; + check(sourceColorStatus(sourceLayer).contains("no tone mapping"), "HDR image status explicitly reports the limitation"); +#endif + sourceLayer.type = SourceType::Video; + sourceLayer.video = std::make_unique(); + auto frameLayout = std::make_shared(); + frameLayout->primaries = "bt709"; frameLayout->transfer = "bt709"; + sourceLayer.frame.layout = frameLayout; + check(sourceColorKey(sourceLayer) == "rec709", "frame tags work even when the container has no color tags"); + frameLayout->primaries = "bt2020"; frameLayout->transfer = "smpte2084"; + check(sourceColorKey(sourceLayer) == "srgb" && sourceColorStatus(sourceLayer).contains("unsupported"), + "a change to unsupported HDR frame tags is reported without an incorrect SDR conversion"); + return failures ? 1 : 0; +} diff --git a/test/show_clock.cpp b/test/show_clock.cpp index fce3d79..b458337 100644 --- a/test/show_clock.cpp +++ b/test/show_clock.cpp @@ -27,6 +27,18 @@ int main(int argc, char **argv) check(std::abs(show - wall) < 1e-10, "repeated stalls do not accumulate show-clock drift at 60/120 Hz"); } check(FrameStep::fromElapsed(8, false).elapsed == .25, "manual renders retain their bounded step"); + { + FaderClock clock; + clock.reset(1'900'000'000); // command near the end of a two-second frame + check(std::abs(clock.step(2'000'000'000, true) - .1) < 1e-12, + "a new fader excludes time before its command"); + check(std::abs(clock.step(2'500'000'000, true) - .5) < 1e-12, + "the fader counts each subsequent wall interval once"); + clock.reset(2'550'000'000); // operator interrupts/reverses it + check(std::abs(clock.step(2'600'000'000, true) - .05) < 1e-12, + "a replacement fader starts at its own command"); + check(clock.step(5'000'000'000, false) == .25, "manual render faders retain the bounded step"); + } check(FrameStep::fromElapsed(-1, true).elapsed == 0 && FrameStep::fromElapsed(std::numeric_limits::infinity(), true).elapsed == 0, "invalid elapsed times cannot poison the clocks"); diff --git a/test/video_test.cpp b/test/video_test.cpp index 7eca45f..87c0475 100644 --- a/test/video_test.cpp +++ b/test/video_test.cpp @@ -552,7 +552,10 @@ int runVideoTests(Engine &e, const QString &root, const QString &tmp) } sws_freeContext(to); sws_freeContext(back); + fr->color_primaries = AVCOL_PRI_BT709; + fr->color_trc = AVCOL_TRC_IEC61966_2_1; const auto L = VideoDecoder::layoutOf(fr); + if (L) VCHECK(L->primaries == "bt709" && L->transfer == "iec61966-2-1"); if (!L || !fm.gpu) { std::printf(" %-12s %s\n", fm.name, L ? "taken by the GPU" : "refused: converted on the CPU"); VCHECK(!L == !fm.gpu); diff --git a/test/workspace_ui.cpp b/test/workspace_ui.cpp index 5bf59bd..c6a7df6 100644 --- a/test/workspace_ui.cpp +++ b/test/workspace_ui.cpp @@ -8,6 +8,11 @@ #include "LayerInspector.h" #include "PresetEditor.h" #include "ColorPicker.h" +#include "ViewportOutput.h" +#include +#include +#include +#include #include #include "Commands.h" #include "LayerTable.h" @@ -97,6 +102,32 @@ int main(int argc, char **argv) auto *mainTabs = window.findChild("mainInspectorTabs"); check(mainTabs && mainTabs->tabText(1) == "Viewports" && mainTabs->tabText(2) == "Composition", "Viewports sits between Layer and Composition"); check(viewportList && viewportList->currentRow() == 0 && viewportInspector && viewportInspector->layerIndex() == engine.viewports().front(), "first viewport selected by default"); + { + const quint64 viewport = engine.layerId(engine.viewports().front()); + QUndoStack colorUndo; + ViewportOutputPanel panel(&engine, viewport, &colorUndo); + auto *input = panel.findChild("outputDecode"); + auto *output = panel.findChild("outputEncode"); + auto *exposure = panel.findChild("outputExposure"); + auto *reset = panel.findChild("resetOutputColor"); + check(input && output && exposure && reset, "viewport output exposes named input/output spaces and exposure"); + if (input && output && exposure && reset) { + output->setCurrentIndex(output->findData("displayp3")); + output->activated(output->currentIndex()); + exposure->setValue(1); + check(engine.parameterValue(viewport,"output/space") == "displayp3" && + engine.parameterValue(viewport,"output/exposure").toDouble() == 1, + "output color widgets write viewport parameters"); + reset->click(); + check(engine.parameterValue(viewport,"output/space") == "same" && exposure->value() == 0, + "one reset restores neutral output and refreshes the widgets"); + colorUndo.undo(); + panel.refreshStatus(); + check(output->currentData() == "displayp3" && exposure->value() == 1, + "Undo restores color controls as well as the rendered settings"); + colorUndo.undo(); colorUndo.undo(); + } + } auto *update = window.findChild("snapshotUpdate"); check(update && lists->isAncestorOf(update), "snapshot Update is in library toolbar"); auto *remove = window.findChild("presetDelete"); @@ -126,6 +157,8 @@ int main(int argc, char **argv) } // Media Bin filters: by type, used / unused; the rebuild button; Replace… for an ISF generator auto *tree = bin->findChild(); + check(tree && tree->columnCount() == 4 && tree->headerItem()->text(3) == "Color space", + "media bin has an explicit source color-space column"); auto *type = bin->findChild("typeFilter"); auto *usage = bin->findChild("usageFilter"); auto *rebuild = bin->findChild("rebuild"); @@ -141,6 +174,28 @@ int main(int argc, char **argv) }; if (tree && type && usage && rebuild && loaded >= 0) { bin->refresh(); + { + QTemporaryDir profiles; + const QString tagged = profiles.path() + "/tagged.png", untagged = profiles.path() + "/untagged.png"; + QImage image(16,16,QImage::Format_RGBA64); + image.fill(Qt::gray); + check(image.save(untagged), "create untagged media-bin fixture"); + image.setColorSpace(QColorSpace::DisplayP3); + check(image.save(tagged), "create Display P3 media-bin fixture"); + engine.addBinItems({tagged,untagged}); bin->refresh(); + QElapsedTimer wait; wait.start(); + while(wait.elapsed()<5000) { + app.processEvents(); + auto *a=itemOf(tagged), *b=itemOf(untagged); + if(a && b && a->text(3)!="…" && b->text(3)!="…") break; + QThread::msleep(1); + } + auto *a=itemOf(tagged), *b=itemOf(untagged); + check(a && a->text(3).contains("P3") && a->toolTip(3).contains("primaries:"), + "media-bin async probe displays the embedded source profile and detailed primaries"); + check(b && b->text(3).contains("Unknown"), "media-bin async probe keeps untagged source space unknown"); + engine.removeBinItem(tagged); engine.removeBinItem(untagged); bin->refresh(); + } QTreeWidgetItem *gen = itemOf(shaderPath); check(gen && shown(gen), "used ISF generator listed in the media bin"); usage->setCurrentIndex(2); // Unused @@ -319,6 +374,22 @@ int main(int argc, char **argv) check(inspector->coTargets() == QList{ids[2]}, "inspector knows the other selected layer"); auto *note = inspector->findChild("multiSelectionNote"); check(note && note->isVisible(), "inspector says the edit applies to the whole selection"); + auto *sourceSpace = inspector->findChild("sourceColorSpace"); + check(sourceSpace && sourceSpace->findData("displayp3") >= 0, "source interpretation control is present"); + if (sourceSpace) { + const int p3 = sourceSpace->findData("displayp3"); + sourceSpace->setCurrentIndex(p3); + QMetaObject::invokeMethod(sourceSpace, "activated", Qt::DirectConnection, Q_ARG(int, p3)); + check(engine.parameterValue(ids[0], "source/color_space") == "displayp3" && + engine.parameterValue(ids[2], "source/color_space") == "displayp3" && + engine.parameterValue(ids[1], "source/color_space") == "auto", + "source declaration reaches the selection only"); + undo->undo(); + check(engine.parameterValue(ids[0], "source/color_space") == "auto" && + engine.parameterValue(ids[2], "source/color_space") == "auto", "one Undo restores source interpretation"); + app.processEvents(); + undo->clear(); // the next scenario starts with no redo branch + } auto *opacity = inspector->findChild("inspectorOpacity"); check(opacity != nullptr, "inspector opacity field present"); if (opacity) {