Skip to content

Collection-based chasers crossfade incorrectly #2044

Description

@InTheMorning

Summary

Two related defects in the chaser crossfade engine when steps are Collections rather than Scenes. The first I have a patch for; the second I have a root-cause analysis but no fix yet. Filing both together so anyone investigating crossfade behaviour has the full picture.

  • Defect A — A Scene included as a child of multiple consecutive Collection steps (e.g. a base FOH state, dimmer-at-max) flickers / blackouts during the transition between those steps instead of holding its constant value.
  • Defect B — When transitioning between Collection steps via the VC CueList "Crossfade" slider (SlidersMode=Crossfade), LTP-grouped channels (Colour, Gobo, Pan, Tilt) jump to their new value instead of fading. The same chaser, advanced by a timed fade (FadeIn>0 on the step), fades those channels smoothly.

Both behaviours reproduce on QLC+ 4.14.4 (engine commit 027e6d4fd). v5 not yet verified, but the engine source is shared so v5 likely shares both defects.

Why it matters

Theatrical / show-control workflow: each cue is typically a Collection bundling FOH + RGB + position + colour + gobo. Sharing a base wash Scene across multiple cues is a natural authoring pattern. Under the two defects above:

  • Defect A makes the shared-child pattern unusable for fades — fixtures blackout mid-transition.
  • Defect B makes the side-slider unusable for moving-head cues — heads snap to position rather than panning/tilting smoothly, despite the same fixture choreography working fine under timed fade.

Reproduction workspaces

Attached test workspaces. They use only Generic RGB + Generic dimmer + Martin MiniMAC Profile, all stock fixtures.

  • testing.qxw — reproduces Defect A. Three Collections, each containing the shared Scene a (dimmer = 255) plus a unique RGB Scene. Chaser steps through the Collections; VC CueList drives it.
  • testing2.qxw — reproduces Defect B in addition. Adds a MiniMAC moving head and parallel chasers: one slider-driven (Crossfade slider test, FadeIn=0), one auto-fade (auto fade test, FadeIn=3000). Both drive the same Collections.

To reproduce Defect A:

  1. Load testing.qxw.
  2. Open the VC CueList and drag the side crossfade slider across the step boundary.
  3. Watch the Dimmers fixture (Universe 1 addr 3) in Simple Desk. Expected: holds 255 throughout. Observed: drops / flickers.

To reproduce Defect B (after Defect A is patched):

  1. Load testing2.qxw.
  2. Use CueList 0 (slider). Drag through a step boundary while watching the MiniMAC pan/tilt/colour/gobo channels in Simple Desk. Observed: channels jump at the moment a new step is reached.
  3. Use CueList 1 (auto-fade, 3 s). Click Next. Observed: the same channels fade smoothly over 3 s.

Root cause — Defect A

When the same Scene Function is included as a child of consecutive Collection steps, the chaser transition triggers a stop() from the outgoing Collection and a start() from the incoming Collection in the same MasterTimer tick. The Scene's lifecycle goes through postRunpreRun in the same tick (engine/src/mastertimer.cpp:287-301), which calls Scene::handleFadersEnd (engine/src/scene.cpp:794-821) and either:

  • dismisses the Scene's faders (fadeOut=0 case → dismissAllFaders → asynchronous removal), or
  • flags them for fade-to-0 (fadeOut>0 case → orphan fader writing toward 0 while the new fader writes toward target).

In addition, Collection::adjustAttribute only applied intensity to children when isRunning(), but during the transient preRun/postRun of a Collection the children's intensity overrides could be set against stale or duplicated parent state — the shared-Scene case ended up with mismatched intensities between the two Collections and the resulting Universe writes oscillated.

Patch (Defect A) — 725da3145

The patch is on branch xfade-investigation-4.14.4 of my fork.

engine: enhance Collection class to manage child intensity and starting states

  • Adds Collection::childIntensity(quint32 childId, bool includeSelf): walks all Collections in the Doc and sums the intensity contribution of every currently-running (or currently-starting) Collection that controls the given child. Shared Scenes therefore get the combined parent intensity rather than only the last-started Collection's value.
  • Introduces a m_startingChildren flag tracked across preRun so adjustAttribute and childIntensity can include a Collection whose children are mid-start (between requestAttributeOverride and the end of preRun). This closes the same-tick re-start race.
  • Moves the per-child start() calls outside the mutex held over m_functions/m_runningChildren. Avoids reentrancy issues when a Scene's start() triggers signals that touch the same Collection state.
  • postRun: snapshots the relevant state under the mutex, then performs function->stop() calls outside the lock and re-applies childIntensity(false) to children that are still running under other Collections (so a Scene still owned by another active Collection retains the correct combined intensity, instead of being suddenly orphaned with stale override).
  • VCCueList::syncCrossfadePrimarySideForStartup: syncs m_primaryTop to the slider's current value at chaser start, so the side-fader doesn't temporarily think the "primary" step is the wrong one when the slider isn't at 0/100. Parallel implementation added to qmlui/virtualconsole/vccuelist.cpp for v5.
  • Adds engine/test/collection/collection_test cases for shared-child intensity scenarios and ui/test/vccuelist/vccuelist_test cases for the new crossfade-primary-side logic.

