fix(icc): make the per-output temperature an actual target

The per-output value was treated as the white point a profile was produced
at, which meant a display with a profile never changed when the user set a
temperature: the profile ramp was applied as measured and the value only
mattered when a night light target existed. Per-display temperatures were
therefore unusable on profiled outputs while the night light was disabled,
which is the normal state for users who want each monitor at its own value.

- `effectiveTempTarget` decides per output: a per-output override wins over
  the night light schedule, the schedule applies when there is no override,
  and `noTempTarget` means neither. `0` is "no override", not 0K.
- `applyGamma` composes the override on top of the profile ramp, treating the
  profile as measured at 6500K (D65), and drives outputs without a profile
  with the same target so both paths agree.
- `dms icc set-temp` help text and the Display Config slider description go
  back to describing an independent per-output temperature.

Covered by TestEffectiveTempTarget (override wins, schedule fallback, none)
and the updated TestProfileRampWithTemp (7000K cools, 5000K warms relative to
the 6500K reference).
This commit is contained in:
KIDult2226
2026-09-13 01:11:34 +08:00
parent 6763931c15
commit 033b96b38c
4 changed files with 88 additions and 50 deletions
+8 -8
View File
@@ -60,13 +60,13 @@ var iccSetTempCmd = &cobra.Command{
Use: "set-temp <output> <kelvin>",
Aliases: []string{"setTemp"},
Short: "Set the color temperature for a specific output",
Long: "Set the reference white point of an output, in kelvin.\n\n" +
"For outputs with an ICC profile this is the white point the profile was\n" +
"produced at: the profile is applied as measured, and the night light\n" +
"shifts from that reference instead of assuming 6500K. Outputs without a\n" +
"profile use it as their color temperature directly.\n\n" +
Long: "Set the color temperature for a specific output, independently of the\n" +
"night light schedule: every display keeps its own value, which also makes\n" +
"the range wider than the night light's.\n\n" +
"For an output with an ICC profile the temperature is composed on top of\n" +
"it, treating the profile as measured at 6500K.\n\n" +
"Accepts 1000-10000K; 0 removes the override, so the output follows the\n" +
"night light schedule with a 6500K reference.",
"night light schedule again (6500K while it is disabled).",
Args: cobra.ExactArgs(2),
Run: runICCSetTemp,
}
@@ -351,10 +351,10 @@ func runICCSetTemp(cmd *cobra.Command, args []string) {
}
if temp == 0 {
fmt.Printf("Output '%s' uses a 6500K reference white point again\n", outputName)
fmt.Printf("Output '%s' follows the night light schedule again\n", outputName)
return
}
fmt.Printf("Set output '%s' reference white point to %dK\n", outputName, temp)
fmt.Printf("Set output '%s' color temperature to %dK\n", outputName, temp)
}
// fetchICCOutputTemps asks the running server for the per-output overrides.
+34 -26
View File
@@ -159,9 +159,9 @@ func TestGenerateGammaRamp_ContrastPivotsAtMidGray(t *testing.T) {
}
}
// The per-output temperature is the white point the ICC profile was produced
// at, so it only shifts the ramp when the night light asks for a different
// temperature than that reference.
// Profile ramps are composed with a target temperature relative to the white
// point display profiles are produced at (D65): no target keeps the profile as
// measured, a target shifts it.
func TestProfileRampWithTemp(t *testing.T) {
const size = uint32(256)
mid := int(size) / 2
@@ -171,35 +171,27 @@ func TestProfileRampWithTemp(t *testing.T) {
identity := GenerateIdentityRamp(size)
t.Run("no target keeps the profile as measured", func(t *testing.T) {
for _, target := range []int{noTempTarget, 7000} {
ramp, err := ProfileRampWithTemp(size, profile, 7000, target, 1, 1)
if err != nil {
t.Fatalf("target %d: ProfileRampWithTemp: %v", target, err)
}
if !slices.Equal(identity.Red, ramp.Red) || !slices.Equal(identity.Blue, ramp.Blue) {
t.Fatalf("target %d: profile ramp should be unchanged at its own white point", target)
}
}
})
t.Run("warmer target than the reference warms the output", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, 7000, 4000, 1, 1)
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, noTempTarget, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
if ramp.Red[mid] <= identity.Red[mid] {
t.Errorf("red should be lifted: got %d, want > %d", ramp.Red[mid], identity.Red[mid])
}
if ramp.Green[mid] >= identity.Green[mid] {
t.Errorf("green should be pulled down: got %d, want < %d", ramp.Green[mid], identity.Green[mid])
}
if ramp.Blue[mid] >= identity.Blue[mid] {
t.Errorf("blue should be pulled down: got %d, want < %d", ramp.Blue[mid], identity.Blue[mid])
if !slices.Equal(identity.Red, ramp.Red) || !slices.Equal(identity.Blue, ramp.Blue) {
t.Fatal("profile ramp should be unchanged without a temperature target")
}
})
t.Run("cooler target than the reference cools the output", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, 6500, 10000, 1, 1)
t.Run("target equal to the reference leaves the profile alone", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, neutralTemp, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
if !slices.Equal(identity.Red, ramp.Red) {
t.Fatal("profile ramp should be unchanged at its reference white point")
}
})
t.Run("cooler target cools the output", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, 7000, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
@@ -213,4 +205,20 @@ func TestProfileRampWithTemp(t *testing.T) {
t.Errorf("blue is the reference channel and should be unchanged: got %d, want %d", ramp.Blue[mid], identity.Blue[mid])
}
})
t.Run("warmer target warms the output", func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, 5000, 1, 1)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
if ramp.Red[mid] != identity.Red[mid] {
t.Errorf("red is the reference channel and should be unchanged: got %d, want %d", ramp.Red[mid], identity.Red[mid])
}
if ramp.Green[mid] >= identity.Green[mid] {
t.Errorf("green should be pulled down: got %d, want < %d", ramp.Green[mid], identity.Green[mid])
}
if ramp.Blue[mid] >= identity.Blue[mid] {
t.Errorf("blue should be pulled down: got %d, want < %d", ramp.Blue[mid], identity.Blue[mid])
}
})
}
+23 -16
View File
@@ -845,6 +845,20 @@ func (m *Manager) applyCurrentTemp(_ string) {
m.updateStateFromSchedule()
}
// effectiveTempTarget returns the temperature to render an output at: a
// per-output override wins over the night light schedule, so each display can
// keep its own temperature (0 = no override). Without either, the output keeps
// whatever its profile describes.
func effectiveTempTarget(out *outputState, scheduleTemp int) int {
if out.outputTemp != 0 {
return out.outputTemp
}
if scheduleTemp > 0 {
return scheduleTemp
}
return noTempTarget
}
func (m *Manager) applyGamma(temp int) {
m.configMutex.RLock()
gamma, contrast := m.config.Gamma, m.config.Contrast
@@ -885,37 +899,30 @@ func (m *Manager) applyGamma(temp int) {
case !m.outputStillValid(out):
continue
}
// Per-output temperature doubles as the white point the profile was
// produced at; without one, the profile is assumed to be at neutral.
outBaseTemp := out.outputTemp
if outBaseTemp == 0 {
outBaseTemp = neutralTemp
}
targetTemp := effectiveTempTarget(out, temp)
var ramp GammaRamp
if out.iccPath != "" && out.iccProfile != nil {
profileRamp, err := ProfileRampWithTemp(out.rampSize, out.iccProfile, outBaseTemp, temp, gamma, contrast)
// The profile describes the display at the white point display
// profiles are produced at (D65); a target temperature is composed
// on top of it.
profileRamp, err := ProfileRampWithTemp(out.rampSize, out.iccProfile, neutralTemp, targetTemp, gamma, contrast)
if err != nil {
log.Warnf("icc: failed to generate ramp for output %d: %v, falling back to temperature", out.id, err)
fallbackTemp := temp
fallbackTemp := targetTemp
if fallbackTemp <= 0 {
fallbackTemp = outBaseTemp
fallbackTemp = neutralTemp
}
ramp = GenerateGammaRamp(out.rampSize, fallbackTemp, gamma, contrast)
} else {
ramp = profileRamp
log.Infof("icc: applied ICC ramp to output %d (size=%d, ref=%dK, target=%dK)", out.id, out.rampSize, outBaseTemp, temp)
log.Infof("icc: applied ICC ramp to output %d (size=%d, ref=%dK, target=%dK)", out.id, out.rampSize, neutralTemp, targetTemp)
}
} else {
// No ICC profile — use per-output temp if set, otherwise the night
// light temperature, otherwise neutral.
outTemp := temp
outTemp := targetTemp
if outTemp <= 0 {
outTemp = neutralTemp
}
if out.outputTemp != 0 {
outTemp = out.outputTemp
}
ramp = GenerateGammaRamp(out.rampSize, outTemp, gamma, contrast)
}
buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6))
@@ -605,3 +605,26 @@ func TestManager_AttachConfiguredICCForNamedOutput(t *testing.T) {
m.attachConfiguredICC(4, "HDMI-A-1")
assert.Zero(t, plain.outputTemp, "outputs without configuration are left alone")
}
// Each display can keep its own temperature: a per-output override wins over the
// night light schedule, and 0 means "no override" rather than 0K.
func TestEffectiveTempTarget(t *testing.T) {
cases := []struct {
name string
outputTemp int
scheduleTemp int
want int
}{
{"override wins over the schedule", 7000, 5000, 7000},
{"override applies without a schedule", 7000, noTempTarget, 7000},
{"schedule applies without an override", 0, 5000, 5000},
{"neither configured", 0, noTempTarget, noTempTarget},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := &outputState{outputTemp: tc.outputTemp}
assert.Equal(t, tc.want, effectiveTempTarget(out, tc.scheduleTemp))
})
}
}