Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -417,14 +417,18 @@ 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})
target_compile_definitions(pinol_tests PRIVATE TEST_DIR="${CMAKE_SOURCE_DIR}/test")
# 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)
Expand Down Expand Up @@ -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()
Expand Down
85 changes: 24 additions & 61 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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. |
80 changes: 80 additions & 0 deletions src/engine/ColorInfo.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#pragma once
#include <QColorSpace>
#include <QMetaEnum>
#include <QString>
#include <QStringList>
#include <algorithm>

// 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<QColorSpace::Primaries>();
const char *name = primaries.valueToKey(int(space.primaries()));
const char *curve = QMetaEnum::fromType<QColorSpace::TransferFunction>().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<decltype(value)>().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
}
30 changes: 28 additions & 2 deletions src/engine/Engine.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions src/engine/Engine.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -887,6 +890,7 @@ class Engine : public QObject
void updateTimeScale();
std::atomic<double> 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;
Expand Down
2 changes: 1 addition & 1 deletion src/engine/EngineInternal.h
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ struct Engine::Garbage {
std::unique_ptr<VideoDecoder> video;
std::unique_ptr<VideoTexture> videoTex;
std::shared_ptr<AudioStream> audio;
Texture2D tex, textTex;
Texture2D tex, textTex, imageAutoTex;
std::unique_ptr<IsfInstance> generator;
RenderTarget generatorTarget;
std::vector<std::unique_ptr<IsfInstance>> effects;
Expand Down
12 changes: 12 additions & 0 deletions src/engine/FrameTiming.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading