mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-23 11:44:00 -04:00
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:
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user