Files
plezy/lib/utils/logo_tone.dart
edde746 56312a9eb2 fix(ui): keep clear logos with significant color untouched on heroes
Remapping a mark whose color is part of its identity - a red-outline
wordmark with white fill, a colored badge - fixes legibility but changes
the logo's character, which reads worse on hero surfaces than the
partial invisibility it cures.

The light tone class now splits on measured colored-pixel fraction:
light-dominant marks with incidental color (<=15% saturated pixels,
lightAccented) always remap, while lightMixed marks remap only when the
new ToneMappedLogoImage.remapMixed policy allows it. Heroes pass false
on the network, offline, and TV spotlight logo paths; the Live TV
guide's channel cells keep remapping mixed marks. The policy joins the
image-cache key. The threshold comes from a real clear-logo set:
remap-friendly marks measured at <=0.11 colored, identity-colored marks
at >=0.28.
2026-08-31 18:51:46 +02:00

134 lines
5.6 KiB
Dart

import 'dart:typed_data';
/// Classification of a channel-logo frame for theme-adaptive rendering.
///
/// Broadcast channel logos (Gracenote and most Jellyfin/Emby lineups) are
/// designed for dark UIs: white or near-white marks on transparency. On the
/// light theme's white cards they are invisible (issue #2197). The tone drives
/// whether [remapLightNeutral] may recolor a logo for a light surface.
enum LogoTone {
/// Too few opaque pixels to judge — leave untouched.
unknown,
/// Near-white, low-saturation mark (CBS/FOX-style wordmarks).
lightMonochrome,
/// Light-dominant mark with only incidental color (≤15% saturated pixels):
/// a white wordmark with a small colored accent. Safe to remap everywhere —
/// the accent keeps its pixels and the mark stays recognizable.
lightAccented,
/// Light content beside *significant* color (an NBC peacock, a red-outline
/// wordmark with white fill). Remapping is legibility-correct but changes
/// the mark's character, so hero surfaces leave these untouched while the
/// guide's tiny channel cells still remap them.
lightMixed,
/// Dark or self-backed artwork (abc's black disc, PBS KIDS' blue chip):
/// legible on light surfaces as-is. Also the gate that keeps
/// [remapLightNeutral] away from light text sitting inside an opaque dark
/// shape, which a per-pixel remap alone would erase.
dark,
}
/// Single-pass tone analysis over a **straight-alpha** RGBA frame
/// (`ImageByteFormat.rawStraightRgba`). Premultiplied input reads antialiased
/// and semi-transparent pixels darker than they are and must not be used.
///
/// [stride] samples every Nth pixel on both axes; 1 visits every pixel.
/// Cost is O(w·h / stride²) with no allocation.
LogoTone analyzeLogoTone(ByteData rgba, int width, int height, {int stride = 1}) {
final bytes = rgba.buffer.asUint8List(rgba.offsetInBytes, rgba.lengthInBytes);
var opaque = 0;
var light = 0;
var lightLowSat = 0;
var colored = 0;
for (var y = 0; y < height; y += stride) {
var i = y * width * 4;
final rowEnd = i + width * 4;
for (; i < rowEnd; i += 4 * stride) {
final a = bytes[i + 3];
if (a < 32) continue;
opaque++;
final r = bytes[i];
final g = bytes[i + 1];
final b = bytes[i + 2];
final maxC = r > g ? (r > b ? r : b) : (g > b ? g : b);
final minC = r < g ? (r < b ? r : b) : (g < b ? g : b);
// Relative saturation ≥0.35: the same boundary at which
// [remapLightNeutral]'s weight reaches zero.
if (maxC > 0 && 255 * (maxC - minC) ~/ maxC >= 89) colored++;
// Rec.601 integer luma.
final luma = (r * 77 + g * 150 + b * 29) >> 8;
if (luma < 176) continue;
light++;
if (maxC - minC <= 48) lightLowSat++;
}
}
final sampled = ((height + stride - 1) ~/ stride) * ((width + stride - 1) ~/ stride);
if (sampled == 0 || opaque * 50 < sampled) return LogoTone.unknown; // <2% coverage
final lightFrac = light / opaque;
if (lightFrac >= 0.85 && lightLowSat / opaque >= 0.80) return LogoTone.lightMonochrome;
if (lightFrac >= 0.30) {
// Measured on real clear-logo sets: remap-friendly marks (white wordmark,
// small accent) sit at ≤0.11 colored, identity-colored marks at ≥0.28 —
// 0.15 splits them with margin on both sides.
return colored * 100 <= opaque * 15 ? LogoTone.lightAccented : LogoTone.lightMixed;
}
return LogoTone.dark;
}
/// Bakes a light-surface-adapted copy of a light-toned logo frame.
///
/// Input is **straight-alpha** RGBA (`ImageByteFormat.rawStraightRgba`);
/// output is **premultiplied** RGBA, ready for
/// `ImageDescriptor.raw(..., PixelFormat.rgba8888)`. Reading the widely used
/// `rawRgba` instead silently premultiplies, which drags antialiased glyph
/// edges below the luma ramp — they escape the remap and render as a light
/// fringe around the recolored mark.
///
/// Lerps each pixel toward [targetArgb] weighted by how light *and* neutral it
/// is: full weight above ~0.85 luma at ~zero saturation, zero weight below
/// ~0.6 luma or above ~0.35 saturation, smooth ramp between. Colored content
/// (an NBC peacock) keeps its pixels; white wordmarks become the target;
/// antialiased boundary pixels land proportionally in between. Alpha is
/// preserved. Only call for [LogoTone.lightMonochrome] /
/// [LogoTone.lightMixed]; the classification is the gate that protects
/// self-backed logos (white text inside an opaque dark disc).
Uint8List remapLightNeutral(ByteData rgba, {required int targetArgb}) {
final src = rgba.buffer.asUint8List(rgba.offsetInBytes, rgba.lengthInBytes);
final out = Uint8List.fromList(src);
final tr = (targetArgb >> 16) & 0xFF, tg = (targetArgb >> 8) & 0xFF, tb = targetArgb & 0xFF;
for (var i = 0; i < out.length; i += 4) {
final a = out[i + 3];
if (a == 0) {
out[i] = 0;
out[i + 1] = 0;
out[i + 2] = 0;
continue;
}
var r = out[i], g = out[i + 1], b = out[i + 2];
final luma = (r * 77 + g * 150 + b * 29) >> 8;
final maxC = r > g ? (r > b ? r : b) : (g > b ? g : b);
final minC = r < g ? (r < b ? r : b) : (g < b ? g : b);
final sat = maxC == 0 ? 0 : 255 * (maxC - minC) ~/ maxC;
// Luma ramp 153..217 (0.60..0.85), saturation ramp 89..38 (0.35..0.15).
final lw = ((luma - 153) * 4).clamp(0, 255);
final sw = ((89 - sat) * 5).clamp(0, 255);
final w = lw * sw ~/ 255;
if (w != 0) {
r += (tr - r) * w ~/ 255;
g += (tg - g) * w ~/ 255;
b += (tb - b) * w ~/ 255;
}
// Premultiply for PixelFormat.rgba8888 consumption.
out[i] = r * a ~/ 255;
out[i + 1] = g * a ~/ 255;
out[i + 2] = b * a ~/ 255;
}
return out;
}