diff --git a/.chrome-cdp-mobile/Crashpad/metadata b/.chrome-cdp-mobile/Crashpad/metadata new file mode 100644 index 0000000000000000000000000000000000000000..f200a76e0fc3847f09cbcfec0931f4852ec75bbe Binary files /dev/null and b/.chrome-cdp-mobile/Crashpad/metadata differ diff --git a/.chrome-cdp-mobile/Crashpad/reports/04484a24-7834-4a62-a8b7-f7313a7c362d.dmp b/.chrome-cdp-mobile/Crashpad/reports/04484a24-7834-4a62-a8b7-f7313a7c362d.dmp new file mode 100644 index 0000000000000000000000000000000000000000..70f9cd41721772e3c157de73885de90a954fdc85 --- /dev/null +++ b/.chrome-cdp-mobile/Crashpad/reports/04484a24-7834-4a62-a8b7-f7313a7c362d.dmp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f08cf7ae8f93c0d1a9a28414fda53dec6b494f15b6df47c5e3e7af26696bc30c +size 1803904 diff --git a/.chrome-cdp-mobile/Crashpad/settings.dat b/.chrome-cdp-mobile/Crashpad/settings.dat new file mode 100644 index 0000000000000000000000000000000000000000..44e49cede1b98599d23be5fa1ef085ecf01cbc2d Binary files /dev/null and b/.chrome-cdp-mobile/Crashpad/settings.dat differ diff --git a/.chrome-cdp-mobile/CrashpadMetrics-active.pma b/.chrome-cdp-mobile/CrashpadMetrics-active.pma new file mode 100644 index 0000000000000000000000000000000000000000..08d4a2e0951f6f75f6466be54b4b1d53028fcb61 --- /dev/null +++ b/.chrome-cdp-mobile/CrashpadMetrics-active.pma @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a54d7e0d4cb6e277cf520b162d630f7039d53bdd0a8e13052153893c7ba6538 +size 1048576 diff --git a/.chrome-cdp-mobile/Default/Account Web Data b/.chrome-cdp-mobile/Default/Account Web Data new file mode 100644 index 0000000000000000000000000000000000000000..512fa75b9e581bdf2c0835f7a37a4ad8844087bb Binary files /dev/null and b/.chrome-cdp-mobile/Default/Account Web Data differ diff --git a/.chrome-cdp-mobile/Default/Account Web Data-journal b/.chrome-cdp-mobile/Default/Account Web Data-journal new file mode 100644 index 0000000000000000000000000000000000000000..e05e3ccf541af11bc1a9b91f9d36ee44e65b162a Binary files /dev/null and b/.chrome-cdp-mobile/Default/Account Web Data-journal differ diff --git a/.chrome-cdp-mobile/Default/Affiliation Database b/.chrome-cdp-mobile/Default/Affiliation Database new file mode 100644 index 0000000000000000000000000000000000000000..c253c2e9d7a9d6161187f12c541d67904025a80f Binary files /dev/null and b/.chrome-cdp-mobile/Default/Affiliation Database differ diff --git a/.chrome-cdp-mobile/Default/Affiliation Database-journal b/.chrome-cdp-mobile/Default/Affiliation Database-journal new file mode 100644 index 0000000000000000000000000000000000000000..e2c6046ac53a8023265d668d43ba00afb45049ae Binary files /dev/null and b/.chrome-cdp-mobile/Default/Affiliation Database-journal differ diff --git a/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_0 b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_0 new file mode 100644 index 0000000000000000000000000000000000000000..25de7a4d60850837434faaea8ecf9720fb962427 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_0 differ diff --git a/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_1 b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_1 new file mode 100644 index 0000000000000000000000000000000000000000..71d1a64867f525555adefa0b75e50f04336369f6 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_1 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50a3cf39a15a5e954506439eafa7e483fbba3c14a681216ff90d33440be4a3e9 +size 270336 diff --git a/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_2 b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_2 new file mode 100644 index 0000000000000000000000000000000000000000..c7e2eb9adcfb2d3313ec85f5c28cedda950a3f9b Binary files /dev/null and b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_2 differ diff --git a/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_3 b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_3 new file mode 100644 index 0000000000000000000000000000000000000000..5eec97358cf550862fd343fc9a73c159d4c0ab10 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Cache/Cache_Data/data_3 differ diff --git a/.chrome-cdp-mobile/Default/Cache/Cache_Data/index b/.chrome-cdp-mobile/Default/Cache/Cache_Data/index new file mode 100644 index 0000000000000000000000000000000000000000..cd385f2cd002ad834b99aa39e8a2bb8af29e765c --- /dev/null +++ b/.chrome-cdp-mobile/Default/Cache/Cache_Data/index @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b25089b77ca3ae0c58159c234e208281621b15f32825842dc5f1061bd3a17ad +size 524656 diff --git a/.chrome-cdp-mobile/Default/Cache/No_Vary_Search/journal.baj b/.chrome-cdp-mobile/Default/Cache/No_Vary_Search/journal.baj new file mode 100644 index 0000000000000000000000000000000000000000..54fe66eb5b1e10ab864415337fc572163e66724e --- /dev/null +++ b/.chrome-cdp-mobile/Default/Cache/No_Vary_Search/journal.baj @@ -0,0 +1 @@ +$F~ \ No newline at end of file diff --git a/.chrome-cdp-mobile/Default/Cache/No_Vary_Search/snapshot.baf b/.chrome-cdp-mobile/Default/Cache/No_Vary_Search/snapshot.baf new file mode 100644 index 0000000000000000000000000000000000000000..8912405f361089375a773ff93370d55411ac14de Binary files /dev/null and b/.chrome-cdp-mobile/Default/Cache/No_Vary_Search/snapshot.baf differ diff --git a/.chrome-cdp-mobile/Default/ClientCertificates/LOCK b/.chrome-cdp-mobile/Default/ClientCertificates/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/ClientCertificates/LOG b/.chrome-cdp-mobile/Default/ClientCertificates/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Code Cache/js/index b/.chrome-cdp-mobile/Default/Code Cache/js/index new file mode 100644 index 0000000000000000000000000000000000000000..79bd403ac665228853dd8fa54b8f4427af1721c0 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Code Cache/js/index differ diff --git a/.chrome-cdp-mobile/Default/Code Cache/js/index-dir/the-real-index b/.chrome-cdp-mobile/Default/Code Cache/js/index-dir/the-real-index new file mode 100644 index 0000000000000000000000000000000000000000..2f8a1de6a2f2886063a6ecfb17f9d3250db42dc1 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Code Cache/js/index-dir/the-real-index differ diff --git a/.chrome-cdp-mobile/Default/Code Cache/wasm/index b/.chrome-cdp-mobile/Default/Code Cache/wasm/index new file mode 100644 index 0000000000000000000000000000000000000000..79bd403ac665228853dd8fa54b8f4427af1721c0 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Code Cache/wasm/index differ diff --git a/.chrome-cdp-mobile/Default/Code Cache/wasm/index-dir/the-real-index b/.chrome-cdp-mobile/Default/Code Cache/wasm/index-dir/the-real-index new file mode 100644 index 0000000000000000000000000000000000000000..2f8a1de6a2f2886063a6ecfb17f9d3250db42dc1 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Code Cache/wasm/index-dir/the-real-index differ diff --git a/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_0 b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_0 new file mode 100644 index 0000000000000000000000000000000000000000..d76fb77e93ac8a536b5dbade616d63abd00626c5 Binary files /dev/null and b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_0 differ diff --git a/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_1 b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_1 new file mode 100644 index 0000000000000000000000000000000000000000..0233ce985a4882f7f2d417f9bf500e4d67356c37 --- /dev/null +++ b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_1 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1e963d702392fb7224786e7d56d43973e9b9efd1b89c17814d7c558ffc0cdec +size 270336 diff --git a/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_2 b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_2 new file mode 100644 index 0000000000000000000000000000000000000000..c7e2eb9adcfb2d3313ec85f5c28cedda950a3f9b Binary files /dev/null and b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_2 differ diff --git a/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_3 b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_3 new file mode 100644 index 0000000000000000000000000000000000000000..5eec97358cf550862fd343fc9a73c159d4c0ab10 Binary files /dev/null and b/.chrome-cdp-mobile/Default/DawnGraphiteCache/data_3 differ diff --git a/.chrome-cdp-mobile/Default/DawnGraphiteCache/index b/.chrome-cdp-mobile/Default/DawnGraphiteCache/index new file mode 100644 index 0000000000000000000000000000000000000000..087e4aa548b42dea275fcc6a12d38ab1a4dadd94 --- /dev/null +++ b/.chrome-cdp-mobile/Default/DawnGraphiteCache/index @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce8c77113889d9ffb6bb85244a551a7adc17b065d3e6d26068cba8423720348a +size 262512 diff --git a/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_0 b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_0 new file mode 100644 index 0000000000000000000000000000000000000000..d76fb77e93ac8a536b5dbade616d63abd00626c5 Binary files /dev/null and b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_0 differ diff --git a/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_1 b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_1 new file mode 100644 index 0000000000000000000000000000000000000000..0233ce985a4882f7f2d417f9bf500e4d67356c37 --- /dev/null +++ b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_1 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1e963d702392fb7224786e7d56d43973e9b9efd1b89c17814d7c558ffc0cdec +size 270336 diff --git a/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_2 b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_2 new file mode 100644 index 0000000000000000000000000000000000000000..c7e2eb9adcfb2d3313ec85f5c28cedda950a3f9b Binary files /dev/null and b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_2 differ diff --git a/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_3 b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_3 new file mode 100644 index 0000000000000000000000000000000000000000..5eec97358cf550862fd343fc9a73c159d4c0ab10 Binary files /dev/null and b/.chrome-cdp-mobile/Default/DawnWebGPUCache/data_3 differ diff --git a/.chrome-cdp-mobile/Default/DawnWebGPUCache/index b/.chrome-cdp-mobile/Default/DawnWebGPUCache/index new file mode 100644 index 0000000000000000000000000000000000000000..527659f92aabb68f0d0cbb47e32b127ef2df86c4 --- /dev/null +++ b/.chrome-cdp-mobile/Default/DawnWebGPUCache/index @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b05c1f881b707d66a6253d7053552bbe3c342d0f832c89948ecb55f83996ea27 +size 262512 diff --git a/.chrome-cdp-mobile/Default/Extension Rules/000003.log b/.chrome-cdp-mobile/Default/Extension Rules/000003.log new file mode 100644 index 0000000000000000000000000000000000000000..bd876bead4e46e9f55bfa9823f15c6c88cd29470 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Extension Rules/000003.log differ diff --git a/.chrome-cdp-mobile/Default/Extension Rules/CURRENT b/.chrome-cdp-mobile/Default/Extension Rules/CURRENT new file mode 100644 index 0000000000000000000000000000000000000000..7ed683d17118b70e46011ddc3562b455d0a4f866 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Extension Rules/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/Extension Rules/LOCK b/.chrome-cdp-mobile/Default/Extension Rules/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Extension Rules/LOG b/.chrome-cdp-mobile/Default/Extension Rules/LOG new file mode 100644 index 0000000000000000000000000000000000000000..6ab7be40588bb66b4378944eef8d1330349ac5e9 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Extension Rules/LOG @@ -0,0 +1,2 @@ +2026/06/14-20:49:09.716 8d58 Creating DB C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\Extension Rules since it was missing. +2026/06/14-20:49:09.769 8d58 Reusing MANIFEST C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\Extension Rules/MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/Extension Rules/MANIFEST-000001 b/.chrome-cdp-mobile/Default/Extension Rules/MANIFEST-000001 new file mode 100644 index 0000000000000000000000000000000000000000..18e5cab72c1550d8dc398e3413eea91bee24db77 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Extension Rules/MANIFEST-000001 differ diff --git a/.chrome-cdp-mobile/Default/Extension Scripts/000001.dbtmp b/.chrome-cdp-mobile/Default/Extension Scripts/000001.dbtmp new file mode 100644 index 0000000000000000000000000000000000000000..7ed683d17118b70e46011ddc3562b455d0a4f866 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Extension Scripts/000001.dbtmp @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/Extension Scripts/LOCK b/.chrome-cdp-mobile/Default/Extension Scripts/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Extension Scripts/LOG b/.chrome-cdp-mobile/Default/Extension Scripts/LOG new file mode 100644 index 0000000000000000000000000000000000000000..259bde7355bd072d54b26a20e55f59a2a10c6874 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Extension Scripts/LOG @@ -0,0 +1 @@ +2026/06/14-20:49:09.774 8d58 Creating DB C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\Extension Scripts since it was missing. diff --git a/.chrome-cdp-mobile/Default/Extension Scripts/MANIFEST-000001 b/.chrome-cdp-mobile/Default/Extension Scripts/MANIFEST-000001 new file mode 100644 index 0000000000000000000000000000000000000000..18e5cab72c1550d8dc398e3413eea91bee24db77 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Extension Scripts/MANIFEST-000001 differ diff --git a/.chrome-cdp-mobile/Default/Extension Scripts/c0bfd1bf-9ef7-4814-93e2-5b6596ddae60.tmp b/.chrome-cdp-mobile/Default/Extension Scripts/c0bfd1bf-9ef7-4814-93e2-5b6596ddae60.tmp new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Favicons b/.chrome-cdp-mobile/Default/Favicons new file mode 100644 index 0000000000000000000000000000000000000000..bb442b34490d279c0aacc95890aeb6f287d9bbd3 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Favicons differ diff --git a/.chrome-cdp-mobile/Default/Favicons-journal b/.chrome-cdp-mobile/Default/Favicons-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/GPUCache/data_0 b/.chrome-cdp-mobile/Default/GPUCache/data_0 new file mode 100644 index 0000000000000000000000000000000000000000..d76fb77e93ac8a536b5dbade616d63abd00626c5 Binary files /dev/null and b/.chrome-cdp-mobile/Default/GPUCache/data_0 differ diff --git a/.chrome-cdp-mobile/Default/GPUCache/data_1 b/.chrome-cdp-mobile/Default/GPUCache/data_1 new file mode 100644 index 0000000000000000000000000000000000000000..0233ce985a4882f7f2d417f9bf500e4d67356c37 --- /dev/null +++ b/.chrome-cdp-mobile/Default/GPUCache/data_1 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1e963d702392fb7224786e7d56d43973e9b9efd1b89c17814d7c558ffc0cdec +size 270336 diff --git a/.chrome-cdp-mobile/Default/GPUCache/data_2 b/.chrome-cdp-mobile/Default/GPUCache/data_2 new file mode 100644 index 0000000000000000000000000000000000000000..c7e2eb9adcfb2d3313ec85f5c28cedda950a3f9b Binary files /dev/null and b/.chrome-cdp-mobile/Default/GPUCache/data_2 differ diff --git a/.chrome-cdp-mobile/Default/GPUCache/data_3 b/.chrome-cdp-mobile/Default/GPUCache/data_3 new file mode 100644 index 0000000000000000000000000000000000000000..5eec97358cf550862fd343fc9a73c159d4c0ab10 Binary files /dev/null and b/.chrome-cdp-mobile/Default/GPUCache/data_3 differ diff --git a/.chrome-cdp-mobile/Default/GPUCache/index b/.chrome-cdp-mobile/Default/GPUCache/index new file mode 100644 index 0000000000000000000000000000000000000000..03a9f98d038c2db57e7c0fe2f4da5ebcdc77ef9f --- /dev/null +++ b/.chrome-cdp-mobile/Default/GPUCache/index @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e0126cc3baa9ffbd8d0ecc7182d14d175e1c14880cefe3151443aea4079c1d52 +size 262512 diff --git a/.chrome-cdp-mobile/Default/History b/.chrome-cdp-mobile/Default/History new file mode 100644 index 0000000000000000000000000000000000000000..5df751934d987f7c8bfcfcfd9d1779550120c68c --- /dev/null +++ b/.chrome-cdp-mobile/Default/History @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:44e9791b2bfb8223f95f365a6bf40015437de8498f3177c53652ed01d6851205 +size 163840 diff --git a/.chrome-cdp-mobile/Default/History-journal b/.chrome-cdp-mobile/Default/History-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/LOCK b/.chrome-cdp-mobile/Default/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/LOG b/.chrome-cdp-mobile/Default/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Login Data b/.chrome-cdp-mobile/Default/Login Data new file mode 100644 index 0000000000000000000000000000000000000000..4f4af503d4476bbd43812a4dbe61898364ade1a3 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Login Data differ diff --git a/.chrome-cdp-mobile/Default/Login Data For Account b/.chrome-cdp-mobile/Default/Login Data For Account new file mode 100644 index 0000000000000000000000000000000000000000..4f4af503d4476bbd43812a4dbe61898364ade1a3 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Login Data For Account differ diff --git a/.chrome-cdp-mobile/Default/Login Data For Account-journal b/.chrome-cdp-mobile/Default/Login Data For Account-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Login Data-journal b/.chrome-cdp-mobile/Default/Login Data-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Network Action Predictor b/.chrome-cdp-mobile/Default/Network Action Predictor new file mode 100644 index 0000000000000000000000000000000000000000..30164ca09e621c7b243a7a25e175816ade596fc2 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Network Action Predictor differ diff --git a/.chrome-cdp-mobile/Default/Network Action Predictor-journal b/.chrome-cdp-mobile/Default/Network Action Predictor-journal new file mode 100644 index 0000000000000000000000000000000000000000..4467b8a2b0ed1cb9f584ccd198c658baadc40f45 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Network Action Predictor-journal differ diff --git a/.chrome-cdp-mobile/Default/Network/Cookies b/.chrome-cdp-mobile/Default/Network/Cookies new file mode 100644 index 0000000000000000000000000000000000000000..ad9fbf3b5a88f9fcf3ab0ebf63c3151a0a1bf512 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Network/Cookies differ diff --git a/.chrome-cdp-mobile/Default/Network/Cookies-journal b/.chrome-cdp-mobile/Default/Network/Cookies-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Network/Device Bound Sessions b/.chrome-cdp-mobile/Default/Network/Device Bound Sessions new file mode 100644 index 0000000000000000000000000000000000000000..93e6647ed6e10bc7c174c858ced3f0f0aaf230c9 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Network/Device Bound Sessions differ diff --git a/.chrome-cdp-mobile/Default/Network/Device Bound Sessions-journal b/.chrome-cdp-mobile/Default/Network/Device Bound Sessions-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Network/NetworkDataMigrated b/.chrome-cdp-mobile/Default/Network/NetworkDataMigrated new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Network/Reporting and NEL b/.chrome-cdp-mobile/Default/Network/Reporting and NEL new file mode 100644 index 0000000000000000000000000000000000000000..5deba44fa614ea99b90bdf2e00a7eb0571eacad4 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Network/Reporting and NEL differ diff --git a/.chrome-cdp-mobile/Default/Network/Reporting and NEL-journal b/.chrome-cdp-mobile/Default/Network/Reporting and NEL-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Network/Trust Tokens b/.chrome-cdp-mobile/Default/Network/Trust Tokens new file mode 100644 index 0000000000000000000000000000000000000000..e4af9e4b54aa2ff538fb90a6316224952603fbf9 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Network/Trust Tokens differ diff --git a/.chrome-cdp-mobile/Default/Network/Trust Tokens-journal b/.chrome-cdp-mobile/Default/Network/Trust Tokens-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/PersistentOriginTrials/LOCK b/.chrome-cdp-mobile/Default/PersistentOriginTrials/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/PersistentOriginTrials/LOG b/.chrome-cdp-mobile/Default/PersistentOriginTrials/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/README b/.chrome-cdp-mobile/Default/README new file mode 100644 index 0000000000000000000000000000000000000000..98d9d278519f73ce220ce053762db117b86b2471 --- /dev/null +++ b/.chrome-cdp-mobile/Default/README @@ -0,0 +1 @@ +Google Chrome settings and storage represent user-selected preferences and information and MUST not be extracted, overwritten or modified except through Google Chrome defined APIs. \ No newline at end of file diff --git a/.chrome-cdp-mobile/Default/Safe Browsing Network/NetworkDataMigrated b/.chrome-cdp-mobile/Default/Safe Browsing Network/NetworkDataMigrated new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Safe Browsing Network/Safe Browsing Cookies b/.chrome-cdp-mobile/Default/Safe Browsing Network/Safe Browsing Cookies new file mode 100644 index 0000000000000000000000000000000000000000..ad9fbf3b5a88f9fcf3ab0ebf63c3151a0a1bf512 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Safe Browsing Network/Safe Browsing Cookies differ diff --git a/.chrome-cdp-mobile/Default/Safe Browsing Network/Safe Browsing Cookies-journal b/.chrome-cdp-mobile/Default/Safe Browsing Network/Safe Browsing Cookies-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Segmentation Platform/SegmentInfoDB/LOCK b/.chrome-cdp-mobile/Default/Segmentation Platform/SegmentInfoDB/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Segmentation Platform/SegmentInfoDB/LOG b/.chrome-cdp-mobile/Default/Segmentation Platform/SegmentInfoDB/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Segmentation Platform/SignalDB/LOCK b/.chrome-cdp-mobile/Default/Segmentation Platform/SignalDB/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Segmentation Platform/SignalDB/LOG b/.chrome-cdp-mobile/Default/Segmentation Platform/SignalDB/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Segmentation Platform/SignalStorageConfigDB/LOCK b/.chrome-cdp-mobile/Default/Segmentation Platform/SignalStorageConfigDB/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Segmentation Platform/SignalStorageConfigDB/LOG b/.chrome-cdp-mobile/Default/Segmentation Platform/SignalStorageConfigDB/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/ServerCertificate b/.chrome-cdp-mobile/Default/ServerCertificate new file mode 100644 index 0000000000000000000000000000000000000000..d5812ccfcd0aa65f6b456009f119c8f92f44b97c Binary files /dev/null and b/.chrome-cdp-mobile/Default/ServerCertificate differ diff --git a/.chrome-cdp-mobile/Default/ServerCertificate-journal b/.chrome-cdp-mobile/Default/ServerCertificate-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Shared Dictionary/cache/index b/.chrome-cdp-mobile/Default/Shared Dictionary/cache/index new file mode 100644 index 0000000000000000000000000000000000000000..79bd403ac665228853dd8fa54b8f4427af1721c0 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Shared Dictionary/cache/index differ diff --git a/.chrome-cdp-mobile/Default/Shared Dictionary/cache/index-dir/the-real-index b/.chrome-cdp-mobile/Default/Shared Dictionary/cache/index-dir/the-real-index new file mode 100644 index 0000000000000000000000000000000000000000..31cd419c766a4f2750545e8b20cb2195f733f267 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Shared Dictionary/cache/index-dir/the-real-index differ diff --git a/.chrome-cdp-mobile/Default/Shared Dictionary/db b/.chrome-cdp-mobile/Default/Shared Dictionary/db new file mode 100644 index 0000000000000000000000000000000000000000..d97070388d70537f758efc0afcf220bdafdce695 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Shared Dictionary/db differ diff --git a/.chrome-cdp-mobile/Default/Shared Dictionary/db-journal b/.chrome-cdp-mobile/Default/Shared Dictionary/db-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Site Characteristics Database/000003.log b/.chrome-cdp-mobile/Default/Site Characteristics Database/000003.log new file mode 100644 index 0000000000000000000000000000000000000000..46580fb8f820d269031534af4743e7cbce94c9b0 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Site Characteristics Database/000003.log differ diff --git a/.chrome-cdp-mobile/Default/Site Characteristics Database/CURRENT b/.chrome-cdp-mobile/Default/Site Characteristics Database/CURRENT new file mode 100644 index 0000000000000000000000000000000000000000..7ed683d17118b70e46011ddc3562b455d0a4f866 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Site Characteristics Database/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/Site Characteristics Database/LOCK b/.chrome-cdp-mobile/Default/Site Characteristics Database/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Site Characteristics Database/LOG b/.chrome-cdp-mobile/Default/Site Characteristics Database/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e1ab6baa85d0425ed6314a3c01eba09d068b38ab --- /dev/null +++ b/.chrome-cdp-mobile/Default/Site Characteristics Database/LOG @@ -0,0 +1,2 @@ +2026/06/14-20:49:09.675 48d0 Creating DB C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\Site Characteristics Database since it was missing. +2026/06/14-20:49:09.716 48d0 Reusing MANIFEST C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\Site Characteristics Database/MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/Site Characteristics Database/MANIFEST-000001 b/.chrome-cdp-mobile/Default/Site Characteristics Database/MANIFEST-000001 new file mode 100644 index 0000000000000000000000000000000000000000..18e5cab72c1550d8dc398e3413eea91bee24db77 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Site Characteristics Database/MANIFEST-000001 differ diff --git a/.chrome-cdp-mobile/Default/Sync Data/LevelDB/000003.log b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/000003.log new file mode 100644 index 0000000000000000000000000000000000000000..cfafc38bb6f2c69be715d462358e74e62ee8a90d Binary files /dev/null and b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/000003.log differ diff --git a/.chrome-cdp-mobile/Default/Sync Data/LevelDB/CURRENT b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/CURRENT new file mode 100644 index 0000000000000000000000000000000000000000..7ed683d17118b70e46011ddc3562b455d0a4f866 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/Sync Data/LevelDB/LOCK b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/Sync Data/LevelDB/LOG b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/LOG new file mode 100644 index 0000000000000000000000000000000000000000..cdf092b203fe4bedced06bb3e00fe579c26ee1c1 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/LOG @@ -0,0 +1,2 @@ +2026/06/14-20:49:09.669 2b9c Creating DB C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\Sync Data\LevelDB since it was missing. +2026/06/14-20:49:09.707 2b9c Reusing MANIFEST C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\Sync Data\LevelDB/MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/Sync Data/LevelDB/MANIFEST-000001 b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/MANIFEST-000001 new file mode 100644 index 0000000000000000000000000000000000000000..18e5cab72c1550d8dc398e3413eea91bee24db77 Binary files /dev/null and b/.chrome-cdp-mobile/Default/Sync Data/LevelDB/MANIFEST-000001 differ diff --git a/.chrome-cdp-mobile/Default/Top Sites b/.chrome-cdp-mobile/Default/Top Sites new file mode 100644 index 0000000000000000000000000000000000000000..853f093638161b6137c1021f65ab553ffa056eaa Binary files /dev/null and b/.chrome-cdp-mobile/Default/Top Sites differ diff --git a/.chrome-cdp-mobile/Default/Top Sites-journal b/.chrome-cdp-mobile/Default/Top Sites-journal new file mode 100644 index 0000000000000000000000000000000000000000..f9996222568268689ed971ac65a3419049163e0b Binary files /dev/null and b/.chrome-cdp-mobile/Default/Top Sites-journal differ diff --git a/.chrome-cdp-mobile/Default/Web Data b/.chrome-cdp-mobile/Default/Web Data new file mode 100644 index 0000000000000000000000000000000000000000..8841ff83a0955da44edf34a90f3b7fde5657f4d5 --- /dev/null +++ b/.chrome-cdp-mobile/Default/Web Data @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:56e2cfff757954777a5425e89464ec3a0a45be8ac6d2ebcd92e179566eaeaa59 +size 151552 diff --git a/.chrome-cdp-mobile/Default/Web Data-journal b/.chrome-cdp-mobile/Default/Web Data-journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/WebStorage/QuotaManager b/.chrome-cdp-mobile/Default/WebStorage/QuotaManager new file mode 100644 index 0000000000000000000000000000000000000000..8e72c8ec84e78c455af51b5771aab94331aa7bc5 Binary files /dev/null and b/.chrome-cdp-mobile/Default/WebStorage/QuotaManager differ diff --git a/.chrome-cdp-mobile/Default/WebStorage/QuotaManager-journal b/.chrome-cdp-mobile/Default/WebStorage/QuotaManager-journal new file mode 100644 index 0000000000000000000000000000000000000000..4fd7a5ab9c2e4812b7abe82c8d307dba160023c1 Binary files /dev/null and b/.chrome-cdp-mobile/Default/WebStorage/QuotaManager-journal differ diff --git a/.chrome-cdp-mobile/Default/chrome_cart_db/LOCK b/.chrome-cdp-mobile/Default/chrome_cart_db/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/chrome_cart_db/LOG b/.chrome-cdp-mobile/Default/chrome_cart_db/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/commerce_subscription_db/LOCK b/.chrome-cdp-mobile/Default/commerce_subscription_db/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/commerce_subscription_db/LOG b/.chrome-cdp-mobile/Default/commerce_subscription_db/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/discount_infos_db/LOCK b/.chrome-cdp-mobile/Default/discount_infos_db/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/discount_infos_db/LOG b/.chrome-cdp-mobile/Default/discount_infos_db/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/discounts_db/LOCK b/.chrome-cdp-mobile/Default/discounts_db/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/discounts_db/LOG b/.chrome-cdp-mobile/Default/discounts_db/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/engine_allowlist.bf b/.chrome-cdp-mobile/Default/engine_allowlist.bf new file mode 100644 index 0000000000000000000000000000000000000000..871816f9410c061115d5d60177404fedfbd547ec Binary files /dev/null and b/.chrome-cdp-mobile/Default/engine_allowlist.bf differ diff --git a/.chrome-cdp-mobile/Default/parcel_tracking_db/LOCK b/.chrome-cdp-mobile/Default/parcel_tracking_db/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/parcel_tracking_db/LOG b/.chrome-cdp-mobile/Default/parcel_tracking_db/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/CURRENT b/.chrome-cdp-mobile/Default/shared_proto_db/CURRENT new file mode 100644 index 0000000000000000000000000000000000000000..7ed683d17118b70e46011ddc3562b455d0a4f866 --- /dev/null +++ b/.chrome-cdp-mobile/Default/shared_proto_db/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/LOCK b/.chrome-cdp-mobile/Default/shared_proto_db/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/LOG b/.chrome-cdp-mobile/Default/shared_proto_db/LOG new file mode 100644 index 0000000000000000000000000000000000000000..e467468d7c57af2982259e8f669615dad8ced5f5 --- /dev/null +++ b/.chrome-cdp-mobile/Default/shared_proto_db/LOG @@ -0,0 +1 @@ +2026/06/14-20:49:09.783 7cd0 Creating DB C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\shared_proto_db since it was missing. diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/MANIFEST-000001 b/.chrome-cdp-mobile/Default/shared_proto_db/MANIFEST-000001 new file mode 100644 index 0000000000000000000000000000000000000000..18e5cab72c1550d8dc398e3413eea91bee24db77 Binary files /dev/null and b/.chrome-cdp-mobile/Default/shared_proto_db/MANIFEST-000001 differ diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/metadata/000003.log b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/000003.log new file mode 100644 index 0000000000000000000000000000000000000000..f364241685eb323a634caa0368caa968eadc4a29 Binary files /dev/null and b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/000003.log differ diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/metadata/CURRENT b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/CURRENT new file mode 100644 index 0000000000000000000000000000000000000000..7ed683d17118b70e46011ddc3562b455d0a4f866 --- /dev/null +++ b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/CURRENT @@ -0,0 +1 @@ +MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/metadata/LOCK b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/LOCK new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/metadata/LOG b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/LOG new file mode 100644 index 0000000000000000000000000000000000000000..3f4af4a75e3ac8838a77cf5b0b318c348726e21c --- /dev/null +++ b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/LOG @@ -0,0 +1,2 @@ +2026/06/14-20:49:09.721 7cd0 Creating DB C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\shared_proto_db\metadata since it was missing. +2026/06/14-20:49:09.774 7cd0 Reusing MANIFEST C:\Users\18632\Desktop\howdyPartner\space\.chrome-cdp-mobile\Default\shared_proto_db\metadata/MANIFEST-000001 diff --git a/.chrome-cdp-mobile/Default/shared_proto_db/metadata/MANIFEST-000001 b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/MANIFEST-000001 new file mode 100644 index 0000000000000000000000000000000000000000..18e5cab72c1550d8dc398e3413eea91bee24db77 Binary files /dev/null and b/.chrome-cdp-mobile/Default/shared_proto_db/metadata/MANIFEST-000001 differ diff --git a/.chrome-cdp-mobile/GPUPersistentCache/DawnGraphiteCache/QFRPRRKRKWZNTFMPUJZIPW2HOZ5QE2WL/cache.db b/.chrome-cdp-mobile/GPUPersistentCache/DawnGraphiteCache/QFRPRRKRKWZNTFMPUJZIPW2HOZ5QE2WL/cache.db new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/GPUPersistentCache/DawnGraphiteCache/QFRPRRKRKWZNTFMPUJZIPW2HOZ5QE2WL/cache.db-wal b/.chrome-cdp-mobile/GPUPersistentCache/DawnGraphiteCache/QFRPRRKRKWZNTFMPUJZIPW2HOZ5QE2WL/cache.db-wal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/GPUPersistentCache/DawnGraphiteCache/QFRPRRKRKWZNTFMPUJZIPW2HOZ5QE2WL/cache.journal b/.chrome-cdp-mobile/GPUPersistentCache/DawnGraphiteCache/QFRPRRKRKWZNTFMPUJZIPW2HOZ5QE2WL/cache.journal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.chrome-cdp-mobile/Last Version b/.chrome-cdp-mobile/Last Version new file mode 100644 index 0000000000000000000000000000000000000000..c9199539f07077be8f6f9e31979c2c5a519a0247 --- /dev/null +++ b/.chrome-cdp-mobile/Last Version @@ -0,0 +1 @@ +149.0.7827.104 \ No newline at end of file diff --git a/.chrome-cdp-mobile/Local State b/.chrome-cdp-mobile/Local State new file mode 100644 index 0000000000000000000000000000000000000000..c4812064894dac7b6dcbea4cf03174c020ee5c5d --- /dev/null +++ b/.chrome-cdp-mobile/Local State @@ -0,0 +1 @@ +{"browser":{"whats_new":{"enabled_order":["SyncAccountSettings"]}},"os_crypt":{"audit_enabled":true,"encrypted_key":"RFBBUEkBAAAA0Iyd3wEV0RGMegDAT8KX6wEAAAALSj6crZJgS4+7aZlRel+oEAAAABwAAABHAG8AbwBnAGwAZQAgAEMAaAByAG8AbQBlAAAAEGYAAAABAAAgAAAAQ6QaAanuAV6Ent7B9LQjk6enODAL3evS9EtfmtzGmU4AAAAADoAAAAACAAAgAAAA2bK8RdtnRpGIm2olIFAjHJoHsX4h9Bmekprtgl+kaUowAAAAjtXIr59PnhZEmak4SckPVYAzL0hn32BSw7RAWsgGLtBfdKR5wSFbDz14u4a4qNgSQAAAAIM4jSV6UsWCpCTYHDsnRRprBuoRRlYS73BHD7+g0At3sN0vKErXef3Ts7NxnJAHLr3YD3+T4yoaE2Bq+GvDzQM="},"signin":{"active_accounts_last_emitted":"13425958149613760"},"uninstall_metrics":{"installation_date2":"1781484525"},"user_experience_metrics":{"client_id2":"838fd3e1-98dc-45a5-a4e8-62725f34d137","client_id_timestamp":"1781484525","last_seen":{"CrashpadMetrics":"0"},"limited_entropy_randomization_source":"F554A9E55F365757A80D0949E4FCDDE5","low_entropy_source3":6448,"pseudo_low_entropy_source":3912,"stability":{"browser_last_live_timestamp":"13425958149554244","stats_buildtime":"1780694161","stats_version":"149.0.7827.104-64","system_crash_count":0}}} \ No newline at end of file diff --git a/.chrome-cdp-mobile/ShaderCache/data_0 b/.chrome-cdp-mobile/ShaderCache/data_0 new file mode 100644 index 0000000000000000000000000000000000000000..d76fb77e93ac8a536b5dbade616d63abd00626c5 Binary files /dev/null and b/.chrome-cdp-mobile/ShaderCache/data_0 differ diff --git a/.chrome-cdp-mobile/ShaderCache/data_1 b/.chrome-cdp-mobile/ShaderCache/data_1 new file mode 100644 index 0000000000000000000000000000000000000000..0233ce985a4882f7f2d417f9bf500e4d67356c37 --- /dev/null +++ b/.chrome-cdp-mobile/ShaderCache/data_1 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1e963d702392fb7224786e7d56d43973e9b9efd1b89c17814d7c558ffc0cdec +size 270336 diff --git a/.chrome-cdp-mobile/ShaderCache/data_2 b/.chrome-cdp-mobile/ShaderCache/data_2 new file mode 100644 index 0000000000000000000000000000000000000000..c7e2eb9adcfb2d3313ec85f5c28cedda950a3f9b Binary files /dev/null and b/.chrome-cdp-mobile/ShaderCache/data_2 differ diff --git a/.chrome-cdp-mobile/ShaderCache/data_3 b/.chrome-cdp-mobile/ShaderCache/data_3 new file mode 100644 index 0000000000000000000000000000000000000000..5eec97358cf550862fd343fc9a73c159d4c0ab10 Binary files /dev/null and b/.chrome-cdp-mobile/ShaderCache/data_3 differ diff --git a/.chrome-cdp-mobile/ShaderCache/index b/.chrome-cdp-mobile/ShaderCache/index new file mode 100644 index 0000000000000000000000000000000000000000..5e6e42c7e564d7815352f1f049328363618f89b8 --- /dev/null +++ b/.chrome-cdp-mobile/ShaderCache/index @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06cc098212cb666ad923f303e78e5f3e2a2ab3cc4d295cc69f8b08a461749009 +size 262512 diff --git a/.chrome-cdp-mobile/Variations b/.chrome-cdp-mobile/Variations new file mode 100644 index 0000000000000000000000000000000000000000..a157215f17ca9513e90c9f145b29b762b6a208b8 --- /dev/null +++ b/.chrome-cdp-mobile/Variations @@ -0,0 +1 @@ +{"user_experience_metrics.stability.exited_cleanly":false,"variations_crash_streak":0} \ No newline at end of file diff --git a/.chrome-cdp-mobile/segmentation_platform/ukm_db b/.chrome-cdp-mobile/segmentation_platform/ukm_db new file mode 100644 index 0000000000000000000000000000000000000000..fb6cc22b18238d5f4b3f15aa18537c2081bca610 Binary files /dev/null and b/.chrome-cdp-mobile/segmentation_platform/ukm_db differ diff --git a/.chrome-cdp-mobile/segmentation_platform/ukm_db-wal b/.chrome-cdp-mobile/segmentation_platform/ukm_db-wal new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..9832473c018928bac845091552de3ed15f0ffe20 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,17 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Crashpad/reports/04484a24-7834-4a62-a8b7-f7313a7c362d.dmp filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/CrashpadMetrics-active.pma filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/Cache/Cache_Data/data_1 filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/Cache/Cache_Data/index filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/DawnGraphiteCache/data_1 filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/DawnGraphiteCache/index filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/DawnWebGPUCache/data_1 filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/DawnWebGPUCache/index filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/GPUCache/data_1 filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/GPUCache/index filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/History filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/Default/Web[[:space:]]Data filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/ShaderCache/data_1 filter=lfs diff=lfs merge=lfs -text +.chrome-cdp-mobile/ShaderCache/index filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..7e0004c7362ca8afab9773787df171e599a3f7f3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# PixelLock β€” GPU Space. +# A fine-tuned *text* LLM (Gemma-4-12B) rewrites a sprite as a PALETTE/GRID text +# wire under a per-sprite GBNF grammar (llama.cpp) that locks the silhouette by +# construction. We base on the OFFICIAL prebuilt llama.cpp CUDA server image so +# there is NO source compile (compiling the CUDA kernels OOM-kills the free HF +# builder) β€” this ships the exact `llama-server` we validated, plus a Gradio UI. +FROM ghcr.io/ggml-org/llama.cpp:server-cuda + +# --- add Python + our deps on top of the llama.cpp CUDA runtime -------------- +USER root +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Make the server binary reachable as `llama-server` regardless of its path in +# the base image (it ships at /app/llama-server). +RUN ln -sf "$(command -v llama-server || echo /app/llama-server)" /usr/local/bin/llama-server || true + +COPY requirements.txt /tmp/requirements.txt +# Ubuntu 24.04 base β†’ PEP 668 blocks system pip installs; --break-system-packages +# is the intended escape hatch inside a container. +RUN pip3 install --no-cache-dir --break-system-packages -r /tmp/requirements.txt + +# --- non-root user (HF Spaces requirement) ---------------------------------- +# The base image already has a uid-1000 user; -o allows reusing the UID under a +# new name so `USER user` / `--chown=user` resolve cleanly. +RUN useradd -m -u 1000 -o user +USER user +ENV HOME=/home/user PATH=/home/user/.local/bin:$PATH +WORKDIR /home/user/app + +# --- bake the fine-tuned GGUF into the image (fast, token-free cold starts) --- +ENV MODEL_REPO=solarkyle/pixellock-gemma-12b-pixelart-gguf \ + MODEL_FILE=pixellock-gemma-12b-q4_k_m.gguf \ + MODEL_PATH=/home/user/app/model.gguf +RUN python3 -c "import os, shutil; from huggingface_hub import hf_hub_download; \ +shutil.copy(hf_hub_download(os.environ['MODEL_REPO'], os.environ['MODEL_FILE']), os.environ['MODEL_PATH'])" + +# --- app code (Gradio UI + engine modules) ---------------------------------- +COPY --chown=user . /home/user/app + +ENV PYTHONUNBUFFERED=1 \ + PIXELLOCK_ENDPOINT=http://127.0.0.1:8080/v1/chat/completions \ + PIXELLOCK_MODEL=pixellock \ + PIXELLOCK_MAX_DIM=64 \ + PORT=7860 + +EXPOSE 7860 +# Reset the base image's entrypoint (it launches llama-server directly) so our +# startup script runs both llama-server (background) and the Gradio UI. +ENTRYPOINT [] +CMD ["bash", "start.sh"] diff --git a/README.md b/README.md index ee7df96568758ee59a29ca5a0590a341583f33ec..243f202360be94c5bfc983fbadd12134fa6982fe 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,72 @@ --- -title: Pixellock -emoji: πŸ‘€ +title: PixelLock +emoji: 🎨 colorFrom: purple -colorTo: green +colorTo: indigo sdk: docker -pinned: false +app_port: 7860 +pinned: true +license: gemma +short_description: Footprint-locked AI pixel-art retexturing +models: + - solarkyle/pixellock-gemma-12b-pixelart-gguf +tags: + - gradio + - build-small-hackathon + - track:wood + - achievement:offbrand + - achievement:welltuned + - achievement:llama + - achievement:sharing + - achievement:fieldnotes + - achievement:offgrid + - best-demo + - bonus-quest-champion + - pixel-art --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# 🎨 PixelLock β€” AI Pixel-Art Retexturing + +Upload a sprite, pick a vibe (or write your own), hit **Run**. PixelLock restyles +the colors and shading while keeping the **exact silhouette** β€” pixel-perfect, +*guaranteed by construction*. + +> ⏳ This is a GPU Space that auto-sleeps. The first visit cold-starts the model +> (~30–60s). If the first edit errors, the model is still waking β€” try again. + +## πŸ”— Links + +- πŸŽ₯ **Demo video:** [WATCH β–Ά](VIDEO_LINK) +- πŸ“£ **Social post:** [SEE POST](SOCIAL_POST_LINK) +- 🧠 **Model (fine-tuned GGUF):** https://huggingface.co/solarkyle/pixellock-gemma-12b-pixelart-gguf +- πŸ“„ **Full technical writeup:** [read it](WRITEUP_LINK) + +## The idea: the *wrong* model on purpose + +PixelLock does **not** use an image generator. It uses a **fine-tuned text LLM** +(Gemma-4-12B). The sprite is serialized to a tiny text wire β€” a `PALETTE` block +plus a `GRID` of space-separated cells β€” and the model **rewrites that text** in a +new style. The trick is a per-sprite **GBNF decoding grammar** (llama.cpp): every +transparent cell is pinned to a fixed literal, so the model is *physically +incapable* of moving a pixel. The footprint and transparency are preserved **by +construction**, not by luck. It's language modeling, not diffusion β€” and that's +exactly why the shape can never break. + +In a benchmark, *no* model could emit a correct 32Γ—32 grid unconstrained +(~0/150 attempts); the grammar takes validity from **0% β†’ 100%** at every size +(16Γ—16 β†’ 128Γ—128, incl. 2Γ— upscale), footprint-perfect every time. + +## The tech + +- **Fine-tuned model** β€” QLoRA fine-tune of Gemma-4-12B on a curated corpus of + palette-indexed pixel-art sprites (≀64px); completion-only loss, + step-checkpointing, final eval loss **0.378**. Exported to GGUF (q4_k_m). +- **Grammar-constrained decoding** β€” a per-sprite GBNF grammar compiled from the + input footprint; the silhouette is locked in the decoder, not post-filtered. +- **Custom Gradio UI** β€” a heavily themed `gr.Blocks` interface (Off-Brand) that + builds the grammar per upload, footprint-checks the output, and shows you the + **raw text the model wrote**. +- **Serving** β€” Docker GPU Space running llama.cpp `llama-server` (CUDA) with the + per-request grammar; auto-sleeps when idle. + +Built for the **Build Small** hackathon Β· *Thousand Token Wood* track. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..d66d82b3af5992d2ab462c75fa236fdb2c715b2b --- /dev/null +++ b/app.py @@ -0,0 +1,383 @@ +"""PixelLock Space app: custom UI at `/`, real Gradio mounted at `/gradio`. + +The public-facing experience is a hand-rolled HTML/CSS/JS workbench, but the +process still runs a genuine Gradio app for Build Small eligibility. The model +path, grammar-constrained llama.cpp call, and footprint verification match the +validated Gradio prototype. +""" + +from __future__ import annotations + +import base64 +import io +import json +import os +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +import gradio as gr +import httpx +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from PIL import Image +from pydantic import BaseModel + + +APP_DIR = Path(__file__).resolve().parent +for _path in (APP_DIR, APP_DIR.parent, APP_DIR.parent / "scratch"): + sys.path.insert(0, str(_path)) + +import config # noqa: E402 +import validate # noqa: E402 + +config.MAX_PALETTE = 64 +validate.MAX_PALETTE = 64 + +import pixel_editor as pe # noqa: E402 + + +ENDPOINT = os.environ.get( + "PIXELLOCK_ENDPOINT", "http://127.0.0.1:8080/v1/chat/completions" +) +MODEL = os.environ.get("PIXELLOCK_MODEL", "pixellock") +MAX_DIM = int(os.environ.get("PIXELLOCK_MAX_DIM", "64")) + +STATIC_DIR = APP_DIR / "static" +EXAMPLES_DIR = STATIC_DIR / "examples" +ASSETS_DIR = STATIC_DIR / "assets" + +THEMES = { + "πŸŒ‹ Molten lava": "a molten lava theme β€” glowing magma core, charred black edges, white-hot highlights, bold orange-red ramp", + "❄️ Frozen ice": "a frozen ice theme β€” pale cyan, frosted surface, white frost highlights, cool blue shadows", + "πŸͺ™ Solid gold": "a solid gold royal theme β€” shimmering gold ramp, warm highlights, deep amber shadows", + "☒️ Toxic": "a toxic radioactive theme β€” sickly neon green, glowing hazard spots, dark slime shadows", + "πŸ‚ Autumn dusk": "an autumn dusk theme β€” warm amber and crimson with deep purple shadows", + "🌌 Cosmic galaxy": "a cosmic galaxy theme β€” deep space-purple with tiny star speckles and glowing cyan accents", + "πŸ–€ Dark emo": "a dark emo theme β€” near-black base with glowing magenta and purple accents, moody", + "🌊 Deep ocean": "a deep ocean theme β€” teal and aqua, blue-green ramp, soft glow", +} + +CHECKER_LIGHT = (235, 235, 235) +CHECKER_DARK = (205, 205, 205) + + +class EditRequest(BaseModel): + image: str + prompt: str = "" + mode: str = "exact" + theme: str | None = None + + +def _checkerboard(width: int, height: int) -> Image.Image: + bg = Image.new("RGB", (width, height), CHECKER_LIGHT) + px = bg.load() + for y in range(height): + for x in range(width): + if ((x // 16) + (y // 16)) % 2: + px[x, y] = CHECKER_DARK + return bg + + +def _file_b64(path: Path) -> str: + return "data:image/png;base64," + base64.b64encode(path.read_bytes()).decode() + + +def _image_b64(image: Image.Image) -> str: + out = io.BytesIO() + image.save(out, format="PNG") + return "data:image/png;base64," + base64.b64encode(out.getvalue()).decode() + + +def _decode_image_b64(image_b64: str) -> tuple[Path, str | None]: + raw = image_b64.split(",", 1)[1] if "," in image_b64 else image_b64 + image = Image.open(io.BytesIO(base64.b64decode(raw))).convert("RGBA") + original_w, original_h = image.size + note = None + if max(original_w, original_h) > MAX_DIM: + scale = MAX_DIM / max(original_w, original_h) + new_w = max(1, round(original_w * scale)) + new_h = max(1, round(original_h * scale)) + image = image.resize((new_w, new_h), Image.Resampling.NEAREST) + note = "downscaled {}x{} -> {}x{}".format( + original_w, original_h, new_w, new_h + ) + tmp_in = Path(tempfile.gettempdir()) / "pixellock_in.png" + image.save(tmp_in) + return tmp_in, note + + +def _render_crisp(sprite: validate.Sprite, target: int = 512) -> Image.Image: + scale = max(1, target // max(sprite.width, sprite.height)) + out_w, out_h = sprite.width * scale, sprite.height * scale + sprite_image = Image.new("RGBA", (sprite.width, sprite.height)) + sprite_image.putdata( + [ + (0, 0, 0, 0) + if sprite.palette[ch] is None + else (*sprite.palette[ch], 255) + for row in sprite.rows + for ch in row + ] + ) + sprite_image = sprite_image.resize((out_w, out_h), Image.Resampling.NEAREST) + bg = _checkerboard(out_w, out_h).convert("RGBA") + bg.alpha_composite(sprite_image) + return bg.convert("RGB") + + +def _build_user_message(wire: str, width: int, height: int, instruction: str, upscale: bool) -> tuple[str, int]: + if upscale: + contract = ( + "Redraw this texture at {}x{} (2x). Every input pixel becomes a 2x2 " + "block: transparent stays transparent, colored stays colored. Add " + "finer shading and detail within that constraint." + ).format(width * 2, height * 2) + out_cells = (width * 2) * (height * 2) + else: + contract = ( + "Edit this texture. The grid stays {}x{} and every transparent cell " + "stays transparent; change only the colors of non-transparent cells." + ).format(width, height) + out_cells = width * height + + return ( + "{}\n\nInstruction: {}\n\nHere is the input texture:\n{}".format( + contract, instruction, wire + ), + out_cells, + ) + + +def _run_engine(image_path: Path, instruction: str, upscale: bool, note: str | None) -> dict[str, Any]: + wire, width, height = pe.png_to_wire(image_path, spaced=True) + input_sprite, parse_error = validate.parse_sprite(wire) + if input_sprite is None: + raise ValueError("Could not read sprite: {}".format(parse_error)) + + opaque_key_count = len([key for key in input_sprite.palette if key != "."]) + grammar = pe.build_grammar(input_sprite.rows, opaque_key_count, upscale, spaced=True) + user_msg, out_cells = _build_user_message(wire, width, height, instruction, upscale) + payload = { + "model": MODEL, + "messages": [ + {"role": "system", "content": pe.APP_SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "max_tokens": min(int(out_cells * 1.8) + 800, 40000), + "temperature": 0.7, + "chat_template_kwargs": {"enable_thinking": False}, + "grammar": grammar, + } + + started = time.perf_counter() + try: + response = httpx.post(ENDPOINT, json=payload, timeout=600.0) + response.raise_for_status() + except Exception as exc: + raise RuntimeError( + "Model backend is still waking or unreachable: {}".format(exc) + ) from exc + latency = time.perf_counter() - started + + body = response.json() + text = body["choices"][0]["message"]["content"] or "" + output_sprite, output_error = validate.parse_sprite(text) + if output_sprite is None: + raise ValueError("Model output failed to parse: {}".format(output_error)) + + input_footprint = validate.footprint(input_sprite) + if upscale: + input_footprint = { + (2 * x + dx, 2 * y + dy) + for (x, y) in input_footprint + for dx in (0, 1) + for dy in (0, 1) + } + footprint_ok = input_footprint == validate.footprint(output_sprite) + color_count = len([key for key in output_sprite.palette if key != "."]) + + true_png = Path(tempfile.gettempdir()) / "pixellock_out.png" + pe.wire_to_png(output_sprite, true_png) + + status_bits = [ + "{}x{}".format(output_sprite.width, output_sprite.height), + "{} colors".format(color_count), + "{:.1f}s".format(latency), + ] + if note: + status_bits.insert(0, note) + + return { + "ok": True, + "status": " Β· ".join(status_bits), + "footprint_ok": footprint_ok, + "footprint_perfect": footprint_ok, + "width": output_sprite.width, + "height": output_sprite.height, + "colors": color_count, + "latency": round(latency, 1), + "image": _file_b64(true_png), + "input": _file_b64(image_path), + "preview": _image_b64(_render_crisp(output_sprite)), + "wire": text.strip(), + "tokens": (body.get("usage") or {}).get("completion_tokens"), + } + + +def _asset_rows() -> list[dict[str, str]]: + rows = [] + for path in sorted(ASSETS_DIR.glob("*.png")): + rows.append( + { + "id": path.stem, + "title": path.stem.replace("gen_", "").replace("_", " ").title(), + "input": _file_b64(path), + "prompt": "", + "mode": "exact", + } + ) + return rows + + +def _example_rows() -> list[dict[str, str]]: + manifest = EXAMPLES_DIR / "examples.json" + if not manifest.exists(): + return [] + rows = [] + for item in json.loads(manifest.read_text(encoding="utf-8")): + input_path = EXAMPLES_DIR / item["input"] + output_path = EXAMPLES_DIR / item["output"] + if not input_path.exists() or not output_path.exists(): + continue + rows.append( + { + "id": str(item.get("id", input_path.stem)), + "title": item["title"], + "theme": item.get("theme", ""), + "prompt": item["prompt"], + "mode": item.get("mode", "exact"), + "input": _file_b64(input_path), + "output": _file_b64(output_path), + } + ) + return rows + + +api = FastAPI(title="PixelLock") + + +@api.get("/", response_class=HTMLResponse) +async def index() -> str: + return (STATIC_DIR / "index.html").read_text(encoding="utf-8") + + +@api.get("/api/health") +async def api_health() -> JSONResponse: + base = ENDPOINT.rsplit("/v1/", 1)[0] if "/v1/" in ENDPOINT else ENDPOINT + model_online = False + detail = "" + try: + with httpx.Client(timeout=2.0) as client: + resp = client.get(base.rstrip("/") + "/v1/models") + model_online = resp.status_code < 500 + except Exception as exc: + detail = str(exc) + return JSONResponse( + { + "ok": True, + "model_online": model_online, + "endpoint": ENDPOINT, + "model": MODEL, + "detail": detail, + } + ) + + +@api.get("/api/themes") +async def api_themes() -> JSONResponse: + return JSONResponse( + {"themes": [{"key": key, "prompt": prompt} for key, prompt in THEMES.items()]} + ) + + +@api.get("/api/assets") +async def api_assets() -> JSONResponse: + return JSONResponse(_asset_rows()) + + +@api.get("/api/examples") +async def api_examples() -> JSONResponse: + return JSONResponse(_example_rows()) + + +@api.post("/api/edit") +async def api_edit(req: EditRequest) -> JSONResponse: + instruction = (req.prompt or "").strip() + if not instruction and req.theme: + instruction = THEMES.get(req.theme, "").strip() + if not instruction: + return JSONResponse( + {"ok": False, "status": "Pick a theme or type a prompt."}, + status_code=400, + ) + + try: + image_path, note = _decode_image_b64(req.image) + result = _run_engine( + image_path, + instruction, + req.mode == "upscale2x" or req.mode.lower().startswith("upscale"), + note, + ) + except ValueError as exc: + return JSONResponse({"ok": False, "status": str(exc)}, status_code=400) + except RuntimeError as exc: + return JSONResponse({"ok": False, "status": str(exc)}, status_code=503) + return JSONResponse(result) + + +api.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +GRADIO_CSS = """ +footer { display: none !important; } +.gradio-container { max-width: 860px !important; } +""" + +with gr.Blocks( + title="PixelLock Gradio Runtime", + theme=gr.themes.Base(primary_hue="purple", neutral_hue="slate"), + css=GRADIO_CSS, +) as gradio_demo: + gr.Markdown( + """ + # PixelLock Gradio Runtime + + The custom PixelLock UI is served at `/`. This mounted Gradio surface is + kept live for Space eligibility and API introspection. It uses the same + backend model, grammar-constrained decoding, and footprint checks. + """ + ) + gr.JSON( + value={ + "custom_ui": "/", + "edit_api": "/api/edit", + "model": MODEL, + "grammar_locked": True, + "thinking_disabled": True, + }, + label="Runtime contract", + ) + + +app = gr.mount_gradio_app(api, gradio_demo, path="/gradio") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..11dd76e04501268578ed314387c11bfcde89fd8f --- /dev/null +++ b/config.py @@ -0,0 +1,169 @@ +"""Static configuration for SpriteBench. + +Holds repo paths, OpenRouter / budget constants, scoring weights, the judge +model, the verified candidate roster, the sampling plan, and the mock model +identifiers. config.py is the single source of truth for the candidate list; +discover_models.py only verifies against it. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +# --- Paths ----------------------------------------------------------------- + +ROOT: Path = Path(__file__).resolve().parent +RUNS: Path = ROOT / "runs" +RESULTS: Path = ROOT / "results" +LOGS: Path = ROOT / "logs" + +# --- OpenRouter / budget --------------------------------------------------- + +OPENROUTER_BASE = "https://openrouter.ai/api/v1" +BUDGET_USD = 25.0 # hard pause threshold; override via env SPRITEBENCH_BUDGET +MAX_PALETTE = 12 +GEN_MAX_TOKENS = 8192 +# Hidden thinking is disabled for ALL candidate generation calls (fairness: +# same setting every model, every round). Smoke testing showed hybrid +# reasoners (qwen3.6-27b) burn the entire max_tokens budget on reasoning and +# return empty text; the target app is interactive, so thinking-off is also +# the production configuration. None = provider default (judge keeps None). +CANDIDATE_REASONING: bool | None = False + +# --- Judge ----------------------------------------------------------------- + +JUDGE_MODEL = "google/gemini-3.1-pro-preview" # frontier vision, NOT a candidate +JUDGE_TEMP = 0.0 +# Mean abs per-dim delta on duplicate judge pairs; above this -> run a 2nd +# judge pass and average. +CONSISTENCY_THRESHOLD = 1.5 + +# --- Scoring weights ------------------------------------------------------- + +WEIGHTS = {"validity": 0.30, "visual": 0.50, "repair": 0.10, "costlat": 0.10} + + +@dataclass(frozen=True) +class Candidate: + """A model under test.""" + + id: str # exact OpenRouter id, or a local alias like "local/gemma-4-12b" + params_b: float # total params, billions (must be <= 32) + vision: bool # can accept images (tier 3 eligible) + enabled: bool + notes: str = "" + # Custom OpenAI-compatible endpoint. None -> OpenRouter. When set, the + # client sends requests there (no real API key, cost recorded as $0, one + # request at a time) and omits OpenRouter-only payload fields. + base_url: str | None = None + api_model: str | None = None # model name for the payload; defaults to id + + +# Verified against live OpenRouter API 2026-06-09. All three confirmed +# available, modality "text+image+video->text", ctx 262144: +# +# | id | params_b | vision | pricing in/out per M | notes | +# |---------------------------------|----------|--------|----------------------|----------------------------------------| +# | qwen/qwen3.6-27b | 27 | True | $0.289 / $2.40 | dense, prime suspect | +# | google/gemma-4-31b-it:free | 31 | True | $0 / $0 | free tier - expect 429s, backoff handles | +# | google/gemma-4-26b-a4b-it:free | 26 | True | $0 / $0 | MoE A4B, free tier | +# +# These three ONLY -- per user instruction (2026-06-09) no other candidates +# are added, even ones named in the mission. +# +# MiniCPM: NOT available on OpenRouter at all (verified live 2026-06-09). The +# mission's sponsor-prize MiniCPM candidate could therefore not be included; +# the final report must note this substitution/exclusion. The roster above is +# user-locked: do not add Nemotron, Mistral Small, tiny <=4B models, or any +# other candidate named in the mission -- the user explicitly froze the list +# to these three on 2026-06-09. +CANDIDATES: list[Candidate] = [ + Candidate( + id="qwen/qwen3.6-27b", + params_b=27, + vision=True, + enabled=True, + notes="dense, prime suspect", + ), + Candidate( + id="google/gemma-4-31b-it:free", + params_b=31, + vision=True, + enabled=True, + notes="free tier - expect 429s, backoff handles", + ), + Candidate( + id="google/gemma-4-26b-a4b-it:free", + params_b=26, + vision=True, + enabled=True, + notes="MoE A4B, free tier", + ), + # User's own local server (added 2026-06-09 at user request): llama.cpp- + # style OpenAI-compatible endpoint, 64K ctx, ~49 tok/s, all layers on GPU. + # Text-only as served (no images) -> vision=False, sits out Tier-3 repair. + # The model emits hidden reasoning that cannot be disabled via the API; + # the answer arrives in message.content so parsing is unaffected, and the + # latency metric honestly reflects the thinking time. + Candidate( + id="local/gemma-4-12b", + params_b=12, + vision=False, + enabled=True, + notes="user's local server; reasoning baked in; cost $0", + base_url="http://localhost:8080/v1", + api_model="gemma-4-12b", + ), +] + +# Sampling plan: 3 samples at temp 0.7, 1 at temp 0.2; fixed seeds for +# reproducibility where the API honors them. +SAMPLES: list[dict] = [ + {"sample": 0, "temp": 0.7, "seed": 11}, + {"sample": 1, "temp": 0.7, "seed": 22}, + {"sample": 2, "temp": 0.7, "seed": 33}, + {"sample": 3, "temp": 0.2, "seed": 44}, +] + +# Mock model identifiers used by MockClient for offline testing; all vision=True. +MOCK_MODELS = ["mock/good-model", "mock/flaky-model", "mock/bad-model"] + + +def enabled_candidates(mock: bool = False) -> list[Candidate]: + """Return the candidate roster to run. + + When mock=True, returns the three MOCK_MODELS wrapped as Candidate objects + (params_b=1, vision=True, enabled=True). Otherwise returns the enabled + real candidates from CANDIDATES. + """ + if mock: + return [ + Candidate(id=model_id, params_b=1, vision=True, enabled=True) + for model_id in MOCK_MODELS + ] + return [c for c in CANDIDATES if c.enabled] + + +def endpoint_for(model_id: str) -> tuple[str, str, bool]: + """Resolve (base_url, payload_model_name, is_openrouter) for a model id. + + Candidates with a custom base_url route there; everything else (including + the judge model) goes to OpenRouter. + """ + for c in CANDIDATES: + if c.id == model_id and c.base_url: + return c.base_url, c.api_model or c.id, False + return OPENROUTER_BASE, model_id, True + + +def budget_usd() -> float: + """Effective budget: env SPRITEBENCH_BUDGET if set and numeric, else BUDGET_USD.""" + raw = os.environ.get("SPRITEBENCH_BUDGET") + if raw is not None: + try: + return float(raw) + except ValueError: + pass + return BUDGET_USD diff --git a/pixel_editor.py b/pixel_editor.py new file mode 100644 index 0000000000000000000000000000000000000000..0f35d990afd7d8ba5c1ce6ff72d6d87d65c8952c --- /dev/null +++ b/pixel_editor.py @@ -0,0 +1,262 @@ +"""Prototype of the actual app engine: pixel-perfect editing of real PNG +textures via grammar-constrained decoding on the local Gemma server. + +Pipeline: PNG -> token-stable wire format (palette quantized to <=26 keys, +alpha thresholded, grid cells separated by spaces) -> per-file GBNF grammar +locking the footprint (exact or 2x upscale) -> local llama.cpp generation -> +parse -> true-alpha PNG out + checkerboard preview. + +Supports non-square textures (Minecraft mob atlases are 64x32 etc.). + +Usage: + python pixel_editor.py "" [--upscale] [--out DIR] +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +import time +from collections import Counter +from pathlib import Path + +import httpx +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import config +import render +import validate + +# The app allows richer palettes than the benchmark's 12-entry cap. +# validate.py defines its own module-level constant, so patch it there. +config.MAX_PALETTE = 64 +validate.MAX_PALETTE = 64 + +URL = "http://localhost:8080/v1/chat/completions" +KEY_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" # up to 26 opaque colors +MAX_COLORS = len(KEY_ALPHABET) + +APP_SYSTEM = """You are an expert pixel artist editing real game textures \ +(Minecraft resource pack style). You receive a texture as a palette-indexed \ +character grid and an instruction. Recolor and restyle it with conviction: \ +full color ramps (dark, mid, light per material), hue-shifted shading (shadows \ +toward purple/blue, highlights warm), consistent upper-left light, no flat \ +single-color fills, no lazy tint shifts. The pixel layout is locked; you choose \ +only the colors. Output the PALETTE block then the GRID block in the exact \ +format of the input. Grid rows use one space between single-character cells; \ +preserve that row format exactly. Write nothing else.""" + + +def _format_rows(rows: list[str], spaced: bool) -> list[str]: + if not spaced: + return list(rows) + return [" ".join(row) for row in rows] + + +def sprite_to_wire(sprite: validate.Sprite, spaced: bool = False) -> str: + lines = ["PALETTE"] + for key, rgb in sprite.palette.items(): + if rgb is None: + lines.append("{} transparent".format(key)) + else: + lines.append("{} {},{},{}".format(key, *rgb)) + lines.append("GRID {}x{}".format(sprite.width, sprite.height)) + lines.extend(_format_rows(sprite.rows, spaced)) + return "\n".join(lines) + + +def png_to_wire(path: Path, spaced: bool = False) -> tuple[str, int, int]: + """Convert a PNG to wire format. Returns (wire_text, width, height).""" + im = Image.open(path).convert("RGBA") + w, h = im.size + pixels = list(im.get_flattened_data() if hasattr(im, "get_flattened_data") else im.getdata()) + opaque = [(r, g, b) for r, g, b, a in pixels if a >= 128] + counts = Counter(opaque) + if len(counts) > MAX_COLORS: + # Quantize opaque colors down to MAX_COLORS with Pillow's median cut. + tmp = Image.new("RGB", (len(opaque), 1)) + tmp.putdata(opaque) + quant = tmp.quantize(colors=MAX_COLORS) + qpal = quant.getpalette()[: MAX_COLORS * 3] + centers = [tuple(qpal[i * 3 : i * 3 + 3]) for i in range(MAX_COLORS)] + + def nearest(c: tuple[int, int, int]) -> tuple[int, int, int]: + return min(centers, key=lambda k: sum((a - b) ** 2 for a, b in zip(k, c))) + + mapping = {c: nearest(c) for c in counts} + counts = Counter(mapping[c] for c in opaque) + else: + mapping = {c: c for c in counts} + + keys: dict[tuple[int, int, int], str] = {} + for i, (color, _n) in enumerate(counts.most_common()): + keys[color] = KEY_ALPHABET[i] + + rows = [] + idx = 0 + for y in range(h): + row = [] + for x in range(w): + r, g, b, a = pixels[idx] + idx += 1 + row.append("." if a < 128 else keys[mapping[(r, g, b)]]) + rows.append("".join(row)) + + palette: dict[str, tuple[int, int, int] | None] = {".": None} + for color, key in sorted(keys.items(), key=lambda kv: kv[1]): + palette[key] = color + sprite = validate.Sprite(palette=palette, width=w, height=h, rows=rows) + return sprite_to_wire(sprite, spaced=spaced), w, h + + +def wire_to_png(sprite: validate.Sprite, out_path: Path) -> None: + """Write the true-alpha 1:1 PNG (the actual resource-pack artifact).""" + im = Image.new("RGBA", (sprite.width, sprite.height)) + data = [] + for row in sprite.rows: + for ch in row: + rgb = sprite.palette[ch] + data.append((0, 0, 0, 0) if rgb is None else (*rgb, 255)) + im.putdata(data) + out_path.parent.mkdir(parents=True, exist_ok=True) + im.save(out_path) + + +def _row_grammar(row: str, spaced: bool) -> str: + if spaced: + parts: list[str] = [] + for i, ch in enumerate(row): + parts.append('"."' if ch == "." else "ckey") + if i != len(row) - 1: + parts.append('" "') + return " ".join(parts) + + parts = ['"."' if ch == "." else "ckey" for ch in row] + # Collapse consecutive '.' literals into one string literal for a smaller + # grammar (matters at 128x128). + merged: list[str] = [] + dots = 0 + for part in parts: + if part == '"."': + dots += 1 + else: + if dots: + merged.append('"{}"'.format("." * dots)) + dots = 0 + merged.append(part) + if dots: + merged.append('"{}"'.format("." * dots)) + return " ".join(merged) + + +def build_grammar( + rows: list[str], n_keys: int, upscale: bool, spaced: bool = False +) -> str: + """Footprint-locked GBNF. '.' cells are literal; colored cells sample ckey.""" + if upscale: + rows = ["".join(ch * 2 for ch in r) for r in rows for _ in (0, 1)] + w, h = len(rows[0]), len(rows) + key_class = KEY_ALPHABET[:n_keys] + palette_part = "".join( + '"{}" " " rgb "\\n" '.format(k) for k in key_class + ) + row_refs = " ".join("r{}".format(y) for y in range(h)) + lines = [ + 'root ::= "PALETTE\\n. transparent\\n" {} "GRID {}x{}\\n" {}'.format( + palette_part, w, h, row_refs + ), + 'rgb ::= num "," num "," num', + 'num ::= ("25" [0-5]) | ("2" [0-4] [0-9]) | ("1" [0-9] [0-9]) | ([1-9] [0-9]) | [0-9]', + "ckey ::= [{}]".format(key_class), + ] + for y, row in enumerate(rows): + lines.append('r{} ::= {} "\\n"'.format(y, _row_grammar(row, spaced))) + return "\n".join(lines) + + +async def edit_file(path: Path, instruction: str, upscale: bool, out_dir: Path) -> dict: + wire, w, h = png_to_wire(path, spaced=True) + in_sprite, perr = validate.parse_sprite(wire) + assert in_sprite is not None, perr + n_keys = len([k for k in in_sprite.palette if k != "."]) + grammar = build_grammar(in_sprite.rows, n_keys, upscale, spaced=True) + + if upscale: + contract = ( + "Redraw this texture at {}x{} (2x). Every input pixel becomes a 2x2 " + "block: transparent stays transparent, colored stays colored. Add " + "finer shading and detail within that constraint." + ).format(w * 2, h * 2) + else: + contract = ( + "Edit this texture. The grid stays {}x{} and every transparent cell " + "stays transparent; change only the colors of non-transparent cells." + ).format(w, h) + + user_msg = "{}\n\nInstruction: {}\n\nHere is the input texture:\n{}".format( + contract, instruction, wire + ) + out_cells = (w * 2) * (h * 2) if upscale else w * h + max_tokens = min(int(out_cells * 1.6) + 800, 40000) + + payload = { + "model": "gemma-4-12b", + "messages": [ + {"role": "system", "content": APP_SYSTEM}, + {"role": "user", "content": user_msg}, + ], + "max_tokens": max_tokens, + "temperature": 0.7, + "chat_template_kwargs": {"enable_thinking": False}, + "grammar": grammar, + } + started = time.perf_counter() + async with httpx.AsyncClient(timeout=1800.0) as http: + resp = await http.post(URL, json=payload) + latency = time.perf_counter() - started + resp.raise_for_status() + data = resp.json() + text = data["choices"][0]["message"]["content"] or "" + finish = data["choices"][0].get("finish_reason") + sprite, perr = validate.parse_sprite(text) + + result = { + "file": path.name, + "mode": "upscale2x" if upscale else "exact", + "latency_s": round(latency, 1), + "completion_tokens": data.get("usage", {}).get("completion_tokens"), + "finish_reason": finish, + "parsed": sprite is not None, + "error": perr, + } + if sprite is not None: + stem = "{}__{}".format(path.stem, "2x" if upscale else "edit") + wire_to_png(sprite, out_dir / (stem + ".png")) + scale = max(2, 512 // max(sprite.width, sprite.height)) + render.save_render(sprite, out_dir / (stem + "_preview.png"), scale=scale) + # Footprint check (the pixel-perfect guarantee, verified not assumed) + in_fp = {(x, y) for y, r in enumerate(in_sprite.rows) for x, ch in enumerate(r) if ch != "."} + if upscale: + in_fp = {(2 * x + dx, 2 * y + dy) for x, y in in_fp for dx in (0, 1) for dy in (0, 1)} + out_fp = {(x, y) for y, r in enumerate(sprite.rows) for x, ch in enumerate(r) if ch != "."} + result["footprint_perfect"] = in_fp == out_fp + return result + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("input") + ap.add_argument("instruction") + ap.add_argument("--upscale", action="store_true") + ap.add_argument("--out", default=str(Path(__file__).parent / "edited")) + args = ap.parse_args() + result = await edit_file(Path(args.input), args.instruction, args.upscale, Path(args.out)) + for k, v in result.items(): + print("{}: {}".format(k, v)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/render.py b/render.py new file mode 100644 index 0000000000000000000000000000000000000000..025ed390b0f4e4d1d1aa3bd63131fcf5156ca9ec --- /dev/null +++ b/render.py @@ -0,0 +1,356 @@ +"""Rendering for SpriteBench. + +Turns parsed sprites into PNG images for human review and for the vision judge. +Pure Pillow: nearest-neighbor upscaling, a neutral checkerboard behind +transparent cells, failure placeholder tiles, and labeled contact sheets. No +font files are used anywhere; everything relies on Pillow's built-in default +font so the module has no external asset dependencies. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from PIL import Image, ImageDraw, ImageFont + +if TYPE_CHECKING: # pragma: no cover - import only for type checking + from validate import Sprite + +# Neutral checkerboard colors shown behind transparent sprite cells. +CHECKER_LIGHT = (200, 200, 200) +CHECKER_DARK = (160, 160, 160) + +# Cosmetic constants for the auxiliary tiles / sheets. +_FAILURE_BG = (90, 20, 20) # dark red +_FAILURE_FG = (235, 235, 235) # near-white text +_MISSING_BG = (110, 110, 110) # gray "missing" tile +_MISSING_FG = (210, 210, 210) +_SHEET_BG = (245, 245, 245) +_SHEET_FG = (20, 20, 20) +_SHEET_GRID = (180, 180, 180) + + +def _default_font() -> ImageFont.ImageFont: + """Pillow's built-in bitmap font (no external font file required).""" + return ImageFont.load_default() + + +def _text_size( + draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont +) -> tuple[int, int]: + """Return (width, height) of a single line of text for the given font.""" + left, top, right, bottom = draw.textbbox((0, 0), text, font=font) + return right - left, bottom - top + + +def render_sprite(sprite: "Sprite", scale: int = 16) -> Image.Image: + """Render a sprite at integer nearest-neighbor scale. + + Each sprite pixel becomes a ``scale`` x ``scale`` block. Opaque cells are + filled with their palette RGB; transparent cells (palette value ``None``) + reveal a neutral checkerboard whose squares are exactly ``scale`` px and are + aligned to the sprite pixel grid (so each transparent pixel shows a single + flat checker square). + + Returns an RGB image of size ``(width * scale, height * scale)``. + """ + if scale < 1: + raise ValueError("scale must be >= 1") + + width = sprite.width + height = sprite.height + out_w = width * scale + out_h = height * scale + + image = Image.new("RGB", (out_w, out_h)) + pixels = image.load() + + palette = sprite.palette + rows = sprite.rows + + for gy in range(height): + row = rows[gy] + base_y = gy * scale + for gx in range(width): + key = row[gx] + color = palette.get(key) + if color is None: + # Transparent: one flat checker square per sprite pixel. + square = CHECKER_LIGHT if (gx + gy) % 2 == 0 else CHECKER_DARK + else: + square = (int(color[0]), int(color[1]), int(color[2])) + base_x = gx * scale + for dy in range(scale): + py = base_y + dy + for dx in range(scale): + pixels[base_x + dx, py] = square + + return image + + +def save_render(sprite: "Sprite", path: Path, scale: int = 16) -> None: + """Render ``sprite`` and write it as a PNG to ``path`` (creates parents).""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + image = render_sprite(sprite, scale=scale) + image.save(path, format="PNG") + + +def _wrap_text( + draw: ImageDraw.ImageDraw, + text: str, + font: ImageFont.ImageFont, + max_width: int, +) -> list[str]: + """Greedily wrap ``text`` into lines no wider than ``max_width`` pixels. + + Words longer than ``max_width`` are split character by character so text + never overflows the tile horizontally. + """ + lines: list[str] = [] + for paragraph in text.splitlines() or [""]: + words = paragraph.split(" ") + current = "" + for word in words: + candidate = word if not current else current + " " + word + cand_w, _ = _text_size(draw, candidate, font) + if cand_w <= max_width or not current: + # Fits, or it is the first token on the line. + if not current: + # A lone token may still be too wide; break it by char. + token_w, _ = _text_size(draw, word, font) + if token_w > max_width and len(word) > 1: + for piece in _break_long_word(draw, word, font, max_width): + lines.append(piece) + current = "" + continue + current = candidate + else: + lines.append(current) + current = word + lines.append(current) + return lines + + +def _break_long_word( + draw: ImageDraw.ImageDraw, + word: str, + font: ImageFont.ImageFont, + max_width: int, +) -> list[str]: + """Split a single overlong word into pixel-width-bounded chunks.""" + pieces: list[str] = [] + current = "" + for ch in word: + candidate = current + ch + cand_w, _ = _text_size(draw, candidate, font) + if cand_w <= max_width or not current: + current = candidate + else: + pieces.append(current) + current = ch + if current: + pieces.append(current) + return pieces + + +def failure_tile(reason: str, px: int = 512) -> Image.Image: + """A dark-red square tile with the wrapped failure ``reason`` text. + + Used in contact sheets in place of a render when a sample failed Tier 1. + Uses Pillow's default font; text is wrapped to fit the tile width and + vertically centered. Long reasons are truncated with an ellipsis marker. + """ + if px < 1: + raise ValueError("px must be >= 1") + + image = Image.new("RGB", (px, px), _FAILURE_BG) + draw = ImageDraw.Draw(image) + font = _default_font() + + margin = max(4, px // 24) + max_text_w = px - 2 * margin + text = reason if reason else "unknown failure" + + lines = _wrap_text(draw, text, font, max_text_w) + + # Determine line height from a representative glyph. + _, line_h = _text_size(draw, "Ag", font) + line_h = max(line_h, 1) + spacing = max(2, line_h // 4) + step = line_h + spacing + + max_lines = max(1, (px - 2 * margin) // step) + if len(lines) > max_lines: + lines = lines[:max_lines] + if lines: + lines[-1] = _ellipsize(draw, lines[-1], font, max_text_w) + + total_h = len(lines) * step - spacing + y = max(margin, (px - total_h) // 2) + for line in lines: + line_w, _ = _text_size(draw, line, font) + x = max(margin, (px - line_w) // 2) + draw.text((x, y), line, fill=_FAILURE_FG, font=font) + y += step + + return image + + +def _ellipsize( + draw: ImageDraw.ImageDraw, + line: str, + font: ImageFont.ImageFont, + max_width: int, +) -> str: + """Trim ``line`` so that ``line + '...'`` fits within ``max_width`` pixels.""" + suffix = "..." + suffix_w, _ = _text_size(draw, suffix, font) + if suffix_w >= max_width: + return suffix + trimmed = line + while trimmed: + cand_w, _ = _text_size(draw, trimmed + suffix, font) + if cand_w <= max_width: + return trimmed + suffix + trimmed = trimmed[:-1] + return suffix + + +def _missing_tile(px: int) -> Image.Image: + """A gray placeholder tile for an absent (None) contact-sheet cell.""" + image = Image.new("RGB", (px, px), _MISSING_BG) + draw = ImageDraw.Draw(image) + font = _default_font() + label = "missing" + label_w, label_h = _text_size(draw, label, font) + draw.text( + ((px - label_w) // 2, (px - label_h) // 2), + label, + fill=_MISSING_FG, + font=font, + ) + return image + + +def _fit_tile(image: Image.Image, tile_px: int) -> Image.Image: + """Resize ``image`` to a ``tile_px`` square using NEAREST (crisp pixels).""" + rgb = image if image.mode == "RGB" else image.convert("RGB") + if rgb.size == (tile_px, tile_px): + return rgb + return rgb.resize((tile_px, tile_px), resample=Image.Resampling.NEAREST) + + +def contact_sheet( + cells: list[list[Image.Image | None]], + row_labels: list[str], + col_labels: list[str], + out_path: Path, + tile_px: int = 256, +) -> None: + """Compose a labeled grid of tiles and write it as a PNG. + + ``cells`` is row-major: ``cells[r][c]`` is the image for row ``r``, column + ``c`` (or ``None`` to draw a gray "missing" tile). Each image is resized to + a ``tile_px`` square with NEAREST resampling so pixel art stays crisp. + Column labels run along the top margin; row labels down the left margin. + Labels use Pillow's default font. + + Robust to ragged ``cells`` (short rows are padded with missing tiles) and to + label lists that are shorter or longer than the grid. + """ + if tile_px < 1: + raise ValueError("tile_px must be >= 1") + + n_rows = max(len(cells), len(row_labels)) + n_cols = max( + (len(row) for row in cells), + default=0, + ) + n_cols = max(n_cols, len(col_labels)) + n_rows = max(n_rows, 1) + n_cols = max(n_cols, 1) + + # Probe font metrics on a scratch image. + font = _default_font() + scratch = Image.new("RGB", (1, 1)) + sdraw = ImageDraw.Draw(scratch) + _, glyph_h = _text_size(sdraw, "Ag", font) + glyph_h = max(glyph_h, 1) + + pad = max(4, tile_px // 32) + + # Left margin: wide enough for the longest row label. + longest_row_label_w = 0 + for r in range(n_rows): + label = row_labels[r] if r < len(row_labels) else "" + if label: + lw, _ = _text_size(sdraw, label, font) + longest_row_label_w = max(longest_row_label_w, lw) + left_margin = longest_row_label_w + 2 * pad if longest_row_label_w else pad + # Top margin: room for a column label line. + top_margin = glyph_h + 2 * pad + + sheet_w = left_margin + n_cols * tile_px + sheet_h = top_margin + n_rows * tile_px + + sheet = Image.new("RGB", (sheet_w, sheet_h), _SHEET_BG) + draw = ImageDraw.Draw(sheet) + + # Column labels across the top margin, centered over each column. + for c in range(n_cols): + label = col_labels[c] if c < len(col_labels) else "" + if not label: + continue + fitted = _ellipsize_for(draw, label, font, tile_px - 2 * pad) + lw, lh = _text_size(draw, fitted, font) + cx = left_margin + c * tile_px + (tile_px - lw) // 2 + cy = max(pad, (top_margin - lh) // 2) + draw.text((cx, cy), fitted, fill=_SHEET_FG, font=font) + + # Row labels down the left margin, vertically centered per row. + for r in range(n_rows): + label = row_labels[r] if r < len(row_labels) else "" + if not label: + continue + fitted = _ellipsize_for(draw, label, font, max(left_margin - 2 * pad, 1)) + lw, lh = _text_size(draw, fitted, font) + rx = max(pad, (left_margin - lw) // 2) + ry = top_margin + r * tile_px + (tile_px - lh) // 2 + draw.text((rx, ry), fitted, fill=_SHEET_FG, font=font) + + # Tiles. + for r in range(n_rows): + row = cells[r] if r < len(cells) else [] + for c in range(n_cols): + cell = row[c] if c < len(row) else None + tile = _missing_tile(tile_px) if cell is None else _fit_tile(cell, tile_px) + x = left_margin + c * tile_px + y = top_margin + r * tile_px + sheet.paste(tile, (x, y)) + + # Light grid lines separating tiles. + for c in range(n_cols + 1): + x = left_margin + c * tile_px + draw.line([(x, top_margin), (x, sheet_h - 1)], fill=_SHEET_GRID, width=1) + for r in range(n_rows + 1): + y = top_margin + r * tile_px + draw.line([(left_margin, y), (sheet_w - 1, y)], fill=_SHEET_GRID, width=1) + + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + sheet.save(out_path, format="PNG") + + +def _ellipsize_for( + draw: ImageDraw.ImageDraw, + label: str, + font: ImageFont.ImageFont, + max_width: int, +) -> str: + """Return ``label`` unchanged if it fits, else an ellipsized version.""" + lw, _ = _text_size(draw, label, font) + if lw <= max_width: + return label + return _ellipsize(draw, label, font, max_width) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..bf228be03f21d586c3f71331bf590214e673b042 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +gradio>=5,<6 +fastapi +uvicorn +pydantic +httpx +pillow +huggingface_hub diff --git a/start.sh b/start.sh new file mode 100644 index 0000000000000000000000000000000000000000..76f7993129437c10337051514924f74d48bdbdc7 --- /dev/null +++ b/start.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Start the FastAPI UI immediately (binds :7860 so the HF health check passes at +# once) and warm the model up in the background. On a cold start the frontend +# status pill reads "connecting…" until llama-server answers /v1/models β€” that's +# the natural "model waking up" state. +set -uo pipefail + +MODEL_PATH="${MODEL_PATH:-/home/user/app/model.gguf}" + +# The base image ships llama-server in /app alongside its shared libs +# (libllama-server-impl.so, libggml*.so). Its RPATH is relative to the binary, so +# we MUST run the real /app path (not a symlink) and point the loader at /app. +export LD_LIBRARY_PATH="/app:${LD_LIBRARY_PATH:-}" +LLAMA_BIN="" +for p in /app/llama-server /llama-server /usr/local/bin/llama-server /usr/bin/llama-server; do + [ -x "$p" ] && LLAMA_BIN="$p" && break +done +[ -z "$LLAMA_BIN" ] && LLAMA_BIN="$(command -v llama-server || true)" +echo "[start] using llama-server at: ${LLAMA_BIN:-NOT FOUND} (LD_LIBRARY_PATH=$LD_LIBRARY_PATH)" + +serve() { # $1 = context window + echo "[start] llama-server -c $1 loading ${MODEL_PATH}" + "$LLAMA_BIN" -m "$MODEL_PATH" --host 127.0.0.1 --port 8080 \ + --alias "${PIXELLOCK_MODEL:-pixellock}" \ + -ngl 999 -np 1 -b 2048 -ub 512 -t 4 --jinja -c "$1" +} + +# f16 KV cache, ample context, no exotic flags β€” the most robust cold start on a +# 16GB T4. The GBNF grammar (not flash-attn or KV quant) is what guarantees +# pixel-perfect output, so plain f16 is the safe, high-quality choice. If 12288 +# can't fit, fall back to a smaller window automatically. +( serve 12288 || serve 8192 ) 2>&1 & + +echo "[start] Gradio UI on :${PORT:-7860} (model warming in background)" +exec python3 app.py diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000000000000000000000000000000000000..db366f44645cff365fd52056dd223c2d192c9169 --- /dev/null +++ b/static/app.js @@ -0,0 +1,514 @@ +/* ============================================================ + PixelLock custom frontend + - drop/upload a PNG + - theme chips -> prompt + - retheme / upscale toggle + - POST /api/edit { image(base64), theme, prompt, mode } + - GET /api/examples -> before/after gallery + ============================================================ */ +"use strict"; + +/* Theme presets. `key` mirrors the THEMES dict keys in app/app.py + (emoji-prefixed) so the backend's THEMES.get(theme_key) resolves the + instruction even if the prompt field is blank. `swatch` is purely visual. */ +const THEMES = [ + { key: "πŸŒ‹ Molten lava", label: "Lava", swatch: "#ff5a1f", + prompt: "a molten lava theme β€” glowing magma core, charred black edges, white-hot highlights, bold orange-red ramp" }, + { key: "❄️ Frozen ice", label: "Ice", swatch: "#8fe6ff", + prompt: "a frozen ice theme β€” pale cyan, frosted surface, white frost highlights, cool blue shadows" }, + { key: "πŸͺ™ Solid gold", label: "Gold", swatch: "#ffcf45", + prompt: "a solid gold royal theme β€” shimmering gold ramp, warm highlights, deep amber shadows" }, + { key: "☒️ Toxic", label: "Toxic", swatch: "#7dff3a", + prompt: "a toxic radioactive theme β€” sickly neon green, glowing hazard spots, dark slime shadows" }, + { key: "πŸ‚ Autumn dusk", label: "Autumn", swatch: "#e0673a", + prompt: "an autumn dusk theme β€” warm amber and crimson with deep purple shadows" }, + { key: "🌌 Cosmic galaxy", label: "Cosmic", swatch: "#a06bff", + prompt: "a cosmic galaxy theme β€” deep space-purple with tiny star speckles and glowing cyan accents" }, + { key: "πŸ–€ Dark emo", label: "Emo", swatch: "#ff3ad1", + prompt: "a dark emo theme β€” near-black base with glowing magenta and purple accents, moody" }, + { key: "🌊 Deep ocean", label: "Ocean", swatch: "#1fd4c4", + prompt: "a deep ocean theme β€” teal and aqua, blue-green ramp, soft glow" }, +]; + +const $ = (id) => document.getElementById(id); + +const els = { + dropzone: $("dropzone"), + file: $("file"), + dzEmpty: $("dz-empty"), + dzLoaded: $("dz-loaded"), + dzPreview: $("dz-preview"), + dzName: $("dz-name"), + dzClear: $("dz-clear"), + chips: $("chips"), + assetStrip:$("asset-strip"), + prompt: $("prompt"), + toggle: $("mode-toggle"), + glider: $("toggle-glider"), + run: $("run"), + runHint: $("run-hint"), + imgIn: $("img-in"), + imgOut: $("img-out"), + badge: $("badge"), + status: $("status"), + download: $("download"), + loader: $("frame-loader"), + loaderText:$("loader-text"), + frameEmpty:$("frame-empty"), + gallery: $("gallery"), + serverDot: $("server-dot"), + serverLbl: $("server-label"), + silCanvas: $("sil-canvas"), + wireOut: $("wire-out"), +}; + +/* draw the input's preserved silhouette (opaque pixels -> pale outline) into + the SILHOUETTE LOCKED panel β€” the visual proof the shape never changes. */ +function renderSilhouette(src) { + const c = els.silCanvas; + if (!c || !src) return; + const img = new Image(); + img.onload = () => { + const w = img.naturalWidth, h = img.naturalHeight; + c.width = w; c.height = h; + const ctx = c.getContext("2d"); + ctx.clearRect(0, 0, w, h); + ctx.drawImage(img, 0, 0); + const d = ctx.getImageData(0, 0, w, h), px = d.data; + for (let i = 0; i < px.length; i += 4) { + if (px[i + 3] >= 128) { px[i] = 206; px[i + 1] = 232; px[i + 2] = 255; px[i + 3] = 255; } + else { px[i + 3] = 0; } + } + ctx.putImageData(d, 0, 0); + }; + img.src = src; +} +function clearSilhouette() { + const c = els.silCanvas; if (!c) return; + const ctx = c.getContext("2d"); ctx && ctx.clearRect(0, 0, c.width, c.height); +} + +const state = { + imageDataUrl: null, // base64 data URL of the loaded sprite + fileName: null, + theme: null, // optional β€” null = use the prompt only + mode: "exact", + busy: false, +}; + +/* ------------------------------------------------------------ chips */ +function buildChips() { + THEMES.forEach((t) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "chip"; + b.dataset.key = t.key; + b.setAttribute("role", "option"); + b.innerHTML = `${t.label}`; + b.addEventListener("click", () => selectTheme(t)); + els.chips.appendChild(b); + }); +} + +function selectTheme(t) { + if (state.theme === t.key) { // re-click deselects β€” theme is optional + state.theme = null; + [...els.chips.children].forEach((c) => c.classList.remove("active")); + } else { + state.theme = t.key; + els.prompt.value = t.prompt; // theme is a prompt shortcut + [...els.chips.children].forEach((c) => c.classList.toggle("active", c.dataset.key === t.key)); + } + els.prompt.dispatchEvent(new Event("input")); +} + +/* ------------------------------------------------------------ file loading */ +function loadFile(file) { + if (!file) return; + if (!/image\//.test(file.type) && !/\.png$/i.test(file.name)) { + setHint("please choose a PNG / image file", true); + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + state.imageDataUrl = e.target.result; + state.fileName = file.name; + els.dzPreview.src = state.imageDataUrl; + els.dzName.textContent = file.name; + els.dzEmpty.hidden = true; + els.dzLoaded.hidden = false; + // mirror into the INPUT compare frame + silhouette panel immediately + els.imgIn.src = state.imageDataUrl; + renderSilhouette(state.imageDataUrl); + refreshRun(); + }; + reader.readAsDataURL(file); +} + +function clearFile() { + state.imageDataUrl = null; + state.fileName = null; + els.file.value = ""; + els.dzEmpty.hidden = false; + els.dzLoaded.hidden = true; + els.imgIn.removeAttribute("src"); + clearSilhouette(); + refreshRun(); +} + +/* ------------------------------------------------------------ mode toggle */ +function setMode(mode, btn) { + state.mode = mode; + [...els.toggle.querySelectorAll(".toggle-opt")].forEach((o) => { + const on = o === btn; + o.classList.toggle("active", on); + o.setAttribute("aria-selected", String(on)); + }); + els.glider.classList.toggle("right", mode === "upscale2x"); + els.run.querySelector(".run-label").textContent = + mode === "upscale2x" ? "β–Ά UPSCALE 2Γ—" : "β–Ά RUN RETEXTURE"; +} + +/* ------------------------------------------------------------ run state */ +function refreshRun() { + const hasInstruction = !!state.theme || els.prompt.value.trim().length > 0; + const ready = !!state.imageDataUrl && hasInstruction && !state.busy; + els.run.disabled = !ready; + if (!state.imageDataUrl) setHint("load a sprite to begin"); + else if (!hasInstruction) setHint("pick a theme or type a prompt"); + else if (!state.busy) setHint("ready Β· footprint stays locked"); +} + +function setHint(text, isError = false) { + els.runHint.textContent = text; + els.runHint.classList.toggle("error", isError); +} + +function setBusy(on) { + state.busy = on; + els.run.classList.toggle("busy", on); + els.run.disabled = on || !state.imageDataUrl; + els.loader.hidden = !on; + if (on) { + els.frameEmpty.hidden = true; + cycleLoaderText(); + } +} + +let loaderTimer = null; +function cycleLoaderText() { + const msgs = ["reading pixels…", "building footprint grammar…", "calling the model…", "locking the silhouette…", "rendering true-res PNG…"]; + let i = 0; + els.loaderText.textContent = msgs[0]; + clearInterval(loaderTimer); + loaderTimer = setInterval(() => { + i = (i + 1) % msgs.length; + if (state.busy) els.loaderText.textContent = msgs[i]; + else clearInterval(loaderTimer); + }, 2200); +} + +/* ------------------------------------------------------------ RUN -> /api/edit */ +async function runEdit() { + if (!state.imageDataUrl || state.busy) return; + setBusy(true); + setHint("working…"); + els.status.textContent = "running…"; + els.status.classList.remove("error"); + els.badge.hidden = true; + els.download.hidden = true; + + const payload = { + image: state.imageDataUrl, // data URL; backend strips the prefix + theme: state.theme, // resolves THEMES.get(theme_key) + prompt: els.prompt.value.trim(), + mode: state.mode, // "exact" | "upscale2x" + }; + + try { + const res = await fetch("/api/edit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const detail = await safeError(res); + throw new Error(detail || `HTTP ${res.status}`); + } + const data = await res.json(); + renderResult(data); + } catch (err) { + els.status.textContent = "βœ— " + (err.message || "edit failed"); + els.status.classList.add("error"); + setHint("try again", true); + } finally { + clearInterval(loaderTimer); + setBusy(false); + refreshRun(); + } +} + +async function safeError(res) { + try { + const j = await res.json(); + return j.error || j.detail || j.message || j.status || ""; + } catch { return ""; } +} + +function renderResult(data) { + // Output image: accept several shapes for backend flexibility. + const out = data.image || data.output || data.output_png || data.data_url; + if (out) els.imgOut.src = normalizeDataUrl(out); + if (data.input) els.imgIn.src = normalizeDataUrl(data.input); + if (out) els.frameEmpty.hidden = true; + + // status line + const footOk = (data.footprint_ok ?? data.fp_ok ?? data.footprint_perfect); + const bits = []; + if (data.width && data.height) bits.push(`${data.width}Γ—${data.height}`); + if (data.colors != null) bits.push(`${data.colors} colors`); + if (data.latency != null) bits.push(`${Number(data.latency).toFixed(1)}s`); + els.status.textContent = data.status || (bits.length ? bits.join(" Β· ") : "done"); + els.status.classList.remove("error"); + + // badge + if (footOk !== undefined && footOk !== null) { + els.badge.hidden = false; + if (footOk) { + els.badge.textContent = "βœ“ silhouette preserved"; + els.badge.className = "badge ok"; + } else { + els.badge.textContent = "⚠ silhouette changed"; + els.badge.className = "badge warn"; + } + } + + // download + if (out) { + els.download.href = normalizeDataUrl(out); + els.download.download = (state.fileName || "sprite").replace(/\.png$/i, "") + "_pixellock.png"; + els.download.hidden = false; + } + + // show the model's raw text output (the "it's a language model" reveal) + if (data.wire && els.wireOut) els.wireOut.textContent = data.wire; +} + +function normalizeDataUrl(s) { + if (typeof s !== "string") return s; + if (s.startsWith("data:") || s.startsWith("http") || s.startsWith("/")) return s; + return "data:image/png;base64," + s; // bare base64 +} + +/* ------------------------------------------------------------ gallery -> /api/examples */ +const FALLBACK_EXAMPLES = [ + { id: "lava_trident", title: "Lava Trident", theme: "πŸŒ‹ Molten lava", + prompt: "Molten lava trident: glowing magma prongs with white-hot tips, charred black shaft, ember glow." }, + { id: "cosmic_cat", title: "Cosmic Cat", theme: "🌌 Cosmic galaxy", + prompt: "Cosmic galaxy cat: deep space-purple fur speckled with tiny stars, glowing cyan eyes." }, + { id: "frozen_apple", title: "Frozen Apple", theme: "❄️ Frozen ice", + prompt: "Frozen crystal apple: icy pale blue, frosted surface, glowing white highlight." }, + { id: "golden_rabbit", title: "Golden Rabbit", theme: "πŸͺ™ Solid gold", + prompt: "Golden royal rabbit: shimmering gold fur with a soft cream belly, regal and warm." }, + { id: "autumn_bark", title: "Autumn Bark", theme: "πŸ‚ Autumn dusk", + prompt: "Autumn dusk bark: warm amber and crimson tones with deep purple shadows." }, + { id: "diamond_sword", title: "Diamond Sword", theme: "❄️ Frozen ice", + prompt: "Diamond sword: pale icy-cyan crystal blade with white glints and a glowing aqua edge." }, +]; + +async function loadGallery() { + let examples = null; + try { + const res = await fetch("/api/examples", { method: "GET" }); + if (res.ok) { + const data = await res.json(); + examples = Array.isArray(data) ? data : (data.examples || null); + } + } catch { /* offline / not wired yet */ } + + if (!examples || !examples.length) { + examples = FALLBACK_EXAMPLES; + } + renderGallery(examples); +} + +/* ------------------------------------------------------------ starter sprites */ +async function loadAssets() { + if (!els.assetStrip) return; + try { + const res = await fetch("/api/assets", { method: "GET" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const assets = await res.json(); + renderAssets(Array.isArray(assets) ? assets : []); + } catch { + els.assetStrip.innerHTML = `starter sprites unavailable`; + } +} + +function renderAssets(assets) { + if (!els.assetStrip) return; + els.assetStrip.innerHTML = ""; + if (!assets.length) { + els.assetStrip.innerHTML = `no starter sprites found`; + return; + } + assets.forEach((asset) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "asset-tile"; + btn.innerHTML = ` + + ${escapeHtml(asset.title || asset.id || "sprite")}`; + btn.addEventListener("click", () => { + state.imageDataUrl = normalizeDataUrl(asset.input); + state.fileName = (asset.id || "starter") + ".png"; + els.dzPreview.src = state.imageDataUrl; + els.dzName.textContent = state.fileName; + els.dzEmpty.hidden = true; + els.dzLoaded.hidden = false; + els.imgIn.src = state.imageDataUrl; + renderSilhouette(state.imageDataUrl); + refreshRun(); + }); + els.assetStrip.appendChild(btn); + }); +} + +function renderGallery(examples) { + els.gallery.innerHTML = ""; + if (!examples.length) { + els.gallery.innerHTML = ``; + return; + } + examples.forEach((ex) => { + const inputSrc = ex.input ? normalizeDataUrl(ex.input) : ""; + const outputSrc = ex.output ? normalizeDataUrl(ex.output) : ""; + const card = document.createElement("div"); + card.className = "card"; + card.tabIndex = 0; + card.setAttribute("role", "button"); + card.innerHTML = ` +
+
${inputSrc ? `` : ""}
+
${outputSrc ? `` : ""}
+
+