After the patch, testing.qxw no longer flickers the dimmer. Behaviour matches expected: shared channels stay constant; non-shared channels (RGB) crossfade smoothly via HTP intensity scaling.

Root cause — Defect B (not patched)

Surfaced after Defect A was fixed and the MiniMAC moving head was added to the test rig.

GenericFader::write (engine/src/genericfader.cpp:241-254) has two gated branches for canFade channels:

if (fc.canFade())
{
    if ((flags & FadeChannel::CrossFade) && fc.fadeTime() == 0)
    {
        // morph start <-> target via intensity()
        ...
    }
    else if (flags & FadeChannel::Intensity)
    {
        value = fc.current(compIntensity);
    }
}
  • The Intensity flag is set in fadechannel.cpp:177 only when m_channelRef->group() == QLCChannel::Intensity. Dimmer + RGB → set. Colour, Gobo, Pan, Tilt → not set.
  • The CrossFade flag is set in scene.cpp:760 only when the Scene's m_blendFunctionIDs contains a predecessor Scene that also owns the channel. ChaserRunner::startNewStep (chaserrunner.cpp:498-519) only wires setBlendFunctionID between two Function::SceneType steps. For Collection-typed outgoing or incoming steps, m_lastFunctionID is reset to invalidId() (chaserrunner.cpp:250,474,785) and the blend wiring never happens.

Consequence in slider mode (Chaser::Blended / BlendedCrossfadechaserrunner.cpp:521-633 zeroes step fadeIn/fadeOut):

  • HTP/Intensity channels (dimmer, RGB): the else if branch fires, value = fc.current(compIntensity) scales by the slider-driven parentIntensity. They fade.
  • LTP channels (Colour, Gobo, Pan, Tilt) on a Collection step: neither branch fires. value = fc.current() which is m_target (snapped because fadeTime == 0). They jump.

Timed-fade mode works for the same LTP channels because Chaser::FromFunction keeps the step's authored fadeIn (3000 ms in the test workspace), Scene::processValue calls fc->setFadeTime(3000) (scene.cpp:789), and FadeChannel::calculateCurrent (fadechannel.cpp:393-398) linearly ramps m_current over the fade time regardless of channel group. The LTP write path falls through GenericFader::write with a ramped fc.current() and the channel fades.

Expected behaviour (Defect B)

Crossfade slider should drive a smooth ramp on every canFade channel, matching the timed-fade behaviour. Workspace authors who want a snap (e.g. discrete gobo wheel position before opening the shutter) can structure that with an extra step — same control they have under timed fade today.

Fix directions considered (Defect B)

In my followup analysis I sketched five hypotheses. Two are the most plausible:

  • F1 — Extend blend-predecessor wiring to flatten Collection children. ChaserRunner would set m_blendFunctionIDs on the incoming Collection's Scene children to the outgoing Collection's Scene children, so the CrossFade flag gets set and the existing morph branch in GenericFader::write runs. This was attempted in earlier (now-superseded) patches and was defeated by the shared-Scene orphan-fader race — but that race is closed by 725da3145, so the predecessor wiring should now be safe to extend.
  • F5 — Treat the slider as a manual elapsed-time scrubber on the step's authored fade time, instead of zeroing it. ChaserRunner::startNewStep keeps the step's fade time; tick advancement of FadeChannel::m_elapsed is replaced by slider position. Closer to the user-visible semantic ("slider mimics timed fade"), but a larger structural change.

I am not proposing either yet — wanted to land the Defect A patch first and document the next layer.

Files touched / referenced

Patch (725da3145):

  • engine/src/collection.cpp, engine/src/collection.h — childIntensity, startingChildren, preRun/postRun rework.
  • engine/test/collection/collection_test.{cpp,h} — new test coverage.
  • ui/src/virtualconsole/vccuelist.{cpp,h}syncCrossfadePrimarySideForStartup.
  • qmlui/virtualconsole/vccuelist.{cpp,h} — v5 parallel implementation.
  • ui/test/vccuelist/vccuelist_test.{cpp,h} — new test coverage.

Referenced in analysis but not modified by the patch:

  • engine/src/genericfader.cpp (the LTP fade gate).
  • engine/src/fadechannel.cpp (Intensity flag autodetection, calculateCurrent ramp).
  • engine/src/chaserrunner.cpp (blend predecessor wiring restricted to SceneType).
  • engine/src/scene.cpp (CrossFade flag application in processValue, handleFadersEnd lifecycle).
  • engine/src/mastertimer.cpp (same-tick stop+start path).

Environment

  • QLC+ 4.14.4 (engine commit 027e6d4fd).
  • Built on Linux, Qt 6, GCC 14. Compilation required demoting two -Werror flags in variables.cmake (-Wno-error=sfinae-incomplete, broader -Wno-error for unused-but-set-variable and deprecated libusb enum mixing). Not part of this patch's scope; flagged separately.
  • DMX observed via Simple Desk; no capture rig used.

Status / what I'm asking for

  • Defect A patch (725da3145) is ready for review on branch xfade-investigation-4.14.4 of my fork — happy to open it as a PR if maintainers want it in that form.
  • Defect B I'm filing for visibility — would welcome opinions from anyone with deeper knowledge of the GenericFader / FadeChannel design. If maintainers prefer one direction over the other, please say so here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions