diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeDataJobsTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeDataJobsTest.kt new file mode 100644 index 00000000..a611a038 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeDataJobsTest.kt @@ -0,0 +1,150 @@ +package com.zarz.spotiflac + +import com.spotiflac.backend.NativeDataJobException +import com.spotiflac.backend.RequestLease +import java.io.File +import java.nio.file.Files +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.BeforeClass +import org.junit.Test + +/** Exercises the actual generated Kotlin ABI against the host Rust library. + * Set SPOTIFLAC_NATIVE_HOST_LIBRARY to the host dylib/so and optionally + * SPOTIFLAC_JNA_HOST_DIRECTORY to desktop JNA's native dispatch directory. + * Ordinary Android-only checkouts skip these host ABI tests. + */ +class NativeDataJobsTest { + companion object { + @BeforeClass @JvmStatic + fun configureHostLibrary() { + val library = System.getenv("SPOTIFLAC_NATIVE_HOST_LIBRARY") + assumeTrue("Build the Rust host library for ABI tests", !library.isNullOrBlank()) + require(File(library!!).isFile) { "Native host library does not exist" } + System.setProperty("uniffi.component.spotiflac_mobile.libraryOverride", library) + System.getenv("SPOTIFLAC_JNA_HOST_DIRECTORY")?.let { + System.setProperty("jna.boot.library.path", it) + } + } + } + + private fun withLease(id: String, block: (RequestLease) -> T): T { + val lease = NativeDataJobs.acquire(id) + return try { block(lease) } finally { NativeDataJobs.release(id, lease) } + } + + @Test fun pureJobsWorkBeforeExtensionInitializationAndCancelledLeasesRejectWork() { + val id = UUID.randomUUID().toString() + withLease(id) { lease -> + val request = JSONObject().put("operation", "palette") + val pixels = byteArrayOf(255.toByte(), 0, 0, 255.toByte()) + val result = JSONObject(RustCoreBackend.runDataJob(request, pixels, lease)) + val color = result.getJSONArray("colors").getJSONArray(0) + assertEquals(0xff0000ffL, color.getLong(0)) + assertEquals(1, color.getInt(1)) + NativeDataJobs.cancel(id) + assertTrue(lease.isCancelled()) + val error = assertThrows(NativeDataJobException.Operation::class.java) { + RustCoreBackend.runDataJob(request, pixels, lease) + } + assertTrue(error.message!!.contains("cancelled")) + } + } + + @Test fun cancellationCannotLeakIntoAnotherRequestOrAnIdleReplacement() { + val cancelledId = UUID.randomUUID().toString() + val otherId = UUID.randomUUID().toString() + withLease(cancelledId) { cancelled -> + withLease(otherId) { other -> + NativeDataJobs.cancel(cancelledId) + assertTrue(cancelled.isCancelled()) + assertFalse(other.isCancelled()) + } + } + withLease(cancelledId) { replacement -> + assertFalse(replacement.isCancelled()) + } + } + + @Test fun unknownAndFinishedCancellationCannotPoisonLaterWork() { + val id = UUID.randomUUID().toString() + NativeDataJobs.cancel(id) + withLease(id) { lease -> assertFalse(lease.isCancelled()) } + NativeDataJobs.cancel(id) + withLease(id) { lease -> assertFalse(lease.isCancelled()) } + } + + @Test fun sharedIdRetainsCancellationUntilEveryLeaseHasFinished() { + val id = UUID.randomUUID().toString() + val first = NativeDataJobs.acquire(id) + val second = NativeDataJobs.acquire(id) + try { + NativeDataJobs.release(id, first) + NativeDataJobs.release(id, first) + NativeDataJobs.cancel(id) + assertTrue(second.isCancelled()) + } finally { + NativeDataJobs.release(id, first) + NativeDataJobs.release(id, second) + } + NativeDataJobs.cancel(id) + withLease(id) { lease -> assertFalse(lease.isCancelled()) } + } + + @Test fun acquiredJobCanBeCancelledWhileStillQueuedForIo() { + val id = UUID.randomUUID().toString() + val executor = Executors.newSingleThreadExecutor() + val occupied = CountDownLatch(1) + val proceed = CountDownLatch(1) + val lease = NativeDataJobs.acquire(id) + try { + executor.submit { + occupied.countDown() + check(proceed.await(5, TimeUnit.SECONDS)) + } + assertTrue(occupied.await(5, TimeUnit.SECONDS)) + val queued = executor.submit { + RustCoreBackend.runDataJob( + JSONObject().put("operation", "palette"), + byteArrayOf(255.toByte(), 0, 0, 255.toByte()), + lease, + ) + } + NativeDataJobs.cancel(id) + proceed.countDown() + val error = assertThrows(ExecutionException::class.java) { + queued.get(5, TimeUnit.SECONDS) + } + assertTrue(error.cause is NativeDataJobException.Operation) + assertTrue(error.cause!!.message!!.contains("cancelled")) + } finally { + proceed.countDown() + executor.shutdownNow() + NativeDataJobs.release(id, lease) + } + } + + @Test fun streamedHashJobUsesTheGeneratedByteArrayAndLeaseABI() { + val path = Files.createTempFile("native-job-hash-", ".bin") + try { + Files.write(path, "abc".toByteArray()) + withLease(UUID.randomUUID().toString()) { lease -> + val request = JSONObject().put("operation", "hash_file").put("path", path.toString()) + val result = JSONObject(RustCoreBackend.runDataJob(request, byteArrayOf(), lease)) + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + result.getString("sha256")) + } + } finally { + Files.deleteIfExists(path) + } + } +} diff --git a/assets/licenses/native_palette.txt b/assets/licenses/native_palette.txt new file mode 100644 index 00000000..45ae9beb --- /dev/null +++ b/assets/licenses/native_palette.txt @@ -0,0 +1,244 @@ +Native palette quantization + +Derived from Material Color Utilities 0.13.0 (QuantizerCelebi, QuantizerWu, +QuantizerWsmeans and color conversion), Copyright 2021 Google LLC. +Source: https://github.com/material-foundation/material-color-utilities + +The deterministic random generator reproduces Dart SDK 3.13.4 Random. +Copyright (c) 2012, the Dart project authors. +Source: https://github.com/dart-lang/sdk/tree/3.13.4 + +Material Color Utilities license + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Dart SDK license + +Copyright 2012, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/lib/main.dart b/lib/main.dart index 0cdec106..107ce22b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,6 +51,10 @@ void main() { yield LicenseEntryWithLineBreaks(const [ 'libsmb2', ], await rootBundle.loadString('assets/licenses/libsmb2.txt')); + yield LicenseEntryWithLineBreaks(const [ + 'Material Color Utilities (native palette)', + 'Dart SDK (native Random compatibility)', + ], await rootBundle.loadString('assets/licenses/native_palette.txt')); yield LicenseEntryWithLineBreaks(const [ 'rusqlite', 'roxmltree', diff --git a/lib/theme/cover_palette.dart b/lib/theme/cover_palette.dart index 7470427c..072cef04 100644 --- a/lib/theme/cover_palette.dart +++ b/lib/theme/cover_palette.dart @@ -3,9 +3,37 @@ import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_color_utilities/quantize/quantizer_celebi.dart'; +import 'package:material_color_utilities/score/score.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +Future>> _quantizePaletteInIsolate(Uint8List bytes) async { + final result = await QuantizerCelebi().quantize( + bytes.buffer.asUint32List(bytes.offsetInBytes, bytes.lengthInBytes ~/ 4), + 128, + ); + return [ + for (final entry in result.colorToCount.entries) [entry.key, entry.value], + ]; +} + +/// Score remains pinned to Flutter's Material implementation. Quantization +/// runs on the native worker; the byte-order conversion matches ColorScheme. +int paletteSeedFromColorCounts(List> colors) { + final populations = {}; + for (final pair in colors) { + final abgr = pair[0]; + final argb = + (abgr & 0xff00ff00) | ((abgr & 255) << 16) | ((abgr >> 16) & 255); + populations[argb] = pair[1]; + } + return Score.score(populations, desired: 1).first; +} + /// Colour scheme derived from cover art, used to theme detail-screen headers. /// /// The generated scheme follows app brightness and supplies contrasting @@ -129,10 +157,7 @@ class CoverPalette { height: 112, policy: ResizeImagePolicy.fit, ); - final scheme = await ColorScheme.fromImageProvider( - provider: sample, - brightness: brightness, - ); + final scheme = await _schemeFromSample(sample, brightness); final sourceColor = await _sampleSourceColor(sample); if (sourceColor != null) _sourceColors[key] = sourceColor; _cache[key] = scheme; @@ -150,6 +175,83 @@ class CoverPalette { } } + static Future _schemeFromSample( + ImageProvider provider, + Brightness brightness, + ) async { + final result = Completer(); + final stream = provider.resolve( + const ImageConfiguration(size: Size(112, 112)), + ); + late ImageStreamListener listener; + late Timer loadFailureTimeout; + listener = ImageStreamListener( + (info, _) async { + loadFailureTimeout.cancel(); + stream.removeListener(listener); + ui.Picture? picture; + ui.Image? image; + try { + final source = info.image; + // Mirror Flutter's image-to-palette canvas/readback. Decoding, + // filtering and premultiplied alpha remain with Flutter's renderer. + final recorder = ui.PictureRecorder(); + paintImage( + canvas: Canvas(recorder), + rect: Rect.fromLTWH( + 0, + 0, + source.width.toDouble(), + source.height.toDouble(), + ), + image: source, + filterQuality: FilterQuality.none, + ); + picture = recorder.endRecording(); + image = await picture.toImage(source.width, source.height); + final data = await image.toByteData(); + if (data == null) throw StateError('Palette readback failed'); + result.complete( + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); + } catch (error, stack) { + result.completeError(error, stack); + } finally { + image?.dispose(); + picture?.dispose(); + info.dispose(); + } + }, + onError: (Object error, StackTrace? stack) { + loadFailureTimeout.cancel(); + stream.removeListener(listener); + result.completeError(error, stack); + }, + ); + loadFailureTimeout = Timer(const Duration(seconds: 5), () { + stream.removeListener(listener); + result.completeError(TimeoutException('Palette image loading timed out')); + }); + stream.addListener(listener); + final bytes = await result.future; + late final List> colors; + try { + final response = await PlatformBridge.runNativeDataJob({ + 'operation': 'palette', + }, bytes: bytes); + colors = [ + for (final pair in response['colors'] as List) + [for (final value in pair as List) (value as num).toInt()], + ]; + } on MissingPluginException { + // Desktop tests and unsupported hosts retain exact Material output, + // with the same expensive quantizer moved off the UI isolate. + colors = await compute(_quantizePaletteInIsolate, bytes); + } + final seed = await compute(paletteSeedFromColorCounts, colors); + return ColorScheme.fromSeed(seedColor: Color(seed), brightness: brightness); + } + static Future _sampleSourceColor(ImageProvider provider) { final result = Completer(); final stream = provider.resolve(ImageConfiguration.empty); diff --git a/pubspec.lock b/pubspec.lock index cd92bf89..251d4f30 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -883,7 +883,7 @@ packages: source: hosted version: "1.1.3" material_color_utilities: - dependency: transitive + dependency: "direct main" description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" diff --git a/pubspec.yaml b/pubspec.yaml index 639db362..d5cf64e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,6 +50,7 @@ dependencies: dynamic_color: ^2.1.0 material_ui: ^1.4.0 material_3_expressive: ^1.1.3 + material_color_utilities: 0.13.0 # Permissions permission_handler: ^13.0.1 @@ -130,6 +131,7 @@ flutter: - assets/licenses/oboe.txt - assets/licenses/wavpack.txt - assets/licenses/libsmb2.txt + - assets/licenses/native_palette.txt - assets/licenses/native_data_jobs.txt fonts: diff --git a/rust_backend/NOTICE b/rust_backend/NOTICE index e9f3f4b8..87955525 100644 --- a/rust_backend/NOTICE +++ b/rust_backend/NOTICE @@ -1,6 +1,9 @@ # Third-party notices -The native SQLite/TTML data jobs use +`crates/core/src/native_palette.rs` ports Material Color Utilities 0.13.0 +quantization (Google LLC, Apache-2.0) and Dart SDK Random compatibility +(The Dart Authors, BSD-3-Clause). Full notices and licenses are packaged in +`assets/licenses/native_palette.txt`. The native SQLite/TTML data jobs use rusqlite 0.40.2 and roxmltree 0.21.1 under their MIT licenses, packaged in `assets/licenses/native_data_jobs.txt`. diff --git a/rust_backend/crates/core/src/lib.rs b/rust_backend/crates/core/src/lib.rs index 4cf4d16b..0d973893 100644 --- a/rust_backend/crates/core/src/lib.rs +++ b/rust_backend/crates/core/src/lib.rs @@ -12,6 +12,7 @@ pub mod lyrics; pub mod matching; pub mod media; pub mod metadata; +pub mod native_palette; pub mod progress; pub mod resolver; pub mod tags; diff --git a/rust_backend/crates/core/src/native_palette.rs b/rust_backend/crates/core/src/native_palette.rs new file mode 100644 index 00000000..0a4923eb --- /dev/null +++ b/rust_backend/crates/core/src/native_palette.rs @@ -0,0 +1,521 @@ +// Copyright 2021 Google LLC +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Exact Celebi/Wu/Wsmeans port from material_color_utilities 0.13.0. +//! Pixel order, alpha behavior, numeric order and the pinned Dart VM RNG are +//! preserved. Flutter retains Score and dynamic ColorScheme semantics. + +use std::collections::HashMap; + +const MAX_PIXELS: usize = 112 * 112; +const COLORS: usize = 128; +const SIDE: usize = 33; +const SIZE: usize = SIDE * SIDE * SIDE; + +/// Input is the same premultiplied RGBA readback supplied to Flutter Celebi. +/// Output is ordered ABGR/population pairs, exactly like colorToCount. +pub fn quantize_rgba( + bytes: &[u8], + check: &dyn Fn() -> Result<(), String>, +) -> Result, String> { + check()?; + if bytes.is_empty() || !bytes.len().is_multiple_of(4) || bytes.len() / 4 > MAX_PIXELS { + return Err("invalid palette pixel buffer".into()); + } + let mut lookup = HashMap::new(); + let mut colors = Vec::new(); + let mut counts = Vec::new(); + for pixel in bytes.as_chunks::<4>().0 { + let color = u32::from_le_bytes([pixel[0], pixel[1], pixel[2], pixel[3]]); + let index = *lookup.entry(color).or_insert_with(|| { + colors.push(color); + counts.push(0_usize); + colors.len() - 1 + }); + counts[index] += 1; + } + let starts = Wu::new(&colors, &counts).quantize(check)?; + wsmeans(&colors, &counts, starts, check) +} + +#[derive(Clone, Copy, Default)] +struct Cube { + low: [usize; 3], + high: [usize; 3], + volume: usize, +} + +fn index(r: usize, g: usize, b: usize) -> usize { + r * SIDE * SIDE + g * SIDE + b +} + +struct Wu { + weights: Vec, + moments: [Vec; 3], + squares: Vec, +} + +impl Wu { + fn new(colors: &[u32], counts: &[usize]) -> Self { + let mut result = Self { + weights: vec![0; SIZE], + moments: std::array::from_fn(|_| vec![0; SIZE]), + squares: vec![0.0; SIZE], + }; + for (&color, &count) in colors.iter().zip(counts) { + if color >> 24 < 255 { + continue; + } + let rgb = [ + ((color >> 16) & 255) as i64, + ((color >> 8) & 255) as i64, + (color & 255) as i64, + ]; + let location = index( + (rgb[0] as usize >> 3) + 1, + (rgb[1] as usize >> 3) + 1, + (rgb[2] as usize >> 3) + 1, + ); + result.weights[location] += count as i64; + for (channel, value) in rgb.iter().enumerate() { + result.moments[channel][location] += value * count as i64; + } + result.squares[location] += + (count as i64 * (rgb[0] * rgb[0] + rgb[1] * rgb[1] + rgb[2] * rgb[2])) as f64; + } + result + } + + fn prefix(&mut self, check: &dyn Fn() -> Result<(), String>) -> Result<(), String> { + for r in 1..SIDE { + check()?; + let mut areas = [[0_i64; SIDE]; 4]; + let mut area_square = [0.0; SIDE]; + for g in 1..SIDE { + let mut lines = [0_i64; 4]; + let mut line_square = 0.0; + for b in 1..SIDE { + let location = index(r, g, b); + let previous = index(r - 1, g, b); + lines[0] += self.weights[location]; + for channel in 0..3 { + lines[channel + 1] += self.moments[channel][location]; + } + line_square += self.squares[location]; + for channel in 0..4 { + areas[channel][b] += lines[channel]; + } + area_square[b] += line_square; + self.weights[location] = self.weights[previous] + areas[0][b]; + for channel in 0..3 { + self.moments[channel][location] = + self.moments[channel][previous] + areas[channel + 1][b]; + } + self.squares[location] = self.squares[previous] + area_square[b]; + } + } + } + Ok(()) + } + + fn quantize(mut self, check: &dyn Fn() -> Result<(), String>) -> Result, String> { + self.prefix(check)?; + let mut cubes = vec![Cube::default(); COLORS]; + cubes[0].high = [32; 3]; + let mut variances = [0.0; COLORS]; + let (mut next, mut count, mut i) = (0, COLORS, 1); + while i < COLORS { + check()?; + if let Some((one, two)) = self.cut(cubes[next]) { + cubes[next] = one; + cubes[i] = two; + variances[next] = if one.volume > 1 { + self.variance(one) + } else { + 0.0 + }; + variances[i] = if two.volume > 1 { + self.variance(two) + } else { + 0.0 + }; + } else { + variances[next] = 0.0; + i -= 1; + } + next = 0; + let mut maximum = variances[0]; + for (candidate, value) in variances.iter().enumerate().take(i + 1).skip(1) { + if *value > maximum { + maximum = *value; + next = candidate; + } + } + if maximum <= 0.0 { + count = i + 1; + break; + } + i += 1; + } + let mut result = Vec::new(); + for cube in cubes.into_iter().take(count) { + let weight = volume(cube, &self.weights); + if weight > 0 { + let rgb = self + .moments + .each_ref() + .map(|values| (volume(cube, values) as f64 / weight as f64).round() as u32); + let color = 0xff000000 | rgb[0] << 16 | rgb[1] << 8 | rgb[2]; + // Dart's Map.fromEntries removes duplicate keys, in first order. + if !result.contains(&color) { + result.push(color); + } + } + } + Ok(result) + } + + fn variance(&self, cube: Cube) -> f64 { + let rgb = self.moments.each_ref().map(|values| volume(cube, values)); + volume_float(cube, &self.squares) + - (rgb[0] * rgb[0] + rgb[1] * rgb[1] + rgb[2] * rgb[2]) as f64 + / volume(cube, &self.weights) as f64 + } + + fn cut(&self, cube: Cube) -> Option<(Cube, Cube)> { + let mut best = [(0.0, None); 3]; + let whole = [ + volume(cube, &self.moments[0]), + volume(cube, &self.moments[1]), + volume(cube, &self.moments[2]), + volume(cube, &self.weights), + ]; + for (direction, best_cut) in best.iter_mut().enumerate() { + for position in cube.low[direction] + 1..cube.high[direction] { + let mut half = cube; + half.high[direction] = position; + let first = [ + volume(half, &self.moments[0]), + volume(half, &self.moments[1]), + volume(half, &self.moments[2]), + volume(half, &self.weights), + ]; + if first[3] == 0 || whole[3] - first[3] == 0 { + continue; + } + let second = std::array::from_fn::<_, 4, _>(|i| whole[i] - first[i]); + let score = (first[0] * first[0] + first[1] * first[1] + first[2] * first[2]) + as f64 + / first[3] as f64 + + (second[0] * second[0] + second[1] * second[1] + second[2] * second[2]) + as f64 + / second[3] as f64; + if score > best_cut.0 { + *best_cut = (score, Some(position)); + } + } + } + let direction = if best[0].0 >= best[1].0 && best[0].0 >= best[2].0 { + 0 + } else if best[1].0 >= best[0].0 && best[1].0 >= best[2].0 { + 1 + } else { + 2 + }; + let position = best[direction].1?; + let (mut one, mut two) = (cube, cube); + one.high[direction] = position; + two.low[direction] = position; + one.volume = + (one.high[0] - one.low[0]) * (one.high[1] - one.low[1]) * (one.high[2] - one.low[2]); + two.volume = + (two.high[0] - two.low[0]) * (two.high[1] - two.low[1]) * (two.high[2] - two.low[2]); + Some((one, two)) + } +} + +fn volume(c: Cube, v: &[i64]) -> i64 { + v[index(c.high[0], c.high[1], c.high[2])] + - v[index(c.high[0], c.high[1], c.low[2])] + - v[index(c.high[0], c.low[1], c.high[2])] + + v[index(c.high[0], c.low[1], c.low[2])] + - v[index(c.low[0], c.high[1], c.high[2])] + + v[index(c.low[0], c.high[1], c.low[2])] + + v[index(c.low[0], c.low[1], c.high[2])] + - v[index(c.low[0], c.low[1], c.low[2])] +} + +fn volume_float(c: Cube, v: &[f64]) -> f64 { + v[index(c.high[0], c.high[1], c.high[2])] + - v[index(c.high[0], c.high[1], c.low[2])] + - v[index(c.high[0], c.low[1], c.high[2])] + + v[index(c.high[0], c.low[1], c.low[2])] + - v[index(c.low[0], c.high[1], c.high[2])] + + v[index(c.low[0], c.high[1], c.low[2])] + + v[index(c.low[0], c.low[1], c.high[2])] + - v[index(c.low[0], c.low[1], c.low[2])] +} + +fn lab(color: u32) -> [f64; 3] { + let linear = |value: u32| { + let n = value as f64 / 255.0; + if n <= 0.040449936 { + n / 12.92 * 100.0 + } else { + ((n + 0.055) / 1.055).powf(2.4) * 100.0 + } + }; + let (r, g, b) = ( + linear(color >> 16 & 255), + linear(color >> 8 & 255), + linear(color & 255), + ); + let f = |t: f64| { + if t > 216.0 / 24389.0 { + t.powf(1.0 / 3.0) + } else { + ((24389.0 / 27.0) * t + 16.0) / 116.0 + } + }; + let x = f((0.41233895 * r + 0.35762064 * g + 0.18051042 * b) / 95.047); + let y = f((0.2126 * r + 0.7152 * g + 0.0722 * b) / 100.0); + let z = f((0.01932141 * r + 0.11916382 * g + 0.95034478 * b) / 108.883); + [116.0 * y - 16.0, 500.0 * (x - y), 200.0 * (y - z)] +} + +fn color(point: [f64; 3]) -> u32 { + let fy = (point[0] + 16.0) / 116.0; + let inverse = |ft: f64| { + let cube = ft * ft * ft; + if cube > 216.0 / 24389.0 { + cube + } else { + (116.0 * ft - 16.0) / (24389.0 / 27.0) + } + }; + let x = inverse(point[1] / 500.0 + fy) * 95.047; + let y = inverse(fy) * 100.0; + let z = inverse(fy - point[2] / 200.0) * 108.883; + let channel = |linear: f64| { + let n = linear / 100.0; + let v = if n <= 0.0031308 { + n * 12.92 + } else { + 1.055 * n.powf(1.0 / 2.4) - 0.055 + }; + (v * 255.0).round().clamp(0.0, 255.0) as u32 + }; + let r = channel(3.2413774792388685 * x + -1.5376652402851851 * y + -0.49885366846268053 * z); + let g = channel(-0.9691452513005321 * x + 1.8758853451067872 * y + 0.04156585616912061 * z); + let b = channel(0.05562093689691305 * x + -0.20395524564742123 * y + 1.0571799111220335 * z); + 0xff000000 | r << 16 | g << 8 | b +} + +fn distance(one: [f64; 3], two: [f64; 3]) -> f64 { + let l = one[0] - two[0]; + let a = one[1] - two[1]; + let b = one[2] - two[2]; + l * l + a * a + b * b +} + +fn wsmeans( + colors: &[u32], + counts: &[usize], + starting: Vec, + check: &dyn Fn() -> Result<(), String>, +) -> Result, String> { + let points: Vec<_> = colors.iter().map(|value| lab(*value)).collect(); + let cluster_count = COLORS.min(points.len()); + let mut clusters: Vec<_> = starting.into_iter().map(lab).collect(); + if clusters.len() < cluster_count { + let mut random = DartRandom::new(0x42688); + let mut chosen = Vec::new(); + while chosen.len() < cluster_count - clusters.len() { + let candidate = random.next_int(points.len()); + if !chosen.contains(&candidate) { + chosen.push(candidate); + } + } + clusters.extend(chosen.into_iter().map(|i| points[i])); + } + let mut indices: Vec<_> = (0..points.len()).map(|i| i % cluster_count).collect(); + let mut distances = vec![vec![0.0_f64; cluster_count]; cluster_count]; + let mut sums = vec![0_usize; cluster_count]; + for iteration in 0..5 { + check()?; + for i in 0..cluster_count { + for j in i + 1..cluster_count { + let value = distance(clusters[i], clusters[j]); + distances[j][i] = value; + distances[i][j] = value; + } + // Match pinned Dart's in-place row sorting, including subsequent + // updates to this already-sorted matrix. Its index matrix is unused. + distances[i].sort_by(f64::total_cmp); + } + let mut moved = 0; + for (i, point) in points.iter().enumerate() { + if i.is_multiple_of(256) { + check()?; + } + let previous = indices[i]; + let previous_distance = distance(*point, clusters[previous]); + let mut minimum = previous_distance; + let mut new = None; + for j in 0..cluster_count { + if distances[previous][j] >= 4.0 * previous_distance { + continue; + } + let value = distance(*point, clusters[j]); + if value < minimum { + minimum = value; + new = Some(j); + } + } + if let Some(new) = new { + moved += 1; + indices[i] = new; + } + } + if moved == 0 && iteration > 0 { + break; + } + let mut component_sums = vec![[0.0; 3]; cluster_count]; + sums.fill(0); + for (i, point) in points.iter().enumerate() { + let cluster = indices[i]; + sums[cluster] += counts[i]; + for channel in 0..3 { + component_sums[cluster][channel] += point[channel] * counts[i] as f64; + } + } + for i in 0..cluster_count { + clusters[i] = if sums[i] == 0 { + [0.0; 3] + } else { + component_sums[i].map(|value| value / sums[i] as f64) + }; + } + } + let mut result = Vec::new(); + for i in 0..cluster_count { + if sums[i] == 0 { + continue; + } + let rgb = color(clusters[i]); + if !result.iter().any(|(key, _)| *key == rgb) { + result.push((rgb, sums[i])); + } + } + check()?; + Ok(result) +} + +// Multiply-with-carry and Thomas Wang seed mixing mirror the pinned Dart VM +// Random implementation (Dart SDK, Copyright the Dart project authors, BSD). +struct DartRandom(u64); +impl DartRandom { + fn new(mut n: u64) -> Self { + n = (!n).wrapping_add(n << 21); + n ^= n >> 24; + n = n.wrapping_mul(265); + n ^= n >> 14; + n = n.wrapping_mul(21); + n ^= n >> 28; + n = n.wrapping_add(n << 31); + if n == 0 { + n = 0x5a17; + } + let mut value = Self(n); + for _ in 0..4 { + value.next(); + } + value + } + fn next(&mut self) { + self.0 = 0xffffda61 * (self.0 & 0xffffffff) + (self.0 >> 32); + } + fn next_int(&mut self, maximum: usize) -> usize { + loop { + self.next(); + let random = self.0 & 0xffffffff; + if maximum.is_power_of_two() { + return random as usize & (maximum - 1); + } + let result = random % maximum as u64; + if random - result + maximum as u64 <= 1_u64 << 32 { + return result as usize; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn solid_pixels_and_invalid_or_cancelled_requests() { + assert_eq!( + quantize_rgba(&[51, 102, 153, 255].repeat(100), &|| Ok(())).unwrap(), + vec![(0xff996633, 100)] + ); + assert!(quantize_rgba(&[0; 3], &|| Ok(())).is_err()); + assert_eq!( + quantize_rgba(&[0; 4], &|| Err("cancelled".into())).unwrap_err(), + "cancelled" + ); + } + + #[test] + fn ordered_populations_match_pinned_material_dart_vectors() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("native_palette/fixtures.json")).unwrap(); + for f in fixture["fixtures"].as_array().unwrap() { + let kind = f["kind"].as_str().unwrap(); + let mut state = f["seed"].as_u64().unwrap() as u32; + let count = f["count"].as_u64().unwrap() as usize; + let mut bytes = Vec::new(); + for i in 0..count { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + let mut rgb = state & 0xffffff; + let mut alpha = 255; + if kind == "transparent" { + alpha = 0; + } + if kind == "mixed_alpha" { + alpha = state >> 24 & 255; + } + if kind == "single_bin" { + rgb = 0x606060 | (rgb & 0x070707); + } + if kind == "gradient" { + rgb = (((i % 112) as u32 * 255 / 111) << 16) + | (((i / 112) as u32 * 255 / 111) << 8) + | 90; + } + if kind == "ties" { + rgb = [0xff0000, 0x00ff00, 0x0000ff, 0x444444][i % 4]; + } + bytes.extend_from_slice(&(alpha << 24 | rgb).to_le_bytes()); + } + let actual = quantize_rgba(&bytes, &|| Ok(())).unwrap(); + assert_eq!( + serde_json::to_value(actual).unwrap(), + f["colors"], + "{kind} seed {}", + f["seed"] + ); + } + let mut random = DartRandom::new(0x42688); + let actual: Vec<_> = (0..20).map(|_| random.next_int(1024)).collect(); + assert_eq!(serde_json::to_value(actual).unwrap(), fixture["rng1024"]); + } +} diff --git a/rust_backend/crates/core/src/native_palette/README.md b/rust_backend/crates/core/src/native_palette/README.md new file mode 100644 index 00000000..fe7ea342 --- /dev/null +++ b/rust_backend/crates/core/src/native_palette/README.md @@ -0,0 +1,10 @@ +`fixtures.json` was generated by the pinned Dart material_color_utilities +0.13.0 `QuantizerCelebi.quantize(pixels, 128)` implementation. It preserves +ordered color/population pairs and final Flutter Score seeds, not approximate +RGB tolerances. Each input is generated from its kind/count/LCG seed, avoiding +large raw artwork fixtures. Cases cover opaque noise, transparent pixels, +mixed alpha, colors sharing one Wu histogram bin (Dart seeded RNG fallback), +112×112 gradients and repeated equal-population colors. + +The Rust port retains the upstream Apache-2.0 attribution. The seeded MWC +helper matches the Dart SDK VM's Random implementation (BSD-3-Clause). diff --git a/rust_backend/crates/core/src/native_palette/fixtures.json b/rust_backend/crates/core/src/native_palette/fixtures.json new file mode 100644 index 00000000..07ce3320 --- /dev/null +++ b/rust_backend/crates/core/src/native_palette/fixtures.json @@ -0,0 +1 @@ +{"fixtures":[{"kind":"noise","seed":1,"count":1024,"colors":[[4280952889,14],[4280526892,14],[4283470502,19],[4281931952,16],[4288582700,13],[4287701273,9],[4288132492,17],[4288948401,20],[4288618321,12],[4288463136,9],[4293469885,10],[4281764816,14],[4282443816,16],[4293110195,8],[4281233166,7],[4281812628,14],[4288664929,14],[4287849570,16],[4289162928,20],[4281919890,15],[4284716068,10],[4292485660,10],[4284290627,12],[4293675670,9],[4288802462,10],[4281652825,8],[4283428303,9],[4281290720,8],[4292711457,18],[4292660899,14],[4281033049,6],[4280226027,6],[4291488799,11],[4293578586,13],[4290714188,7],[4282494134,11],[4287241437,8],[4292701712,11],[4280911721,14],[4280294011,12],[4291515971,7],[4288386529,8],[4284539031,6],[4291452902,10],[4286529522,5],[4283424984,7],[4288501487,5],[4285144469,5],[4293405146,10],[4283016024,12],[4284868415,5],[4283556631,7],[4293059036,6],[4287271758,12],[4289063113,11],[4284487024,5],[4285043425,4],[4282735995,6],[4284038561,10],[4293299171,8],[4284175137,3],[4292908389,10],[4286661919,9],[4283769054,9],[4293521199,8],[4288539736,8],[4287637621,11],[4289024101,10],[4280785470,7],[4288306383,3],[4285966240,10],[4280959508,3],[4293350869,6],[4290063086,11],[4292242565,7],[4285033698,13],[4287750696,11],[4280202670,4],[4287738834,9],[4284472041,3],[4290489787,6],[4280165284,5],[4281854037,9],[4293867795,12],[4279710565,7],[4281631980,5],[4293733216,6],[4288554393,7],[4289432892,2],[4289519853,4],[4282095851,8],[4285164953,4],[4292683681,7],[4286294881,5],[4291678755,8],[4285130683,6],[4279815578,6],[4293144818,8],[4280965690,5],[4292240945,5],[4285461628,11],[4280073572,4],[4285816427,2],[4283705081,6],[4284790320,7],[4290690898,3],[4291093938,3],[4285986316,6],[4288247819,2],[4283312484,6],[4292306337,4],[4280865004,8],[4284820017,3],[4293833188,6],[4289868611,1],[4280924957,5],[4281866812,2],[4294635773,1],[4289491834,2],[4291483478,2],[4294421700,4],[4288704817,4],[4285736895,4],[4294595433,3],[4288576210,6],[4280405157,2],[4284708401,4]],"seed_argb":4280870976},{"kind":"noise","seed":7,"count":1024,"colors":[[4279113764,7],[4287700508,11],[4289574188,15],[4281276815,11],[4286980753,4],[4286030490,8],[4281639722,11],[4280389261,13],[4288174371,12],[4290077592,14],[4290113063,10],[4292191670,13],[4281168792,9],[4280060012,10],[4280642087,12],[4281382353,20],[4292299030,8],[4292751426,13],[4292990498,9],[4280312155,13],[4288029924,8],[4281983556,10],[4292064235,12],[4281345483,9],[4287446224,6],[4289057439,16],[4288453207,10],[4281451139,12],[4282734751,7],[4282291434,5],[4284194842,14],[4284420653,8],[4286668924,7],[4287961699,13],[4292237238,12],[4283125007,6],[4291175586,13],[4283948907,14],[4293170716,10],[4288663764,10],[4292272209,6],[4284623756,7],[4283001049,12],[4281134798,9],[4291089138,7],[4283550123,7],[4288341106,8],[4284651945,7],[4292287082,11],[4291479982,6],[4293665258,5],[4293085029,6],[4283442906,8],[4282555978,4],[4286944725,9],[4291123418,10],[4281078610,9],[4283190385,10],[4284309225,7],[4285000488,8],[4283293404,7],[4289499770,7],[4280861215,15],[4288180018,12],[4284950388,5],[4283648311,11],[4280677022,6],[4293336735,6],[4282751322,8],[4289022298,10],[4291110176,5],[4287551556,10],[4279742957,8],[4293096641,7],[4288944451,6],[4292113713,3],[4282302385,8],[4293762790,6],[4284202257,6],[4289667803,12],[4280349302,11],[4286538662,8],[4294106709,7],[4281737710,14],[4282585134,7],[4293582311,5],[4293564060,9],[4289326049,7],[4292188529,6],[4281135128,5],[4281606836,7],[4288326206,5],[4281488920,6],[4286047310,4],[4290074392,7],[4293255839,7],[4283854691,5],[4279660717,5],[4293456935,5],[4292702310,6],[4289629655,6],[4284985319,7],[4284625841,7],[4289279131,5],[4288145050,8],[4289255259,4],[4284969939,9],[4293678313,2],[4284169455,10],[4290030496,5],[4293598490,8],[4293598300,3],[4287917791,9],[4289893189,5],[4292520485,4],[4281823469,3],[4285234210,5],[4289489490,3],[4292496183,5],[4279810897,2],[4290112336,6],[4286216613,5],[4291961046,3],[4289932148,8],[4280555636,5],[4281452979,4],[4281328610,5],[4286585391,3]],"seed_argb":4279824428},{"kind":"noise","seed":77,"count":1024,"colors":[[4279902748,9],[4288619544,10],[4287573438,15],[4280820139,12],[4287933222,11],[4288584091,18],[4280322314,7],[4284329648,10],[4291270947,8],[4291984784,12],[4288816498,13],[4288112809,6],[4281004831,11],[4280511523,9],[4284029329,7],[4281457547,13],[4288256606,8],[4290681713,13],[4291272926,12],[4285962443,12],[4293076048,9],[4282047833,9],[4288900393,7],[4291504304,12],[4283395742,5],[4282619778,9],[4281095509,8],[4280232780,9],[4292699569,8],[4282181316,16],[4281997550,11],[4280725056,8],[4286834782,6],[4292204323,10],[4293792025,9],[4289491032,12],[4291809436,11],[4288070808,10],[4283643098,13],[4283187273,13],[4287129545,11],[4291020773,7],[4291290225,13],[4279920798,10],[4284995136,4],[4287483687,5],[4293400823,3],[4291778384,8],[4283470048,11],[4283747610,8],[4286240747,6],[4286314193,5],[4285279080,8],[4281494502,9],[4282692330,11],[4282717972,11],[4284389949,8],[4281420062,10],[4293618211,9],[4286518510,5],[4283075615,7],[4293484783,9],[4285784456,9],[4294167711,10],[4292854839,8],[4293971922,10],[4289492206,7],[4293538453,8],[4287665267,8],[4288650186,6],[4292234865,8],[4281016166,13],[4280971613,8],[4284184657,4],[4282807582,6],[4282468513,6],[4288037218,5],[4292846419,8],[4279711645,8],[4290066157,8],[4288123704,13],[4282865122,6],[4289488244,6],[4284281977,9],[4290372593,9],[4280999487,10],[4291964451,10],[4282855406,7],[4286763157,3],[4280967355,7],[4282813091,7],[4288883000,8],[4284463064,10],[4289585759,6],[4293694515,7],[4284880418,9],[4281654759,6],[4284398123,6],[4285225681,6],[4293339773,5],[4288933444,6],[4289223697,6],[4282955451,7],[4293057889,3],[4284025796,3],[4290209432,7],[4283063738,4],[4291617331,11],[4288911079,6],[4280719978,7],[4284189795,3],[4292076175,7],[4280765803,4],[4292321959,3],[4294417528,4],[4289071300,4],[4282834849,6],[4285068085,6],[4291555029,8],[4280202272,9],[4293663923,6],[4281982233,5],[4285125865,4],[4292751582,6],[4286009885,1],[4293172452,2],[4293225849,3],[4290141390,4]],"seed_argb":4280283946},{"kind":"transparent","seed":1,"count":1024,"colors":[[4282375508,15],[4281958681,13],[4283205309,11],[4288813030,5],[4285021080,14],[4281790001,8],[4290874676,12],[4285869791,12],[4288684434,15],[4290327634,23],[4292600252,16],[4282901649,14],[4293431833,7],[4279571299,4],[4282618856,21],[4284917946,15],[4284036797,13],[4293161168,10],[4281941884,15],[4291835631,7],[4284869649,12],[4293241533,16],[4281189603,5],[4283324103,11],[4293029721,7],[4281510170,7],[4282315907,7],[4280690311,12],[4293343642,16],[4282420353,19],[4290865446,2],[4288267343,13],[4287948589,13],[4282340653,8],[4286330410,18],[4281576783,16],[4290201375,10],[4287652126,5],[4289099706,10],[4292915237,5],[4279110169,2],[4289030257,14],[4286720628,11],[4283197171,6],[4287682903,10],[4284619584,12],[4290625526,3],[4283700203,12],[4288825973,8],[4288187111,7],[4293256254,9],[4281145266,8],[4293704786,4],[4282895255,12],[4281282621,9],[4288524411,12],[4291421544,2],[4282878024,8],[4282241198,8],[4281200861,6],[4284996295,7],[4278958098,2],[4292311015,8],[4286650006,7],[4293534804,9],[4282895837,12],[4289064375,6],[4279522695,4],[4288318261,11],[4291859553,6],[4287788656,7],[4288355679,6],[4288536781,10],[4280705593,9],[4281383715,2],[4293733834,9],[4288686809,3],[4290756178,7],[4292844591,3],[4288444112,9],[4288880571,9],[4279635351,6],[4286642041,8],[4281032545,6],[4288527263,10],[4287874582,7],[4280361026,5],[4293094423,14],[4282315549,2],[4285371949,8],[4288912843,4],[4283133850,2],[4279938346,5],[4293256737,5],[4281997147,3],[4293080512,5],[4287542601,3],[4292747997,10],[4292080153,4],[4281346792,9],[4284260177,4],[4293054565,3],[4287138638,6],[4285964962,7],[4290570370,4],[4292012330,1],[4291219239,8],[4288178821,3],[4292792542,6],[4285682813,3],[4282090144,4],[4288865525,3],[4290595826,2],[4288342935,5],[4293249670,1],[4288880103,5],[4293498828,7],[4289421809,8],[4284201775,2],[4285250264,3],[4293405982,18],[4281591091,9],[4289588266,7],[4282052257,6],[4285390787,3],[4288842935,1],[4282871528,3],[4293672939,5]],"seed_argb":4280152894},{"kind":"transparent","seed":7,"count":1024,"colors":[[4289128527,11],[4289127647,10],[4292340535,10],[4293833599,4],[4285837859,10],[4280611202,11],[4287819059,9],[4281458227,7],[4291344863,15],[4281548333,8],[4291438257,16],[4294003025,6],[4284194668,9],[4285355945,15],[4288428654,14],[4281035733,6],[4283017249,10],[4289386907,23],[4290328649,2],[4291456942,15],[4292442136,8],[4284848296,15],[4281701958,10],[4281602408,9],[4288980631,10],[4287897627,14],[4289127277,9],[4291548129,7],[4284966017,13],[4289498926,8],[4282460076,22],[4292268634,7],[4283457821,8],[4292461866,10],[4280427050,4],[4286533662,11],[4282503529,7],[4294253680,4],[4286480833,14],[4280971153,9],[4279275622,3],[4291445305,17],[4294587172,3],[4284266688,7],[4286212838,8],[4284553752,2],[4281067492,11],[4283251178,14],[4282414611,7],[4283458508,4],[4294503259,3],[4280187502,12],[4292331685,8],[4283230896,16],[4280895103,3],[4290261729,14],[4281120409,5],[4284764729,6],[4283351591,8],[4289076082,15],[4282210027,6],[4292640901,9],[4280599332,10],[4280657972,6],[4286320946,6],[4289426746,16],[4281037800,4],[4285832985,2],[4292387303,17],[4288923225,3],[4287176418,11],[4282130878,14],[4287782858,9],[4281733951,10],[4283622239,6],[4278907752,4],[4281699015,10],[4282451996,5],[4282162065,7],[4283289019,16],[4281184199,7],[4293410515,3],[4293664024,5],[4293307315,10],[4291224035,4],[4280402058,6],[4281189863,9],[4287001396,4],[4280425194,12],[4286795895,5],[4289562488,10],[4293416045,4],[4293086378,10],[4288522029,5],[4290329697,10],[4294705662,1],[4285109494,6],[4293588128,3],[4287483197,5],[4288262939,4],[4281106823,4],[4289144771,3],[4279192129,4],[4284318020,2],[4281102948,7],[4292406811,6],[4290197925,4],[4287242117,8],[4286640766,4],[4282465380,3],[4285409866,2],[4291012458,1],[4292449575,11],[4291586778,5],[4280360812,5],[4292034845,13],[4282886365,13],[4282968870,13],[4286616376,2],[4281305874,2],[4292634833,3],[4281164583,10],[4287314370,5],[4293078258,4],[4288188890,3],[4292464410,3],[4291957346,14]],"seed_argb":4281589297},{"kind":"transparent","seed":77,"count":1024,"colors":[[4293403057,7],[4279987488,10],[4293078765,8],[4287772034,9],[4291766722,10],[4291855144,10],[4286421915,11],[4283034647,7],[4289687946,12],[4282444382,10],[4283209429,11],[4285060747,7],[4288294879,7],[4292685095,14],[4284586803,10],[4281000081,13],[4288016330,10],[4280596378,10],[4288144443,8],[4292045991,10],[4284736115,8],[4281446876,8],[4289556441,15],[4281706272,23],[4290046712,3],[4291588705,11],[4287338871,8],[4293604748,12],[4291865247,12],[4292857398,14],[4291487354,18],[4293122224,11],[4283286563,9],[4281803394,12],[4289971330,18],[4280493099,6],[4285064165,8],[4280483082,2],[4286812948,4],[4291850304,3],[4283962638,6],[4279529822,3],[4283594280,11],[4285310163,8],[4294621077,5],[4281983022,14],[4288836656,10],[4293277779,8],[4293044717,3],[4285973472,9],[4280437934,22],[4288580208,15],[4280752472,6],[4289715182,4],[4288786219,5],[4293165197,8],[4289108446,10],[4282013044,13],[4281071155,5],[4289061694,8],[4289077612,7],[4287644270,5],[4289729999,5],[4283116350,8],[4281547317,11],[4282856418,19],[4282285155,9],[4284638164,20],[4293577693,7],[4280127834,5],[4281694883,11],[4284882518,4],[4288458271,4],[4287018904,4],[4290767586,5],[4280833362,5],[4284919989,7],[4281049667,4],[4291682100,14],[4281716445,11],[4280647548,2],[4286902887,3],[4283994533,3],[4282390799,8],[4287907602,4],[4284944933,5],[4281421089,9],[4288249294,4],[4287506587,4],[4281407983,8],[4289538621,13],[4282838724,10],[4290289445,3],[4292192375,5],[4288188730,10],[4291447636,3],[4290169580,7],[4286755665,3],[4284654686,3],[4291160021,2],[4291595724,5],[4282097524,1],[4288169043,6],[4279513163,4],[4283336408,12],[4293044787,5],[4293668877,10],[4284211360,5],[4292294048,8],[4284739446,5],[4293980392,2],[4289671198,7],[4290684878,2],[4288730698,5],[4285326892,4],[4287995299,5],[4289042637,7],[4280762237,7],[4292698347,10],[4285404394,9],[4284032436,27],[4289424730,3],[4293758769,9],[4293110629,5],[4279882836,5],[4292560492,4],[4289218800,6]],"seed_argb":4278910242},{"kind":"mixed_alpha","seed":1,"count":1024,"colors":[[4281932109,10],[4292359779,15],[4285506730,16],[4290657317,12],[4280554898,10],[4293477134,19],[4282074228,11],[4293798719,9],[4282571606,16],[4282419490,16],[4281834205,7],[4288754913,7],[4285744549,15],[4281658424,13],[4290939957,12],[4285937128,10],[4288815762,16],[4289674325,10],[4291027128,17],[4281983637,22],[4293827110,7],[4278723443,1],[4282552805,18],[4285769410,11],[4285743563,9],[4293228247,10],[4281485717,5],[4292032752,7],[4284935186,14],[4293504707,18],[4282500843,5],[4283521227,14],[4294083949,4],[4280918819,6],[4282250111,7],[4280756362,11],[4293604767,13],[4281765763,16],[4292045099,2],[4289319755,9],[4287029811,12],[4282275371,10],[4286133543,17],[4281576782,13],[4289617448,4],[4287589409,8],[4289100469,8],[4293441075,5],[4279110169,2],[4288637297,15],[4286194286,9],[4281688813,6],[4288141650,10],[4285273406,11],[4290558958,5],[4284093420,12],[4288563824,12],[4288056298,12],[4294108253,5],[4281407161,8],[4292065105,5],[4282826120,7],[4281413433,8],[4289180809,10],[4290700396,5],[4283664968,9],[4282109355,7],[4283233243,10],[4284275402,7],[4278763031,4],[4292178660,8],[4286980250,6],[4282501850,12],[4289525692,6],[4280968611,6],[4288383278,6],[4292910700,5],[4288249721,7],[4288157785,6],[4288799437,8],[4280443708,8],[4282433057,5],[4293665729,5],[4288754931,3],[4290558789,7],[4292317996,4],[4288181195,6],[4288486842,10],[4280028833,4],[4286773376,9],[4281163619,7],[4288658847,9],[4288268820,7],[4279707465,4],[4292768029,7],[4281791257,3],[4285371690,8],[4289174993,2],[4284314266,4],[4279347244,3],[4293915168,6],[4283312500,3],[4292884669,11],[4288855893,8],[4293079266,15],[4291555611,9],[4281672675,9],[4284721509,4],[4293054564,3],[4287992918,9],[4283080066,2],[4288339842,3],[4290636080,3],[4291218468,7],[4289033141,1],[4293513158,3],[4287323022,1],[4283009448,4],[4288144627,3],[4290463471,3],[4286767501,3],[4292987030,2],[4288943590,5],[4293365182,8],[4289161715,3],[4283744033,3],[4287156971,2]],"seed_argb":4279890742},{"kind":"mixed_alpha","seed":7,"count":1024,"colors":[[4280777111,9],[4287983848,10],[4288916516,9],[4283704457,21],[4279473537,8],[4290167387,4],[4289917269,7],[4288866275,9],[4292734006,10],[4291206764,9],[4285507356,8],[4280677002,12],[4287295020,10],[4282570838,7],[4291672543,16],[4281810985,8],[4291178427,11],[4293539414,6],[4282416969,15],[4284112298,11],[4288491883,12],[4282614015,1],[4283608360,9],[4289321115,23],[4291637086,8],[4290932399,18],[4292508954,10],[4285898669,12],[4281374277,9],[4281143118,7],[4289637021,10],[4287569436,12],[4288996452,8],[4292599795,4],[4285097346,12],[4289236778,7],[4282133164,17],[4292464982,4],[4283786265,11],[4293117991,13],[4280950822,5],[4286532890,11],[4282635623,6],[4293335658,10],[4286349764,15],[4283529120,3],[4279275622,3],[4291576376,15],[4293475889,4],[4285841340,7],[4283916246,4],[4284553752,2],[4281067492,11],[4282725611,17],[4280906004,10],[4283197387,7],[4294503259,3],[4280121460,8],[4292855206,9],[4283426732,16],[4280895103,3],[4290458851,12],[4281841044,5],[4284764729,6],[4284204579,9],[4289207923,15],[4282472432,5],[4292772743,12],[4280861740,13],[4280657972,6],[4286650935,7],[4289427002,16],[4282219499,8],[4292191467,16],[4288006233,4],[4287110371,12],[4281540282,12],[4288769234,6],[4281602879,8],[4283950427,3],[4280087130,5],[4282484927,7],[4282451229,4],[4283998100,6],[4283091897,16],[4294178676,1],[4281840075,12],[4293408978,9],[4293794326,6],[4293176247,7],[4291223520,5],[4280074128,4],[4280534248,8],[4287790659,2],[4280752618,10],[4286993274,4],[4289102968,8],[4293487466,2],[4292692135,12],[4286026801,1],[4290329181,12],[4293725936,3],[4283339508,2],[4293523115,3],[4288075075,7],[4288132367,3],[4279666578,4],[4288492745,8],[4279717961,8],[4289047665,1],[4281953887,4],[4291684874,5],[4289870761,4],[4289011089,6],[4287033985,4],[4282794346,4],[4285870673,4],[4292186156,7],[4291453919,4],[4279967847,2],[4291836952,14],[4283215078,13],[4282771496,15],[4286422350,3],[4282814723,1],[4292960443,3]],"seed_argb":4284150300},{"kind":"mixed_alpha","seed":77,"count":1024,"colors":[[4283185490,11],[4286436907,16],[4290892920,11],[4290457473,12],[4293628223,17],[4284944933,5],[4293599409,5],[4279658279,8],[4291965682,11],[4286985085,7],[4292030409,12],[4291200810,8],[4284848539,10],[4282051350,6],[4289755027,16],[4282378587,12],[4283274449,18],[4286110345,7],[4285805262,10],[4292750119,13],[4283668271,9],[4280999820,11],[4287689164,13],[4280531872,9],[4291881519,3],[4292307617,11],[4283949423,10],[4282823648,7],[4289688541,15],[4281312033,20],[4290046712,3],[4290867296,13],[4286550125,7],[4293669772,12],[4291603880,8],[4292661296,12],[4291226231,9],[4293187754,8],[4282106404,9],[4281543560,11],[4290301327,12],[4279506711,4],[4285194724,9],[4280483082,2],[4287205662,6],[4289617706,4],[4283962638,6],[4279857243,1],[4283333675,13],[4284526812,7],[4294425753,9],[4281786155,12],[4288245034,6],[4292622928,13],[4292454640,2],[4285974502,5],[4280436390,19],[4288580210,19],[4280752472,6],[4289584110,3],[4288850474,4],[4293099658,7],[4289633504,11],[4281684853,10],[4281071155,5],[4290505287,3],[4288094307,2],[4287971959,4],[4289598413,6],[4281932330,3],[4281481523,11],[4283118563,21],[4282481248,8],[4284573657,24],[4293119458,9],[4280258652,4],[4281890205,12],[4284617033,3],[4288460309,3],[4287281820,5],[4291553760,4],[4280833362,5],[4285380027,6],[4280523066,4],[4291682105,14],[4282240992,12],[4279992953,4],[4287556963,5],[4284322211,3],[4282390799,8],[4287841554,3],[4281288987,9],[4288510925,3],[4286720158,6],[4281799912,7],[4289735486,13],[4282772162,7],[4290880305,4],[4291208052,10],[4288057663,11],[4291447636,3],[4290233827,4],[4287148373,6],[4282554706,2],[4290174191,1],[4292933095,2],[4291333581,4],[4282164092,2],[4288036690,6],[4279252304,3],[4283728594,9],[4293243444,6],[4293668877,10],[4283030431,10],[4292554407,6],[4283298166,4],[4294044901,2],[4290131488,6],[4291142354,3],[4288796230,6],[4285721388,4],[4288065203,6],[4288453327,5],[4281155451,6],[4292631785,10],[4284946404,10],[4284229301,22]],"seed_argb":4278910242},{"kind":"single_bin","seed":1,"count":1024,"colors":[[4284769380,7],[4284900198,18],[4284704102,18],[4284834150,13],[4284572256,17],[4284572263,19],[4284835427,15],[4284573282,18],[4284899936,8],[4284638305,8],[4284703590,10],[4284768867,8],[4284901216,16],[4284637796,8],[4284901222,10],[4284768614,14],[4284572775,9],[4284899940,11],[4284637283,5],[4284900705,10],[4284637281,13],[4284703843,11],[4284835684,7],[4284835174,16],[4284506465,11],[4284638052,5],[4284703846,10],[4284573286,10],[4284900708,10],[4284965478,4],[4284703588,6],[4284899680,10],[4284506725,5],[4284703334,6],[4284638818,10],[4284768356,16],[4284900195,11],[4284834662,8],[4284835169,10],[4284637284,9],[4284573281,14],[4284704356,15],[4284900193,19],[4284572517,3],[4284703075,4],[4284637541,6],[4284638562,19],[4284834406,1],[4284704099,11],[4284703329,21],[4284637540,10],[4284639074,7],[4284572772,12],[4284966240,2],[4284637799,8],[4284966245,2],[4284572260,5],[4284900450,4],[4284770146,9],[4284508004,13],[4284508003,1],[4284899425,15],[4284966246,4],[4284835168,1],[4284571750,8],[4284964965,4],[4284703847,1],[4284638820,7],[4284834917,4],[4284507237,8],[4284965222,9],[4284507232,4],[4284704100,9],[4284769376,16],[4284573285,17],[4284966241,2],[4284768611,6],[4284704354,2],[4284966501,11],[4284768357,13],[4284964962,7],[4284507238,13],[4284508000,3],[4284506980,12],[4284702823,7],[4284704612,2],[4284769888,22],[4284834403,9],[4284508001,4],[4284833889,2],[4284769127,9],[4284506470,2],[4284704097,6],[4284507234,6],[4284966757,5],[4284768359,6],[4284900454,7],[4284965474,9],[4284835430,16],[4284964964,8],[4284900964,1],[4284506212,3],[4284966243,3],[4284506722,3],[4284572514,4],[4284572003,5],[4284770150,4],[4284834914,4],[4284638306,8],[4284637794,8],[4284637537,11],[4284703586,6],[4284572005,5],[4284899943,7],[4284769383,5],[4284702818,1],[4284507746,2],[4284965732,5],[4284507233,2],[4284573543,4],[4284507493,1]],"seed_argb":4284639076},{"kind":"single_bin","seed":7,"count":1024,"colors":[[4284703588,15],[4284965734,6],[4284571749,9],[4284768613,15],[4284572007,9],[4284703841,23],[4284573282,19],[4284900705,15],[4284834150,18],[4284900960,16],[4284769123,2],[4284833893,9],[4284899683,8],[4284835686,12],[4284899936,5],[4284573286,13],[4284768867,15],[4284900964,11],[4284768353,10],[4284899429,10],[4284768609,11],[4284965731,5],[4284965475,5],[4284768865,4],[4284703076,14],[4284835426,14],[4284637537,6],[4284769637,4],[4284702823,11],[4284965478,14],[4284507493,5],[4284768871,12],[4284638308,16],[4284899430,9],[4284508002,11],[4284899426,13],[4284572260,10],[4284703843,17],[4284638306,18],[4284966501,10],[4284507748,7],[4284572262,13],[4284770146,6],[4284964964,3],[4284835681,12],[4284507488,4],[4284703072,9],[4284506983,6],[4284900451,12],[4284573536,15],[4284900965,12],[4284572773,10],[4284834663,13],[4284704608,8],[4284900196,7],[4284507233,11],[4284769638,17],[4284769379,4],[4284835427,7],[4284965986,6],[4284573028,11],[4284506723,10],[4284834657,15],[4284899937,8],[4284965223,2],[4284572000,3],[4284900963,4],[4284572002,11],[4284899424,3],[4284703591,2],[4284637543,6],[4284572774,8],[4284506981,5],[4284506727,5],[4284834913,6],[4284573542,12],[4284964961,5],[4284703586,6],[4284506468,1],[4284638054,7],[4284704099,6],[4284965472,8],[4284637285,5],[4284571747,2],[4284571745,7],[4284769125,8],[4284506465,10],[4284704612,6],[4284833890,1],[4284834148,5],[4284965477,1],[4284835680,6],[4284899680,3],[4284966246,13],[4284507495,6],[4284637793,2],[4284900448,16],[4284703077,4],[4284768610,2],[4284769376,5],[4284637286,2],[4284638562,12],[4284835175,5],[4284637283,2],[4284506215,7],[4284900197,15],[4284834914,6],[4284638567,11],[4284768355,9],[4284702816,2],[4284704357,5],[4284638817,9],[4284769381,2],[4284637797,1],[4284508004,4],[4284572513,3],[4284572258,5],[4284704613,15],[4284637794,7],[4284507747,2],[4284572775,5],[4284965220,2],[4284506470,3],[4284900710,2]],"seed_argb":4284508001},{"kind":"single_bin","seed":77,"count":1024,"colors":[[4284834660,21],[4284901220,16],[4284964965,10],[4284899680,8],[4284769635,24],[4284572006,12],[4284572004,15],[4284704613,15],[4284571751,7],[4284507239,7],[4284703074,13],[4284573284,6],[4284703846,14],[4284964966,3],[4284703841,9],[4284900960,12],[4284637794,23],[4284508001,9],[4284572263,11],[4284833889,6],[4284768359,5],[4284572770,15],[4284964963,13],[4284768612,7],[4284573285,17],[4284966754,5],[4284834407,13],[4284704609,15],[4284572516,21],[4284573025,8],[4284506721,11],[4284966244,5],[4284572519,5],[4284834913,8],[4284900196,23],[4284964960,2],[4284900452,8],[4284507744,2],[4284573540,1],[4284571749,11],[4284833890,6],[4284900450,19],[4284834402,11],[4284899428,10],[4284965217,5],[4284506469,4],[4284703328,6],[4284573538,8],[4284507747,3],[4284638307,13],[4284573539,6],[4284834919,8],[4284506722,8],[4284702816,4],[4284900454,15],[4284506208,6],[4284572260,3],[4284901217,4],[4284573286,15],[4284637284,1],[4284572003,4],[4284834916,3],[4284965984,10],[4284571746,8],[4284965730,7],[4284769632,5],[4284639076,2],[4284704358,10],[4284638048,5],[4284900192,13],[4284704355,8],[4284900198,18],[4284572257,8],[4284769376,3],[4284768358,11],[4284899682,4],[4284965478,11],[4284899431,4],[4284638308,5],[4284900709,7],[4284638305,6],[4284769638,7],[4284704099,1],[4284835686,4],[4284573030,4],[4284901222,13],[4284639072,9],[4284966242,3],[4284769120,7],[4284769121,7],[4284835174,4],[4284768869,3],[4284507233,3],[4284966500,9],[4284965223,7],[4284508002,1],[4284900962,9],[4284835170,1],[4284899940,11],[4284965218,1],[4284770144,3],[4284572002,6],[4284573027,3],[4284769895,7],[4284638055,10],[4284835425,4],[4284768870,14],[4284638822,8],[4284507488,7],[4284638310,18],[4284507489,6],[4284572259,6],[4284835175,3],[4284638560,12],[4284769890,14],[4284506727,7],[4284571745,1],[4284835683,5],[4284507749,7],[4284833888,8],[4284770145,6]],"seed_argb":4284573540},{"kind":"gradient","seed":1,"count":12544,"colors":[[4279700314,208],[4286847066,212],[4287271002,151],[4280910426,214],[4291400794,150],[4291303514,178],[4283734362,130],[4283110746,172],[4291285338,160],[4287155802,154],[4291187546,152],[4283159386,146],[4280533850,191],[4287123290,143],[4282994778,122],[4279780442,133],[4293381722,141],[4293430874,131],[4287295578,140],[4293481306,137],[4283102042,111],[4289365082,115],[4293465690,147],[4280477018,151],[4283019098,156],[4289414490,125],[4289139802,139],[4280456026,132],[4284960346,114],[4282921306,108],[4280570714,112],[4280882778,66],[4287147866,130],[4291359322,120],[4293455706,133],[4284976986,104],[4287131226,110],[4284968538,104],[4291276890,115],[4289381722,112],[4293505114,122],[4289455706,115],[4280593754,134],[4282937178,96],[4289179226,113],[4287050330,83],[4291260762,105],[4285124698,94],[4293422938,101],[4284936026,102],[4284952666,82],[4280578394,94],[4285116506,85],[4289422682,94],[4291113562,100],[4280537434,94],[4293472858,101],[4280546138,107],[4280627802,86],[4289668698,81],[4279612762,76],[4284879450,95],[4279854170,120],[4279772250,122],[4288121690,77],[4285959770,81],[4288113754,89],[4286017370,78],[4283731034,58],[4288171354,74],[4286009178,75],[4283920218,87],[4283846234,79],[4292382554,85],[4288138330,73],[4294413658,79],[4288278106,91],[4282225754,40],[4288229722,78],[4292308826,81],[4286107482,68],[4294405722,81],[4285984602,72],[4285935706,66],[4282085722,60],[4289829210,83],[4287933786,52],[4286034010,56],[4289952090,72],[4292366170,74],[4283944794,71],[4294462810,79],[4283953242,72],[4283903834,74],[4281946458,56],[4284068186,55],[4289878362,57],[4292333402,67],[4294364250,78],[4292407898,66],[4294372442,70],[4292358490,76],[4282175834,32],[4294389850,79],[4282315354,31],[4281937754,59],[4292391002,61],[4290107738,66],[4294421594,76],[4289853530,62],[4281970778,66],[4289804634,54],[4290228314,49],[4288090714,74],[4280458074,106],[4285927514,68],[4280318298,85],[4283830618,75],[4280244826,91],[4292416602,77],[4280932954,123],[4294448474,84],[4281006682,117],[4281798746,74],[4280105306,104],[4290253658,66],[4280267098,35],[4279703386,29]],"seed_argb":4284152362},{"kind":"gradient","seed":7,"count":12544,"colors":[[4279700314,208],[4286847066,212],[4287271002,151],[4280910426,214],[4291400794,150],[4291303514,178],[4283734362,130],[4283110746,172],[4291285338,160],[4287155802,154],[4291187546,152],[4283159386,146],[4280533850,191],[4287123290,143],[4282994778,122],[4279780442,133],[4293381722,141],[4293430874,131],[4287295578,140],[4293481306,137],[4283102042,111],[4289365082,115],[4293465690,147],[4280477018,151],[4283019098,156],[4289414490,125],[4289139802,139],[4280456026,132],[4284960346,114],[4282921306,108],[4280570714,112],[4280882778,66],[4287147866,130],[4291359322,120],[4293455706,133],[4284976986,104],[4287131226,110],[4284968538,104],[4291276890,115],[4289381722,112],[4293505114,122],[4289455706,115],[4280593754,134],[4282937178,96],[4289179226,113],[4287050330,83],[4291260762,105],[4285124698,94],[4293422938,101],[4284936026,102],[4284952666,82],[4280578394,94],[4285116506,85],[4289422682,94],[4291113562,100],[4280537434,94],[4293472858,101],[4280546138,107],[4280627802,86],[4289668698,81],[4279612762,76],[4284879450,95],[4279854170,120],[4279772250,122],[4288121690,77],[4285959770,81],[4288113754,89],[4286017370,78],[4283731034,58],[4288171354,74],[4286009178,75],[4283920218,87],[4283846234,79],[4292382554,85],[4288138330,73],[4294413658,79],[4288278106,91],[4282225754,40],[4288229722,78],[4292308826,81],[4286107482,68],[4294405722,81],[4285984602,72],[4285935706,66],[4282085722,60],[4289829210,83],[4287933786,52],[4286034010,56],[4289952090,72],[4292366170,74],[4283944794,71],[4294462810,79],[4283953242,72],[4283903834,74],[4281946458,56],[4284068186,55],[4289878362,57],[4292333402,67],[4294364250,78],[4292407898,66],[4294372442,70],[4292358490,76],[4282175834,32],[4294389850,79],[4282315354,31],[4281937754,59],[4292391002,61],[4290107738,66],[4294421594,76],[4289853530,62],[4281970778,66],[4289804634,54],[4290228314,49],[4288090714,74],[4280458074,106],[4285927514,68],[4280318298,85],[4283830618,75],[4280244826,91],[4292416602,77],[4280932954,123],[4294448474,84],[4281006682,117],[4281798746,74],[4280105306,104],[4290253658,66],[4280267098,35],[4279703386,29]],"seed_argb":4284152362},{"kind":"gradient","seed":77,"count":12544,"colors":[[4279700314,208],[4286847066,212],[4287271002,151],[4280910426,214],[4291400794,150],[4291303514,178],[4283734362,130],[4283110746,172],[4291285338,160],[4287155802,154],[4291187546,152],[4283159386,146],[4280533850,191],[4287123290,143],[4282994778,122],[4279780442,133],[4293381722,141],[4293430874,131],[4287295578,140],[4293481306,137],[4283102042,111],[4289365082,115],[4293465690,147],[4280477018,151],[4283019098,156],[4289414490,125],[4289139802,139],[4280456026,132],[4284960346,114],[4282921306,108],[4280570714,112],[4280882778,66],[4287147866,130],[4291359322,120],[4293455706,133],[4284976986,104],[4287131226,110],[4284968538,104],[4291276890,115],[4289381722,112],[4293505114,122],[4289455706,115],[4280593754,134],[4282937178,96],[4289179226,113],[4287050330,83],[4291260762,105],[4285124698,94],[4293422938,101],[4284936026,102],[4284952666,82],[4280578394,94],[4285116506,85],[4289422682,94],[4291113562,100],[4280537434,94],[4293472858,101],[4280546138,107],[4280627802,86],[4289668698,81],[4279612762,76],[4284879450,95],[4279854170,120],[4279772250,122],[4288121690,77],[4285959770,81],[4288113754,89],[4286017370,78],[4283731034,58],[4288171354,74],[4286009178,75],[4283920218,87],[4283846234,79],[4292382554,85],[4288138330,73],[4294413658,79],[4288278106,91],[4282225754,40],[4288229722,78],[4292308826,81],[4286107482,68],[4294405722,81],[4285984602,72],[4285935706,66],[4282085722,60],[4289829210,83],[4287933786,52],[4286034010,56],[4289952090,72],[4292366170,74],[4283944794,71],[4294462810,79],[4283953242,72],[4283903834,74],[4281946458,56],[4284068186,55],[4289878362,57],[4292333402,67],[4294364250,78],[4292407898,66],[4294372442,70],[4292358490,76],[4282175834,32],[4294389850,79],[4282315354,31],[4281937754,59],[4292391002,61],[4290107738,66],[4294421594,76],[4289853530,62],[4281970778,66],[4289804634,54],[4290228314,49],[4288090714,74],[4280458074,106],[4285927514,68],[4280318298,85],[4283830618,75],[4280244826,91],[4292416602,77],[4280932954,123],[4294448474,84],[4281006682,117],[4281798746,74],[4280105306,104],[4290253658,66],[4280267098,35],[4279703386,29]],"seed_argb":4284152362},{"kind":"ties","seed":1,"count":1024,"colors":[[4278190335,256],[4294901760,256],[4278255360,256],[4282664004,256]],"seed_argb":4294901760},{"kind":"ties","seed":7,"count":1024,"colors":[[4278190335,256],[4294901760,256],[4278255360,256],[4282664004,256]],"seed_argb":4294901760},{"kind":"ties","seed":77,"count":1024,"colors":[[4278190335,256],[4294901760,256],[4278255360,256],[4282664004,256]],"seed_argb":4294901760}],"rng1024":[397,12,322,318,860,229,814,572,700,353,498,307,307,330,409,24,119,3,521,967]} diff --git a/rust_backend/crates/mobile/src/data_jobs.rs b/rust_backend/crates/mobile/src/data_jobs.rs index 262bd974..ec3f8bba 100644 --- a/rust_backend/crates/mobile/src/data_jobs.rs +++ b/rust_backend/crates/mobile/src/data_jobs.rs @@ -80,6 +80,10 @@ fn execute( check, ), "parse_network_listing" => crate::native_listing::execute(request, Some(bytes)), + "palette" => { + let colors = spotiflac_core::native_palette::quantize_rgba(bytes, check)?; + Ok(json!({"colors": colors})) + } _ => Err(format!("Unknown native data operation: {operation}")), } } diff --git a/test/native_palette_worker_test.dart b/test/native_palette_worker_test.dart new file mode 100644 index 00000000..c24c949c --- /dev/null +++ b/test/native_palette_worker_test.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/cover_palette.dart'; + +void main() { + test('native ordered counts retain the pinned Material palette seeds', () { + final fixtures = + jsonDecode( + File( + 'rust_backend/crates/core/src/native_palette/fixtures.json', + ).readAsStringSync(), + ) + as Map; + for (final value in fixtures['fixtures'] as List) { + final fixture = value as Map; + final colors = [ + for (final pair in fixture['colors'] as List) + [for (final value in pair as List) (value as num).toInt()], + ]; + expect( + paletteSeedFromColorCounts(colors), + fixture['seed_argb'], + reason: '${fixture['kind']} seed ${fixture['seed']}', + ); + } + }); +}