${escapeHtml(ex.title || ex.id || "example")}

+

${escapeHtml(ex.prompt || "")}

`; + const activate = () => useExample(ex, inputSrc); + card.addEventListener("click", activate); + card.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }); + els.gallery.appendChild(card); + }); +} + +function useExample(ex, inputSrc) { + // load the input sprite into the lab + if (inputSrc) { + state.imageDataUrl = inputSrc; + state.fileName = (ex.id || "example") + ".png"; + els.dzPreview.src = inputSrc; + els.dzName.textContent = state.fileName; + els.dzEmpty.hidden = true; + els.dzLoaded.hidden = false; + els.imgIn.src = inputSrc; + renderSilhouette(inputSrc); + } + // prompt + theme + if (ex.prompt) els.prompt.value = ex.prompt; + if (ex.theme) { + const match = THEMES.find((t) => t.key === ex.theme || t.label.toLowerCase() === String(ex.theme).toLowerCase()); + if (match) { + state.theme = match.key; + [...els.chips.children].forEach((c) => c.classList.toggle("active", c.dataset.key === match.key)); + } + } + if (ex.mode) setModeByValue(ex.mode); + refreshRun(); + document.querySelector(".lab")?.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +function setModeByValue(mode) { + const want = mode === "upscale2x" || /upscale/i.test(mode) ? "upscale2x" : "exact"; + const btn = els.toggle.querySelector(`.toggle-opt[data-mode="${want}"]`); + if (btn) setMode(want, btn); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} + +/* ------------------------------------------------------------ server status */ +async function pingServer() { + try { + const res = await fetch("/api/health", { method: "GET" }); + if (res.ok) { + const data = await res.json(); + setServer(Boolean(data.model_online), data.model_online ? "online" : "model waking"); + return; + } + } catch { /* fall through */ } + // /api/examples succeeding also means the app server is up + try { + const res = await fetch("/api/examples", { method: "GET" }); + setServer(res.ok, res.ok ? "ui online" : "offline"); + } catch { setServer(false, "offline"); } +} + +function setServer(up, label) { + els.serverDot.className = "dot " + (up ? "up" : "down"); + els.serverLbl.textContent = label || (up ? "online" : "offline"); +} + +/* ------------------------------------------------------------ wire up DOM */ +function init() { + buildChips(); + // no default theme β€” it's optional; the user can just type a prompt + + // dropzone + els.dropzone.addEventListener("click", (e) => { + if (e.target.closest(".link-btn")) return; // "change" handled separately + els.file.click(); + }); + els.dropzone.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); els.file.click(); } + }); + els.file.addEventListener("change", (e) => loadFile(e.target.files[0])); + els.dzClear.addEventListener("click", (e) => { e.stopPropagation(); clearFile(); }); + + ["dragenter", "dragover"].forEach((ev) => + els.dropzone.addEventListener(ev, (e) => { e.preventDefault(); els.dropzone.classList.add("dragover"); })); + ["dragleave", "drop"].forEach((ev) => + els.dropzone.addEventListener(ev, (e) => { e.preventDefault(); els.dropzone.classList.remove("dragover"); })); + els.dropzone.addEventListener("drop", (e) => { + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) loadFile(f); + }); + // paste an image from clipboard + window.addEventListener("paste", (e) => { + const item = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith("image/")); + if (item) loadFile(item.getAsFile()); + }); + + // mode toggle + els.toggle.querySelectorAll(".toggle-opt").forEach((btn) => + btn.addEventListener("click", () => setMode(btn.dataset.mode, btn))); + + // prompt edits clear active chip only if it diverges (keeps it simple: leave chip, prompt is source of truth) + els.prompt.addEventListener("input", refreshRun); + + // run + els.run.addEventListener("click", runEdit); + // ctrl/cmd+enter to run + window.addEventListener("keydown", (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && !els.run.disabled) runEdit(); + }); + + refreshRun(); + loadAssets(); + loadGallery(); + pingServer(); +} + +if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); +else init(); diff --git a/static/assets/apple.png b/static/assets/apple.png new file mode 100644 index 0000000000000000000000000000000000000000..51c082ac9ffc06d09d0e6c8283b6d96098d646f3 Binary files /dev/null and b/static/assets/apple.png differ diff --git a/static/assets/chest.png b/static/assets/chest.png new file mode 100644 index 0000000000000000000000000000000000000000..91167965ab2587b35e42ea3ba8c06cf7b57b64df Binary files /dev/null and b/static/assets/chest.png differ diff --git a/static/assets/egg.png b/static/assets/egg.png new file mode 100644 index 0000000000000000000000000000000000000000..0a7adfdaef7f11ad8d2b54aa12f1b05503e59a57 Binary files /dev/null and b/static/assets/egg.png differ diff --git a/static/assets/gen_coin.png b/static/assets/gen_coin.png new file mode 100644 index 0000000000000000000000000000000000000000..8acd3cf10fa98392685333e41da8caf54cbcbec3 Binary files /dev/null and b/static/assets/gen_coin.png differ diff --git a/static/assets/gen_gem.png b/static/assets/gen_gem.png new file mode 100644 index 0000000000000000000000000000000000000000..805c60d2aec631beb7511aa5bb8f410b03b715b8 Binary files /dev/null and b/static/assets/gen_gem.png differ diff --git a/static/assets/gen_potion.png b/static/assets/gen_potion.png new file mode 100644 index 0000000000000000000000000000000000000000..b826fca7845ac5b786423b53a1061bd0d58a656c Binary files /dev/null and b/static/assets/gen_potion.png differ diff --git a/static/assets/gen_star.png b/static/assets/gen_star.png new file mode 100644 index 0000000000000000000000000000000000000000..b2f5cf424222278c5d90017e913bfb1ef819171a Binary files /dev/null and b/static/assets/gen_star.png differ diff --git a/static/assets/pickaxe.png b/static/assets/pickaxe.png new file mode 100644 index 0000000000000000000000000000000000000000..913f3daeccecc91164396ac0cb3b6a9ccf4f1a98 Binary files /dev/null and b/static/assets/pickaxe.png differ diff --git a/static/assets/sword.png b/static/assets/sword.png new file mode 100644 index 0000000000000000000000000000000000000000..b697e386491c02847618bcdb4aff88542fe60fcf Binary files /dev/null and b/static/assets/sword.png differ diff --git a/static/assets/totem.png b/static/assets/totem.png new file mode 100644 index 0000000000000000000000000000000000000000..83582976d4a1cb533cbc392941b0d98806312497 Binary files /dev/null and b/static/assets/totem.png differ diff --git a/static/examples/autumn_chest_in.png b/static/examples/autumn_chest_in.png new file mode 100644 index 0000000000000000000000000000000000000000..91167965ab2587b35e42ea3ba8c06cf7b57b64df Binary files /dev/null and b/static/examples/autumn_chest_in.png differ diff --git a/static/examples/autumn_chest_out.png b/static/examples/autumn_chest_out.png new file mode 100644 index 0000000000000000000000000000000000000000..355af75d8e1566823b4682ecae1f334fb370fd12 Binary files /dev/null and b/static/examples/autumn_chest_out.png differ diff --git a/static/examples/cosmic_totem_in.png b/static/examples/cosmic_totem_in.png new file mode 100644 index 0000000000000000000000000000000000000000..83582976d4a1cb533cbc392941b0d98806312497 Binary files /dev/null and b/static/examples/cosmic_totem_in.png differ diff --git a/static/examples/cosmic_totem_out.png b/static/examples/cosmic_totem_out.png new file mode 100644 index 0000000000000000000000000000000000000000..7755f79ddf1e8c360cc26a141266b0bf4f83284c Binary files /dev/null and b/static/examples/cosmic_totem_out.png differ diff --git a/static/examples/examples.json b/static/examples/examples.json new file mode 100644 index 0000000000000000000000000000000000000000..2b280662bf7794376bdd3f82ccaf40a09304fc13 --- /dev/null +++ b/static/examples/examples.json @@ -0,0 +1,56 @@ +[ + { + "id": "lava_sword", + "title": "Lava Sword", + "theme": "\ud83c\udf0b Molten lava", + "prompt": "Molten lava blade: glowing magma core, white-hot edge, charred guard, bold orange-red ramp.", + "mode": "exact", + "input": "lava_sword_in.png", + "output": "lava_sword_out.png" + }, + { + "id": "gold_pickaxe", + "title": "Gold Pickaxe", + "theme": "\ud83e\ude99 Solid gold", + "prompt": "Solid gold royal pickaxe: shimmering gold ramp, warm highlights, deep amber shadows.", + "mode": "exact", + "input": "gold_pickaxe_in.png", + "output": "gold_pickaxe_out.png" + }, + { + "id": "autumn_chest", + "title": "Autumn Chest", + "theme": "\ud83c\udf42 Autumn dusk", + "prompt": "Autumn dusk chest: warm amber and crimson wood with deep purple shadows, brass trim.", + "mode": "exact", + "input": "autumn_chest_in.png", + "output": "autumn_chest_out.png" + }, + { + "id": "toxic_egg", + "title": "Toxic Orb", + "theme": "\u2622\ufe0f Toxic", + "prompt": "Toxic radioactive orb: sickly neon green, glowing hazard speckles, dark slime shadows.", + "mode": "exact", + "input": "toxic_egg_in.png", + "output": "toxic_egg_out.png" + }, + { + "id": "ocean_apple", + "title": "Ocean Apple", + "theme": "\ud83c\udf0a Deep ocean", + "prompt": "Deep ocean apple: teal and aqua, blue-green ramp, soft glow, frosted highlight.", + "mode": "exact", + "input": "ocean_apple_in.png", + "output": "ocean_apple_out.png" + }, + { + "id": "cosmic_totem", + "title": "Cosmic Totem", + "theme": "\ud83c\udf0c Cosmic galaxy", + "prompt": "Cosmic galaxy totem: deep space-purple with tiny star speckles and glowing cyan accents.", + "mode": "exact", + "input": "cosmic_totem_in.png", + "output": "cosmic_totem_out.png" + } +] \ No newline at end of file diff --git a/static/examples/gold_pickaxe_in.png b/static/examples/gold_pickaxe_in.png new file mode 100644 index 0000000000000000000000000000000000000000..913f3daeccecc91164396ac0cb3b6a9ccf4f1a98 Binary files /dev/null and b/static/examples/gold_pickaxe_in.png differ diff --git a/static/examples/gold_pickaxe_out.png b/static/examples/gold_pickaxe_out.png new file mode 100644 index 0000000000000000000000000000000000000000..7cc45d940ae072a8192b4e5e345a29b2d113a033 Binary files /dev/null and b/static/examples/gold_pickaxe_out.png differ diff --git a/static/examples/lava_sword_in.png b/static/examples/lava_sword_in.png new file mode 100644 index 0000000000000000000000000000000000000000..b697e386491c02847618bcdb4aff88542fe60fcf Binary files /dev/null and b/static/examples/lava_sword_in.png differ diff --git a/static/examples/lava_sword_out.png b/static/examples/lava_sword_out.png new file mode 100644 index 0000000000000000000000000000000000000000..eccf51e7521037f8e146254200fc0f423afdb6c2 Binary files /dev/null and b/static/examples/lava_sword_out.png differ diff --git a/static/examples/ocean_apple_in.png b/static/examples/ocean_apple_in.png new file mode 100644 index 0000000000000000000000000000000000000000..51c082ac9ffc06d09d0e6c8283b6d96098d646f3 Binary files /dev/null and b/static/examples/ocean_apple_in.png differ diff --git a/static/examples/ocean_apple_out.png b/static/examples/ocean_apple_out.png new file mode 100644 index 0000000000000000000000000000000000000000..59c053f05dcd17cbee859e697180108bb53d1219 Binary files /dev/null and b/static/examples/ocean_apple_out.png differ diff --git a/static/examples/toxic_egg_in.png b/static/examples/toxic_egg_in.png new file mode 100644 index 0000000000000000000000000000000000000000..0a7adfdaef7f11ad8d2b54aa12f1b05503e59a57 Binary files /dev/null and b/static/examples/toxic_egg_in.png differ diff --git a/static/examples/toxic_egg_out.png b/static/examples/toxic_egg_out.png new file mode 100644 index 0000000000000000000000000000000000000000..e6bd50006750b9cdede5e9ad3c5247cff2eff8ca Binary files /dev/null and b/static/examples/toxic_egg_out.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..dcfc8b09097fc62ecea8c9b3fed47e0126c792fa --- /dev/null +++ b/static/index.html @@ -0,0 +1,166 @@ + + + + + +PixelLock β€” AI Pixel-Art Retexturing + + + + + + + + + + + +
+
+ +

PixelLock

+ | + AI Pixel-Art Retexturing Tool +
+
+ + Footprint-locked retexturing +
+
+ connecting… + ⌁ GBNF Β· llama.cpp +
+
+ +
+ ⚑ NOT an image generator + your PNG β†’ a text grid (PALETTE + GRID) β†’ a fine-tuned text LLM rewrites it under a GBNF grammar that locks the exact shape β†’ pixel-perfect PNG. It's language modeling, not diffusion. +
+ +
+
+ 100% + footprint constrained +
+
+ PNG β†’ TEXT β†’ PNG + palette grid, not diffusion +
+
+ LLAMA.CPP + GBNF + shape locked in the decoder +
+
+ +
+ + + + +
+
+
+
INPUT your sprite
+
input sprite
+
+ + + +
+
OUTPUT AI restyled
+
+ restyled sprite +

your restyled sprite
appears here

+ +
+
+ + +
+ +
+ + β€” + +
+ +
+ βŒ– see what the model actually wrote β€” it's text, not pixels +
Run an edit, then peek here: the LLM literally writes a PALETTE block and a GRID of cells (K R R K …). The GBNF grammar forces every transparent cell to stay transparent β€” that's why the shape can't break.
+
+ + +
+
+ +
+ ✦ Pixel-precise · AI-powered · your shape, any vibe + · + fine-tuned Gemma-4-12B · GBNF footprint grammar · runs on llama.cpp · Build Small Hackathon +
+ + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000000000000000000000000000000000000..f14c2cf7af15abb5195371d55d18f37da08e8f43 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,260 @@ +/* ============================================================ + PixelLock β€” neon "pixel lab" UI (matches the product mockup) + dark navy base Β· violet + cyan neon Β· crisp pixels + ============================================================ */ +:root{ + --bg:#06060d; --bg2:#0a0a16; + --panel:rgba(18,18,32,.72); --panel2:rgba(26,26,44,.6); + --line:rgba(150,140,210,.16); --line2:rgba(150,140,210,.34); + --ink:#eceaff; --dim:#a8a3c8; --faint:#6d688d; + --violet:#a06bff; --violet-hi:#c4a3ff; --cyan:#00e5d4; --cyan-hi:#6ff5ea; + --amber:#ffd166; --lime:#7dff6a; --rose:#ff5ab3; + --ok:#39e08a; --warn:#ffb347; --err:#ff5d6c; + --radius:14px; --mono:"JetBrains Mono",ui-monospace,monospace; --sans:"Space Grotesk",system-ui,sans-serif; +} +*{box-sizing:border-box} +[hidden]{display:none!important} /* author display rules must not override the hidden attribute */ +html,body{margin:0;padding:0} +body{ + font-family:var(--sans); color:var(--ink); background:var(--bg); + min-height:100vh; overflow-x:hidden; line-height:1.45; +} +.bg-glow{position:fixed;inset:0;z-index:-2;pointer-events:none; + background: + radial-gradient(60% 50% at 18% 0%, rgba(160,107,255,.20), transparent 70%), + radial-gradient(55% 45% at 92% 8%, rgba(0,229,212,.16), transparent 70%), + radial-gradient(80% 60% at 50% 110%, rgba(160,107,255,.10), transparent 70%), + var(--bg);} +.scanlines{position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.35; + background:repeating-linear-gradient(to bottom,rgba(255,255,255,.018) 0 1px,transparent 1px 3px);} +.pixelated{image-rendering:pixelated;image-rendering:crisp-edges} + +/* ───── header ───── */ +.appbar{display:flex;align-items:center;gap:18px;padding:14px 22px; + border-bottom:1px solid var(--line); + background:linear-gradient(180deg,rgba(12,12,22,.9),rgba(12,12,22,.55)); + backdrop-filter:blur(8px);position:sticky;top:0;z-index:10;flex-wrap:wrap} +.brand{display:flex;align-items:center;gap:12px} +.logo{display:grid;grid-template-columns:repeat(2,11px);grid-auto-rows:11px;gap:3px; + padding:6px;border:1px solid var(--line2);border-radius:9px;background:#0c0c18} +.logo i{width:11px;height:11px;border-radius:2px} +.logo i:nth-child(1){background:var(--violet)} .logo i:nth-child(2){background:var(--cyan)} +.logo i:nth-child(3){background:var(--cyan)} .logo i:nth-child(4){background:var(--violet); + animation:blink 2.4s steps(1) infinite} +@keyframes blink{0%,80%{opacity:1}90%{opacity:.25}} +.wordmark{font-size:24px;font-weight:700;margin:0;letter-spacing:.5px} +.wordmark b{color:var(--cyan);text-shadow:0 0 18px rgba(0,229,212,.5)} +.brand-div{color:var(--faint)} +.brand-sub{font-family:var(--mono);font-size:12.5px;color:var(--dim)} +.fp-pill{display:inline-flex;align-items:center;gap:7px;margin-left:6px; + font-family:var(--mono);font-size:12.5px;color:var(--cyan-hi); + padding:7px 14px;border-radius:999px;border:1px solid rgba(0,229,212,.4); + background:rgba(0,229,212,.07);box-shadow:0 0 22px rgba(0,229,212,.18) inset} +.appbar-right{margin-left:auto;display:flex;align-items:center;gap:12px} +.server-pill{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono); + font-size:12px;color:var(--dim);padding:6px 12px;border:1px solid var(--line);border-radius:999px;background:var(--panel2)} +.dot{width:8px;height:8px;border-radius:50%} +.dot.up{background:var(--ok);box-shadow:0 0 10px var(--ok)} .dot.down{background:var(--err);box-shadow:0 0 10px var(--err)} +.ghost-btn{font-family:var(--mono);font-size:12px;color:var(--dim);text-decoration:none; + padding:6px 12px;border:1px solid var(--line);border-radius:9px;background:var(--panel2);transition:.15s} +.ghost-btn:hover{color:var(--ink);border-color:var(--line2)} + +/* ───── how-it-works strip + "see the LLM output" reveal ───── */ +.howbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:10px 22px;border-bottom:1px solid var(--line); + background:rgba(160,107,255,.045);font-family:var(--mono);font-size:12px;color:var(--dim);line-height:1.5} +.how-tag{color:var(--violet-hi);font-weight:700;border:1px solid rgba(160,107,255,.4);border-radius:999px;padding:4px 11px;background:rgba(160,107,255,.08);white-space:nowrap} +.how-steps b{color:var(--cyan-hi)} .how-steps em{color:var(--ink);font-style:normal;font-weight:700} +.proofbar{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px; + max-width:1480px;margin:14px auto 0;padding:0 22px} +.proof-card{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:12px; + background:linear-gradient(180deg,rgba(20,20,34,.82),rgba(10,10,18,.72));padding:13px 16px} +.proof-card::before{content:"";position:absolute;inset:0 0 auto;height:2px; + background:linear-gradient(90deg,var(--violet),var(--cyan),var(--amber))} +.proof-card:nth-child(2)::before{background:linear-gradient(90deg,var(--amber),var(--rose),var(--violet))} +.proof-card:nth-child(3)::before{background:linear-gradient(90deg,var(--lime),var(--cyan),var(--violet))} +.proof-k{display:block;font-family:var(--mono);font-size:12px;font-weight:700;color:var(--ink); + letter-spacing:.8px;text-transform:uppercase} +.proof-v{display:block;margin-top:3px;font-family:var(--mono);font-size:11px;color:var(--faint)} +.wire-reveal{background:#08080f;border:1px solid var(--line);border-radius:12px;overflow:hidden} +.wire-reveal summary{cursor:pointer;list-style:none;padding:11px 16px;font-family:var(--mono);font-size:12.5px;color:var(--cyan-hi);user-select:none} +.wire-reveal summary::-webkit-details-marker{display:none} +.wire-reveal summary b{color:var(--ink)} +.wire-reveal summary:hover{background:rgba(0,229,212,.05)} +.wire-reveal[open] summary{border-bottom:1px solid var(--line)} +.wire{margin:0;padding:14px 16px;max-height:300px;overflow:auto;font-family:var(--mono);font-size:11px;line-height:1.5;color:#9fe6dd;white-space:pre-wrap;word-break:break-word} + +/* ───── layout ───── */ +.layout{display:grid;grid-template-columns:372px 1fr;gap:20px;padding:20px 22px;max-width:1480px;margin:0 auto;align-items:start} + +/* ───── controls ───── */ +.controls{display:flex;flex-direction:column;gap:16px;min-width:0; + background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:18px} +.step h2{display:flex;align-items:center;gap:9px;margin:0 0 9px;font-size:13px;font-weight:600; + text-transform:uppercase;letter-spacing:1.2px;color:var(--dim)} +.step .num{font-family:var(--mono);font-size:11px;color:var(--violet-hi); + border:1px solid rgba(160,107,255,.4);border-radius:6px;padding:2px 6px;background:rgba(160,107,255,.08)} +.step .opt{font-family:var(--mono);font-size:10px;color:var(--faint);text-transform:none;letter-spacing:0} + +.dropzone{border:1.5px dashed var(--line2);border-radius:12px;padding:22px 14px;text-align:center; + cursor:pointer;transition:.15s;background:rgba(160,107,255,.03)} +.dropzone:hover,.dropzone.dragover{border-color:var(--violet);background:rgba(160,107,255,.08);box-shadow:0 0 26px rgba(160,107,255,.14) inset} +.dz-ico{font-size:26px;color:var(--violet-hi);margin-bottom:6px} +.dz-main{margin:0;font-size:14px} .dz-main .link{color:var(--cyan-hi)} +.dz-hint{margin:4px 0 0;font-family:var(--mono);font-size:11px;color:var(--faint)} +.dz-tag{display:inline-block;margin-top:10px;font-family:var(--mono);font-size:10px;color:var(--violet-hi); + border:1px solid rgba(160,107,255,.4);border-radius:6px;padding:2px 9px} +.dz-loaded{display:flex;flex-direction:column;align-items:center;gap:8px} +#dz-preview{width:96px;height:96px;object-fit:contain;background:#0c0c18;border:1px solid var(--line);border-radius:8px} +.dz-meta{display:flex;gap:10px;align-items:center;font-family:var(--mono);font-size:11px;color:var(--dim)} +.link-btn{background:none;border:none;color:var(--cyan-hi);cursor:pointer;font-family:var(--mono);font-size:11px;padding:0;text-decoration:underline} + +.starter-step{margin-top:-2px} +.asset-strip{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px} +.asset-tile{min-width:0;border:1px solid var(--line);border-radius:10px;background:rgba(10,10,20,.7); + padding:7px 6px 6px;color:var(--dim);cursor:pointer;transition:.14s} +.asset-tile:hover,.asset-tile:focus{border-color:var(--cyan);transform:translateY(-1px); + box-shadow:0 0 18px rgba(0,229,212,.14);outline:none} +.asset-img{display:flex;align-items:center;justify-content:center;aspect-ratio:1/1;border-radius:8px; + border:1px solid rgba(150,140,210,.12);overflow:hidden} +.asset-img img{width:100%;height:100%;object-fit:contain;padding:10%;image-rendering:pixelated} +.asset-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:5px; + font-family:var(--mono);font-size:9.5px;text-align:center;color:var(--faint)} +.asset-empty{grid-column:1/-1;font-family:var(--mono);font-size:11px;color:var(--faint);text-align:center;padding:8px} + +.chips{display:grid;grid-template-columns:1fr 1fr;gap:8px} +.chip{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12.5px;color:var(--ink); + padding:9px 11px;border:1px solid var(--line);border-radius:10px;background:var(--panel2);cursor:pointer;transition:.13s} +.chip:hover{border-color:var(--line2);transform:translateY(-1px)} +.chip .swatch{width:11px;height:11px;border-radius:3px;box-shadow:0 0 8px currentColor} +.chip.active{border-color:var(--violet);background:rgba(160,107,255,.14);box-shadow:0 0 18px rgba(160,107,255,.2)} + +.prompt{width:100%;resize:vertical;min-height:76px;font-family:var(--mono);font-size:12.5px;line-height:1.5; + color:var(--ink);background:#0a0a16;border:1px solid var(--line);border-radius:10px;padding:11px} +.prompt:focus{outline:none;border-color:var(--violet);box-shadow:0 0 0 3px rgba(160,107,255,.16)} + +.toggle{position:relative;display:grid;grid-template-columns:1fr 1fr;background:#0a0a16;border:1px solid var(--line);border-radius:10px;padding:4px} +.toggle-glider{position:absolute;top:4px;left:4px;width:calc(50% - 4px);height:calc(100% - 8px); + border-radius:7px;background:linear-gradient(135deg,rgba(160,107,255,.9),rgba(0,229,212,.7));transition:transform .22s cubic-bezier(.4,1.3,.5,1);z-index:0} +.toggle-glider.right{transform:translateX(100%)} +.toggle-opt{position:relative;z-index:1;background:none;border:none;cursor:pointer;color:var(--dim); + font-family:var(--mono);font-size:12.5px;padding:9px 4px;transition:.15s} +.toggle-opt.active{color:#0a0712;font-weight:700} + +.run-btn{position:relative;margin-top:2px;border:none;cursor:pointer;border-radius:12px;padding:15px; + font-family:var(--mono);font-weight:700;font-size:15px;letter-spacing:1px;color:#0a0712; + background:linear-gradient(135deg,var(--violet) 0%,#c14bff 35%,var(--cyan) 72%,var(--amber) 100%); + box-shadow:0 0 30px rgba(160,107,255,.4),0 6px 18px rgba(0,0,0,.4);transition:.15s} +.run-btn:hover:not(:disabled){filter:brightness(1.08);transform:translateY(-1px)} +.run-btn:disabled{opacity:.4;cursor:not-allowed;filter:grayscale(.4)} +.run-btn .run-spinner{display:none;width:15px;height:15px;border:2px solid rgba(10,7,18,.4);border-top-color:#0a0712;border-radius:50%;vertical-align:-2px;margin-left:8px;animation:spin .7s linear infinite} +.run-btn.busy .run-spinner{display:inline-block} .run-btn.busy .run-label{opacity:.8} +@keyframes spin{to{transform:rotate(360deg)}} +.hint{margin:0;text-align:center;font-family:var(--mono);font-size:11px;color:var(--faint)} +.hint.error{color:var(--err)} + +/* ───── stage ───── */ +.stage{display:flex;flex-direction:column;gap:16px;min-width:0} +.compare{display:grid;grid-template-columns:1fr auto 1fr 232px;gap:14px;align-items:stretch; + background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:18px} +.frame{margin:0;display:flex;flex-direction:column;gap:9px;min-width:0} +.frame figcaption{font-family:var(--mono);font-size:12px;font-weight:700;letter-spacing:1px;color:var(--ink)} +.frame figcaption span{color:var(--faint);font-weight:400;text-transform:none;margin-left:6px} +.canvas{position:relative;border-radius:12px;border:1px solid var(--line);aspect-ratio:1/1;overflow:hidden;display:flex;align-items:center;justify-content:center} +.canvas img:not([src]), .cell img:not([src]), #dz-preview:not([src]){display:none!important} +.checker{background-color:#11111c; + background-image:linear-gradient(45deg,#1a1a2a 25%,transparent 25%),linear-gradient(-45deg,#1a1a2a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#1a1a2a 75%),linear-gradient(-45deg,transparent 75%,#1a1a2a 75%); + background-size:18px 18px;background-position:0 0,0 9px,9px -9px,-9px 0} +#img-in,#img-out{width:100%;height:100%;object-fit:contain;padding:6%} +.arrow{align-self:center;color:var(--violet-hi);font-size:18px;text-shadow:0 0 12px var(--violet);animation:pulse 1.8s ease-in-out infinite} +@keyframes pulse{0%,100%{opacity:.55;transform:translateX(0)}50%{opacity:1;transform:translateX(3px)}} +.frame-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center; + font-family:var(--mono);font-size:12px;color:var(--faint)} +.frame-empty::before{content:"";position:absolute;width:96px;height:96px;border-radius:18px; + border:1px solid rgba(0,229,212,.24);background: + linear-gradient(90deg,transparent 48%,rgba(0,229,212,.18) 49% 51%,transparent 52%), + linear-gradient(transparent 48%,rgba(160,107,255,.18) 49% 51%,transparent 52%); + box-shadow:0 0 34px rgba(0,229,212,.1)} +.frame-empty p{position:relative;z-index:1;margin:0;padding-top:128px} +.frame-loader{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;background:rgba(8,8,18,.78);backdrop-filter:blur(2px)} +.loader-grid{display:grid;grid-template-columns:repeat(3,12px);grid-auto-rows:12px;gap:4px} +.loader-grid span{background:var(--violet);border-radius:2px;animation:lg 1.1s ease-in-out infinite} +.loader-grid span:nth-child(2){animation-delay:.1s;background:var(--cyan)} .loader-grid span:nth-child(3){animation-delay:.2s} +.loader-grid span:nth-child(4){animation-delay:.1s} .loader-grid span:nth-child(5){animation-delay:.2s;background:var(--cyan)} .loader-grid span:nth-child(6){animation-delay:.3s} +.loader-grid span:nth-child(7){animation-delay:.2s} .loader-grid span:nth-child(8){animation-delay:.3s} .loader-grid span:nth-child(9){animation-delay:.4s;background:var(--cyan)} +@keyframes lg{0%,100%{opacity:.2;transform:scale(.8)}50%{opacity:1;transform:scale(1)}} +.frame-loader p{margin:0;font-family:var(--mono);font-size:12px;color:var(--dim)} + +/* silhouette panel */ +.sil-panel{display:flex;flex-direction:column;gap:9px;border:1px solid rgba(0,229,212,.28);border-radius:12px; + padding:12px;background:linear-gradient(180deg,rgba(0,229,212,.05),rgba(8,8,18,.2))} +.sil-head{display:flex;align-items:center;gap:6px;font-family:var(--mono);font-size:10.5px;font-weight:700;letter-spacing:1px;color:var(--cyan-hi)} +.sil-art{border-radius:9px;border:1px solid var(--line);aspect-ratio:1/1;display:flex;align-items:center;justify-content:center;overflow:hidden} +#sil-canvas{image-rendering:pixelated;width:84%;height:84%;object-fit:contain} +.sil-note{margin:0;font-family:var(--mono);font-size:10.5px;line-height:1.5;color:var(--faint)} + +/* statusbar (chips) */ +.statusbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:0 4px} +.badge{font-family:var(--mono);font-size:12px;padding:7px 13px;border-radius:999px;border:1px solid;} +.badge.ok{color:var(--ok);border-color:rgba(57,224,138,.4);background:rgba(57,224,138,.08)} +.badge.warn{color:var(--warn);border-color:rgba(255,179,71,.4);background:rgba(255,179,71,.08)} +.status{font-family:var(--mono);font-size:12.5px;color:var(--dim)} .status.error{color:var(--err)} +.dl-btn{margin-left:auto;font-family:var(--mono);font-size:12.5px;font-weight:700;color:#0a0712;text-decoration:none; + padding:9px 18px;border-radius:10px;background:linear-gradient(135deg,var(--cyan),var(--cyan-hi));box-shadow:0 0 20px rgba(0,229,212,.35)} +.dl-btn:hover{filter:brightness(1.08)} + +/* gallery */ +.gallery-wrap{background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:18px} +.gallery-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px} +.gallery-head h3{margin:0;font-size:14px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--ink)} +.hint-sm{font-family:var(--mono);font-size:11px;color:var(--faint)} +.gallery{display:grid;grid-template-columns:repeat(6,1fr);gap:12px} +.card{cursor:pointer;border:1px solid var(--line);border-radius:11px;overflow:hidden;background:var(--panel2);transition:.15s} +.card:hover,.card:focus{border-color:var(--violet);transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.4),0 0 22px rgba(160,107,255,.18);outline:none} +.card-imgs{display:grid;grid-template-columns:1fr 1fr;position:relative} +.card-imgs::after{content:"";position:absolute;top:6%;bottom:6%;left:50%;width:1px;background:linear-gradient(var(--violet),var(--cyan));transform:translateX(-.5px)} +.cell{position:relative;aspect-ratio:1/1;display:flex;align-items:center;justify-content:center; + background-color:#11111c;background-image:linear-gradient(45deg,#1a1a2a 25%,transparent 25%),linear-gradient(-45deg,#1a1a2a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#1a1a2a 75%),linear-gradient(-45deg,transparent 75%,#1a1a2a 75%);background-size:12px 12px;background-position:0 0,0 6px,6px -6px,-6px 0} +.cell img{width:100%;height:100%;object-fit:contain;padding:9%;image-rendering:pixelated} +.cell::before{content:attr(data-tag);position:absolute;top:5px;left:5px;font-family:var(--mono);font-size:8px;color:var(--faint);background:rgba(8,8,18,.7);padding:1px 4px;border-radius:4px} +.card-title{margin:8px 9px 2px;font-size:12.5px;font-weight:600;color:var(--ink)} +.card-prompt{margin:0 9px 10px;font-family:var(--mono);font-size:10px;line-height:1.45;color:var(--faint); + display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.gallery-empty{color:var(--faint);font-family:var(--mono);font-size:12px;grid-column:1/-1;text-align:center;padding:20px} + +/* footer */ +.site-foot{display:flex;justify-content:center;align-items:center;gap:10px;flex-wrap:wrap;padding:22px; + border-top:1px solid var(--line);margin-top:8px;font-size:12.5px} +.foot-strong{color:var(--dim)} .foot-dim{font-family:var(--mono);font-size:11px;color:var(--faint)} .sep{color:var(--faint)} + +/* responsive */ +@media(max-width:1080px){ + .layout{grid-template-columns:1fr} + .proofbar{grid-template-columns:1fr} + .compare{grid-template-columns:1fr auto 1fr;grid-template-rows:auto auto} + .sil-panel{grid-column:1/-1;flex-direction:row;align-items:center} + .sil-art{width:90px;height:90px;flex:0 0 auto} + .gallery{grid-template-columns:repeat(3,1fr)} +} +@media(max-width:900px){ + .appbar{align-items:flex-start;gap:10px;padding:12px 14px} + .brand{min-width:0;flex-wrap:wrap} + .brand-div,.brand-sub{display:none} + .fp-pill{margin-left:0} + .appbar-right{width:100%;margin-left:0;justify-content:flex-start;gap:8px;flex-wrap:wrap} + .ghost-btn{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .howbar{display:block;padding:10px 14px} + .how-tag{display:inline-flex;margin-bottom:8px} + .how-steps{display:block;overflow-wrap:anywhere} + .proofbar{padding:0 14px} + .layout{padding:16px 14px} +} +@media(max-width:620px){ + .wordmark{font-size:22px} + .server-pill{font-size:11px;padding:5px 10px} + .compare{grid-template-columns:1fr;grid-template-rows:auto} + .arrow{transform:rotate(90deg)} + .gallery{grid-template-columns:repeat(2,1fr)} + .asset-strip{grid-template-columns:repeat(4,minmax(0,1fr))} + .chips{grid-template-columns:1fr 1fr} +} +@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/validate.py b/validate.py new file mode 100644 index 0000000000000000000000000000000000000000..3832023bf6cf06d279f9ea24eedb479279c19da4 --- /dev/null +++ b/validate.py @@ -0,0 +1,420 @@ +"""Strict parser and Tier-1 mechanical checks for SpriteBench wire-format sprites. + +Wire format (fixed for all models): + + PALETTE + . transparent + K 34,32,52 + R 214,48,74 + (... max 12 entries, single-char keys) + GRID 32x32 + + +Token-stable local training/app data may also put a single ASCII space between +cells in grid rows (``K R R K``). Parsed sprites always store compact rows +internally (``KRRK``), so existing render/scoring code sees one representation. + +The parser tolerates prose / reasoning / code fences BEFORE the PALETTE line +and AFTER the grid rows, but is STRICT in between. The first violation wins. +Error strings are fed back to models on retry, so they must be specific and +actionable. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional + +if TYPE_CHECKING: # pragma: no cover - import only for type checking + from tasks import Task + + +# Palette line patterns. Single space separators; trailing whitespace tolerated +# (stripped before matching). Single-char (non-whitespace) key. +_RGB_LINE_RE = re.compile(r"^(\S) (\d{1,3}),(\d{1,3}),(\d{1,3})$") +_TRANSPARENT_LINE_RE = re.compile(r"^(\S) transparent$") +_GRID_LINE_RE = re.compile(r"^GRID (\d+)x(\d+)$") + +MAX_PALETTE = 12 + + +def _normalize_grid_row(row: str, width: int, row_num: int) -> tuple[Optional[str], Optional[str]]: + """Return compact row text, accepting either compact or space-separated cells.""" + if len(row) == width: + return row, None + + if " " in row: + parts = row.split(" ") + if ( + len(parts) == width + and all(len(part) == 1 for part in parts) + and all(part != "" for part in parts) + ): + return "".join(parts), None + return None, ( + f"row {row_num} of grid is not a valid compact row or " + f"space-separated row; expected {width} characters or {width} " + "single-character cells separated by single spaces" + ) + + return None, ( + f"row {row_num} of grid has {len(row)} characters, " + f"expected {width}" + ) + + +@dataclass +class Sprite: + """A parsed palette-indexed sprite. + + palette maps a single-char key to an (R, G, B) tuple, or to None for the + transparent entry (whose key must be '.'). + """ + + palette: dict[str, Optional[tuple[int, int, int]]] + width: int + height: int + rows: list[str] + + def to_dict(self) -> dict: + return { + # JSON has no tuples; store RGB as a list, transparent as null. + "palette": { + key: (None if rgb is None else list(rgb)) + for key, rgb in self.palette.items() + }, + "width": self.width, + "height": self.height, + "rows": list(self.rows), + } + + @staticmethod + def from_dict(d: dict) -> "Sprite": + palette: dict[str, Optional[tuple[int, int, int]]] = {} + for key, rgb in d["palette"].items(): + if rgb is None: + palette[key] = None + else: + palette[key] = (int(rgb[0]), int(rgb[1]), int(rgb[2])) + return Sprite( + palette=palette, + width=int(d["width"]), + height=int(d["height"]), + rows=list(d["rows"]), + ) + + +def parse_sprite(text: str) -> tuple[Optional[Sprite], Optional[str]]: + """Parse wire-format text into a Sprite. + + Returns (sprite, None) on success or (None, error_message) on failure. + The error message is specific and actionable for model-retry feedback. + + Tolerance: arbitrary content before the first exact `PALETTE` line and + after the final grid row is ignored. Everything in between is strict, and + the first violation determines the returned error. + """ + if text is None: + return None, "no PALETTE line found in output" + + lines = text.splitlines() + + # 1. Locate the first line that is exactly `PALETTE` after stripping + # surrounding whitespace. Tolerate any prose / fences before it. + palette_index: Optional[int] = None + for i, line in enumerate(lines): + if line.strip() == "PALETTE": + palette_index = i + break + if palette_index is None: + return None, "no PALETTE line found in output" + + # 2. Parse palette entries on the consecutive lines after PALETTE, up to the + # GRID line. Strict from here on. + palette: dict[str, Optional[tuple[int, int, int]]] = {} + idx = palette_index + 1 + grid_index: Optional[int] = None + + while idx < len(lines): + # Strip trailing whitespace only (single-space separators are strict); + # leading whitespace is part of the strict region and not allowed. + raw = lines[idx] + line = raw.rstrip() + + if line.startswith("GRID") or _GRID_LINE_RE.match(line): + grid_index = idx + break + + if line == "": + return None, ( + "blank line inside the palette section is not allowed; " + "list palette entries then the GRID line with no gaps" + ) + + rgb_match = _RGB_LINE_RE.match(line) + transparent_match = _TRANSPARENT_LINE_RE.match(line) + + if transparent_match: + key = transparent_match.group(1) + if key != ".": + return None, ( + f"transparent palette key must be '.' but found '{key}'" + ) + if key in palette: + return None, f"duplicate palette key '{key}'" + palette[key] = None + elif rgb_match: + key = rgb_match.group(1) + r = int(rgb_match.group(2)) + g = int(rgb_match.group(3)) + b = int(rgb_match.group(4)) + for component, value in (("R", r), ("G", g), ("B", b)): + if value > 255: + return None, ( + f"palette key '{key}' has {component} value {value}, " + f"which is out of range (RGB components must be 0-255)" + ) + if key in palette: + return None, f"duplicate palette key '{key}'" + palette[key] = (r, g, b) + else: + return None, ( + f"palette line '{line}' is malformed; expected " + f"' R,G,B' (e.g. 'K 34,32,52') or '. transparent'" + ) + + if len(palette) > MAX_PALETTE: + return None, ( + f"palette has {len(palette)} entries, exceeding the maximum " + f"of {MAX_PALETTE}" + ) + + idx += 1 + + if not palette: + return None, "palette has no entries; declare at least one color" + + # 3. The GRID line must exist and be well formed. + if grid_index is None: + return None, "no 'GRID WxH' line found after the palette" + + grid_line = lines[grid_index].rstrip() + grid_match = _GRID_LINE_RE.match(grid_line) + if not grid_match: + return None, ( + f"GRID line '{grid_line}' is malformed; expected 'GRID WxH' " + f"(e.g. 'GRID 32x32')" + ) + + width = int(grid_match.group(1)) + height = int(grid_match.group(2)) + if width <= 0 or height <= 0: + return None, ( + f"GRID dimensions must be positive but found {width}x{height}" + ) + + # 4. Exactly H consecutive non-empty rows, each exactly W chars, all chars + # declared in the palette. Stop at H rows; trailing content ignored. + rows: list[str] = [] + row_idx = grid_index + 1 + for r in range(height): + if row_idx >= len(lines): + return None, ( + f"grid ended early: expected {height} rows but found " + f"{len(rows)}" + ) + # Rows are taken verbatim (no stripping): a trailing space is a real + # pixel-count error and must be reported, not silently trimmed. + row = lines[row_idx] + if row == "": + return None, ( + f"row {r + 1} of grid is empty; expected {width} characters" + ) + row, row_err = _normalize_grid_row(row, width, r + 1) + if row_err is not None: + return None, row_err + assert row is not None + for col, ch in enumerate(row): + if ch not in palette: + return None, ( + f"character '{ch}' at row {r + 1}, column {col + 1} is " + f"not declared in the palette" + ) + rows.append(row) + row_idx += 1 + + sprite = Sprite(palette=palette, width=width, height=height, rows=rows) + return sprite, None + + +def footprint(sprite: Sprite) -> set[tuple[int, int]]: + """Return the set of (x, y) coordinates of non-transparent cells. + + A cell is non-transparent when its palette value is not None. x is the + column index, y is the row index, both zero-based. + """ + cells: set[tuple[int, int]] = set() + for y, row in enumerate(sprite.rows): + for x, ch in enumerate(row): + if sprite.palette.get(ch) is not None: + cells.add((x, y)) + return cells + + +@dataclass +class Tier1: + """Tier-1 mechanical check result for a single sample.""" + + parse_ok: bool + dims_ok: bool + palette_ok: bool + transparency_ok: bool + footprint_ok: Optional[bool] # T10 only, else None + passed: bool + errors: list[str] + + def to_dict(self) -> dict: + return { + "parse_ok": self.parse_ok, + "dims_ok": self.dims_ok, + "palette_ok": self.palette_ok, + "transparency_ok": self.transparency_ok, + "footprint_ok": self.footprint_ok, + "passed": self.passed, + "errors": list(self.errors), + } + + @staticmethod + def from_dict(d: dict) -> "Tier1": + return Tier1( + parse_ok=bool(d["parse_ok"]), + dims_ok=bool(d["dims_ok"]), + palette_ok=bool(d["palette_ok"]), + transparency_ok=bool(d["transparency_ok"]), + footprint_ok=( + None if d["footprint_ok"] is None else bool(d["footprint_ok"]) + ), + passed=bool(d["passed"]), + errors=list(d["errors"]), + ) + + +def tier1_check( + sprite: Optional[Sprite], parse_error: Optional[str], task: "Task" +) -> Tier1: + """Run the deterministic Tier-1 checks for one sample. + + Checks: parse success, exact dimensions, palette adherence, transparency + sanity (0 < transparent fraction < 1), and for the T10 edit task, exact + footprint match against task.expected_footprint. + + parse_error is the message returned by parse_sprite (None on success). + When parse failed on an undeclared-char error, palette_ok is reported False; + on any other parse failure palette_ok is also False because the palette + could not be validated. When parse succeeded, the parser guarantees + palette adherence so palette_ok is True. + """ + is_edit = getattr(task, "kind", None) == "edit" + + if sprite is None: + message = parse_error if parse_error else "parse failed" + # Distinguish an undeclared-char failure so palette_ok reflects it; in + # all parse-failure cases palette adherence is unverified -> False. + return Tier1( + parse_ok=False, + dims_ok=False, + palette_ok=False, + transparency_ok=False, + footprint_ok=(False if is_edit else None), + passed=False, + errors=[message], + ) + + errors: list[str] = [] + + # Dimensions: sprite must be exactly task.size x task.size (square). + dims_ok = sprite.width == task.size and sprite.height == task.size + if not dims_ok: + errors.append( + f"sprite dimensions are {sprite.width}x{sprite.height}, " + f"expected {task.size}x{task.size}" + ) + + # Palette adherence is guaranteed by the parser on a successful parse. + palette_ok = True + + # Transparency sanity: must have SOME transparency and SOME content. + total_cells = sprite.width * sprite.height + content_cells = footprint(sprite) + transparent_count = total_cells - len(content_cells) + transparent_fraction = ( + transparent_count / total_cells if total_cells else 0.0 + ) + transparency_ok = 0.0 < transparent_fraction < 1.0 + if not transparency_ok: + if transparent_fraction <= 0.0: + errors.append( + "sprite has no transparent cells; a sprite must include some " + "transparency (use the '.' key for background pixels)" + ) + else: + errors.append( + "sprite is entirely transparent; it must contain some " + "non-transparent content" + ) + + # Footprint match for the T10 edit task only. + footprint_ok: Optional[bool] + if is_edit and task.expected_footprint is not None: + expected = set(task.expected_footprint) + actual = content_cells + footprint_ok = actual == expected + if not footprint_ok: + added = sorted(actual - expected) + removed = sorted(expected - actual) + detail_parts: list[str] = [] + if added: + detail_parts.append( + f"{len(added)} cell(s) became non-transparent that must " + f"stay transparent (e.g. {_format_cells(added)})" + ) + if removed: + detail_parts.append( + f"{len(removed)} cell(s) became transparent that must " + f"stay filled (e.g. {_format_cells(removed)})" + ) + errors.append( + "footprint changed: the set of transparent cells must remain " + "exactly identical to the input; " + + "; ".join(detail_parts) + ) + elif is_edit: + # Edit task without an expected footprint declared: cannot verify. + footprint_ok = None + else: + footprint_ok = None + + # passed = all applicable checks true. + applicable = [dims_ok, palette_ok, transparency_ok] + if footprint_ok is not None: + applicable.append(footprint_ok) + passed = all(applicable) + + return Tier1( + parse_ok=True, + dims_ok=dims_ok, + palette_ok=palette_ok, + transparency_ok=transparency_ok, + footprint_ok=footprint_ok, + passed=passed, + errors=errors, + ) + + +def _format_cells(cells: list[tuple[int, int]], limit: int = 5) -> str: + """Format a short, readable sample of (x, y) cell coordinates.""" + shown = ", ".join(f"({x},{y})" for x, y in cells[:limit]) + if len(cells) > limit: + shown += ", ..." + return shown