diff --git a/Cargo.lock b/Cargo.lock index af416a67..f82e1b70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -343,7 +343,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-link 0.2.1", @@ -559,12 +559,24 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1107,6 +1119,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1619,6 +1640,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -1657,6 +1687,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -2495,6 +2536,21 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indenter" version = "0.3.4" @@ -3232,6 +3288,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", ] [[package]] @@ -3286,6 +3353,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multimap" version = "0.10.1" @@ -3936,6 +4013,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -4142,6 +4232,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quinn" version = "0.11.9" @@ -5157,6 +5253,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -7234,11 +7336,18 @@ dependencies = [ "capsule-core", "eyre", "hex", + "image", + "jiff", + "kamadak-exif", "regex", "semver", "serde", "serde_json", + "sha2", + "tempfile", "toml_edit 0.22.27", + "tracing", + "tracing-subscriber", "uuid", ] @@ -7385,8 +7494,29 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index b8f2247c..bb6eea3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,6 +99,10 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } sha2 = "0.10" hex = "0.4" +# Build tooling only: xtask's README screenshot pipeline (seed normalization + compositing). +# Product media decoding stays with Rawshift — see the "Tooling image processing" row in +# design/dependencies.md. +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } [profile.release] lto = "thin" @@ -117,6 +121,18 @@ split-debuginfo = "unpacked" # that is the difference between building and running out of disk. Full builds it is. incremental = false +# xtask's screenshot pipeline decodes and Lanczos-resizes ~40 multi-megapixel photos and blurs a +# 1.5k×3k canvas; unoptimized, that takes minutes instead of seconds. Only build tooling depends +# on these crates, so optimizing them costs nothing elsewhere. +[profile.dev.package.image] +opt-level = 3 +[profile.dev.package.zune-jpeg] +opt-level = 3 +[profile.dev.package.png] +opt-level = 3 +[profile.dev.package.fdeflate] +opt-level = 3 + # The same settings for the test profile, spelled out rather than inherited. # # `profile.test` inherits `debug` from `dev` and does **not** inherit `split-debuginfo`, so every diff --git a/README.ar.md b/README.ar.md index 0b56e50d..e8172a17 100644 --- a/README.ar.md +++ b/README.ar.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ خدمة إدارة ومشاركة صور مفتوحة المصدر، اتحادية، ومشفرة من طرف إلى طرف (E2E)، مصممة للمحترفين والمستخدمين المتقدمين. > تنويه: لا يزال هذا المشروع قيد التطوير النشط. ضع نجمة على هذا المستودع للحصول على آخر التحديثات! @@ -30,10 +37,6 @@ ومع ذلك، من المهم ملاحظة أن Capsule (على الأقل حاليًا) يتطلب خادمًا **مستضافًا ذاتيًا** يستلزم بعض المعرفة التقنية. هذا ليس حلاً جاهزًا للاستخدام الفوري بل مشروع مفتوح المصدر قادر ويُطوَّر بنشاط. لقد أُنشئ بدافع الشغف، لذا فأنا (بصفتي المؤلف) لا أطلب أي تعويض مالي. أفضل شكل من أشكال التعويض هو المساهمات التقنية والملاحظات! -## لقطات الشاشة - - - ## لمن يُصمَّم Capsule؟ - **المصورون**: صُمم Capsule للمصورين الذين يرغبون في تخزين ومشاركة صورهم مع العملاء والزملاء. diff --git a/README.de.md b/README.de.md index d6d113ed..797b90d1 100644 --- a/README.de.md +++ b/README.de.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ Open-Source, föderierter, E2E-verschlüsselter Foto-Verwaltungs- und Freigabedienst, entwickelt für Profis und Prosumer. > Haftungsausschluss: Dieses Projekt befindet sich weiterhin in aktiver Entwicklung. Markieren Sie dieses Repo mit einem Stern, um die neuesten Updates zu erhalten! @@ -30,10 +37,6 @@ Wir setzen strenge Sicherheits- und Datenschutzanforderungen um, unter der Annah Es ist jedoch wichtig zu beachten, dass Capsule (zumindest derzeit) einen **selbst gehosteten** Server erfordert, was einiges an technischem Wissen voraussetzt. Dies ist keine schlüsselfertige Lösung, sondern ein leistungsfähiges und aktiv weiterentwickeltes Open-Source-Projekt. Es entstand aus Leidenschaft, weshalb ich (als Autor) keine finanzielle Entschädigung erwarte. Die beste Form der Entschädigung sind technische Beiträge und Feedback! -## Screenshots - - - ## Für wen ist Capsule gedacht? - **Fotografen**: Capsule wurde für Fotografen entwickelt, die ihre Fotos mit Kunden und Kollegen speichern und teilen möchten. diff --git a/README.es.md b/README.es.md index baad159a..6231e907 100644 --- a/README.es.md +++ b/README.es.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ Servicio de gestión y uso compartido de fotos de código abierto, federado y cifrado E2E, creado para profesionales y prosumidores. > Aviso: Este proyecto continúa en desarrollo activo. ¡Dale una estrella a este repositorio para recibir las últimas actualizaciones! @@ -30,10 +37,6 @@ Implementamos requisitos estrictos de seguridad y privacidad partiendo del supue Sin embargo, es importante señalar que (al menos por ahora) Capsule requiere un servidor **autoalojado**, lo que exige ciertos conocimientos técnicos. No se trata de una solución llave en mano, sino de un proyecto de código abierto capaz y desarrollado activamente. Se creó por pasión, así que yo (como autor) no pido ninguna compensación económica. ¡La mejor forma de compensación son las contribuciones técnicas y los comentarios! -## Capturas de pantalla - - - ## ¿Para quién es Capsule? - **Fotógrafos**: Capsule está diseñado para fotógrafos que quieren almacenar y compartir sus fotos con clientes y colegas. diff --git a/README.fr.md b/README.fr.md index f4c44357..96ad67b5 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ Service de gestion et de partage de photos open source, fédéré et chiffré de bout en bout (E2E), conçu pour les professionnels et les prosumers. > Avertissement : Ce projet est toujours en développement actif. Mettez une étoile à ce dépôt pour recevoir les dernières mises à jour ! @@ -30,10 +37,6 @@ Nous appliquons des exigences strictes en matière de sécurité et de confident Cependant, il est important de noter que (du moins actuellement) Capsule nécessite un serveur **auto-hébergé**, ce qui requiert certaines connaissances techniques. Il ne s'agit pas d'une solution clé en main, mais plutôt d'un projet open source performant et activement développé. Il a été créé par passion, et je (en tant qu'auteur) ne demande donc aucune compensation financière. La meilleure forme de compensation reste les contributions techniques et les retours ! -## Captures d'écran - - - ## À qui s'adresse Capsule ? - **Photographes** : Capsule est conçu pour les photographes qui souhaitent stocker et partager leurs photos avec leurs clients et leurs pairs. diff --git a/README.hi.md b/README.hi.md index 97d00ea6..13f226ab 100644 --- a/README.hi.md +++ b/README.hi.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ पेशेवरों और प्रोज़्यूमर के लिए बनाई गई एक ओपन-सोर्स, फ़ेडरेटेड, E2E एन्क्रिप्टेड फ़ोटो प्रबंधन और साझाकरण सेवा। > अस्वीकरण: यह प्रोजेक्ट अभी भी सक्रिय विकास में है। नवीनतम अपडेट पाने के लिए इस रिपॉज़िटरी को स्टार करें! @@ -30,10 +37,6 @@ Capsule उन फ़ोटोग्राफ़रों और प्रोज हालांकि, यह ध्यान रखना महत्वपूर्ण है कि (कम से कम अभी के लिए) Capsule को एक **सेल्फ़-होस्टेड** सर्वर की आवश्यकता होती है जिसके लिए कुछ तकनीकी ज्ञान चाहिए। यह कोई टर्न-की समाधान नहीं है, बल्कि एक सक्षम और सक्रिय रूप से विकसित हो रहा ओपन-सोर्स प्रोजेक्ट है। यह जुनून से बनाया गया था, इसलिए मैं (लेखक के रूप में) किसी भी आर्थिक मुआवज़े की मांग नहीं करता। मुआवज़े का सबसे अच्छा रूप तकनीकी योगदान और फ़ीडबैक है! -## स्क्रीनशॉट - - - ## Capsule किसके लिए है? - **फ़ोटोग्राफ़र**: Capsule उन फ़ोटोग्राफ़रों के लिए डिज़ाइन किया गया है जो अपनी फ़ोटो को क्लाइंट और साथियों के साथ संग्रहीत और साझा करना चाहते हैं। diff --git a/README.it.md b/README.it.md index 4d624801..fa3a3cb7 100644 --- a/README.it.md +++ b/README.it.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ Servizio di gestione e condivisione di foto open-source, federato e crittografato E2E, creato per professionisti e prosumer. > Avvertenza: questo progetto è ancora in fase di sviluppo attivo. Metti una stella a questo repository per ricevere gli ultimi aggiornamenti! @@ -30,10 +37,6 @@ Implementiamo requisiti rigorosi di sicurezza e privacy partendo dal presupposto Tuttavia, è importante notare che (almeno attualmente) Capsule richiede un server **self-hosted**, il che richiede alcune conoscenze tecniche. Non si tratta di una soluzione chiavi in mano, bensì di un progetto open-source capace e sviluppato attivamente. È stato creato per passione, quindi io (come autore) non chiedo alcun compenso monetario. La migliore forma di compenso sono i contributi tecnici e i feedback! -## Screenshot - - - ## A chi è rivolto Capsule? - **Fotografi**: Capsule è pensata per i fotografi che vogliono archiviare e condividere le proprie foto con clienti e colleghi. diff --git a/README.ja.md b/README.ja.md index bde0e1fa..e517d88c 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ プロフェッショナルおよびプロシューマー向けに構築された、オープンソースでフェデレーテッドな、E2E暗号化された写真管理・共有サービスです。 > 免責事項: このプロジェクトは現在も活発に開発が進められています。最新情報を受け取るには、このリポジトリにスターを付けてください! @@ -30,10 +37,6 @@ Capsuleは、すべてのデバイスで同じように快適に動作すると ただし、(少なくとも現時点では)Capsuleには**セルフホスト**型のサーバーが必要であり、ある程度の技術的知識が求められる点に注意してください。これはターンキー型のソリューションではなく、能力が高く積極的に開発が続けられているオープンソースプロジェクトです。このプロジェクトは情熱から生まれたものであり、作者である私は金銭的な対価を一切求めていません。最良の対価は、技術的な貢献とフィードバックです! -## スクリーンショット - - - ## Capsuleは誰のためのものですか? - **写真家**: Capsuleは、クライアントや仲間と写真を保存・共有したい写真家のために設計されています。 diff --git a/README.ko.md b/README.ko.md index b2b1f1cc..e187620c 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ 전문가와 프로슈머를 위해 만들어진 오픈소스, 연합형, E2E 암호화 사진 관리 및 공유 서비스입니다. > 면책 조항: 이 프로젝트는 계속해서 활발히 개발 중입니다. 최신 소식을 받아보려면 이 저장소에 Star를 눌러주세요! @@ -30,10 +37,6 @@ Capsule은 모든 기기에서 반드시 동일하게 잘 작동하지는 않으 다만 (적어도 현재는) Capsule이 어느 정도의 기술적 지식이 필요한 **셀프 호스팅** 서버를 필요로 한다는 점은 중요하게 알아두셔야 합니다. 이는 즉시 사용 가능한 솔루션이 아니라 역량 있고 활발하게 개발되고 있는 오픈소스 프로젝트입니다. 이 프로젝트는 열정으로 만들어졌으며, 저(작성자)는 어떠한 금전적 대가도 요구하지 않습니다. 가장 좋은 형태의 보상은 기술적 기여와 피드백입니다! -## 스크린샷 - - - ## Capsule은 누구를 위한 것인가요? - **사진작가**: Capsule은 고객 및 동료와 사진을 저장하고 공유하고자 하는 사진작가를 위해 설계되었습니다. diff --git a/README.md b/README.md index 8383e18f..6383da67 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,12 @@ # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ Open-source, federated, E2E encrypted photo management and sharing service built for professionals and prosumers. > Disclaimer: This project continues to be in active development. Star this repo to get the latest updates! @@ -27,10 +34,6 @@ We implement strict security and privacy requirements with the assumption that a However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback! -## Screenshots - - - ## Who is Capsule for? - **Photographers**: Capsule is designed for photographers who want to store and share their photos with clients and peers. diff --git a/README.pt-BR.md b/README.pt-BR.md index 4a27f4dd..0a984d6d 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ Serviço de gerenciamento e compartilhamento de fotos de código aberto, federado e criptografado E2E, criado para profissionais e prosumers. > Aviso: Este projeto continua em desenvolvimento ativo. Dê uma estrela neste repositório para receber as últimas atualizações! @@ -30,10 +37,6 @@ Implementamos requisitos rigorosos de segurança e privacidade partindo do press No entanto, é importante notar que (pelo menos atualmente) o Capsule requer um servidor **auto-hospedado**, o que exige algum conhecimento técnico. Esta não é uma solução pronta para uso, mas sim um projeto de código aberto capaz e em desenvolvimento ativo. Ele foi criado por paixão e, por isso, eu (como autor) não peço nenhuma compensação monetária. A melhor forma de compensação é contribuições técnicas e feedback! -## Capturas de tela - - - ## Para quem é o Capsule? - **Fotógrafos**: O Capsule foi projetado para fotógrafos que desejam armazenar e compartilhar suas fotos com clientes e colegas. diff --git a/README.ru.md b/README.ru.md index 172470b9..5d06559b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ Сервис управления и обмена фотографиями с открытым исходным кодом, федеративный, со сквозным (E2E) шифрованием, созданный для профессионалов и продвинутых пользователей. > Отказ от ответственности: Проект продолжает активно разрабатываться. Поставьте репозиторию звезду, чтобы получать последние обновления! @@ -30,10 +37,6 @@ Capsule тщательно доработан для фотографов и п Однако важно отметить, что (по крайней мере на данный момент) Capsule требует **самостоятельно развёрнутого (self-hosted)** сервера, что предполагает определённые технические знания. Это не готовое «под ключ» решение, а скорее функциональный и активно развивающийся проект с открытым исходным кодом. Он создан из увлечения, поэтому я (как автор) не прошу никакой денежной компенсации. Лучшая форма благодарности — технический вклад и обратная связь! -## Скриншоты - - - ## Для кого предназначен Capsule? - **Фотографы**: Capsule предназначен для фотографов, которые хотят хранить свои фотографии и делиться ими с клиентами и коллегами. diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 29c9c8e2..545c706e 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ 为专业人士和高端消费者打造的开源、联邦式、E2E 加密照片管理与共享服务。 > 免责声明:本项目仍在积极开发中。为本仓库加星标以获取最新动态! @@ -30,10 +37,6 @@ Capsule 专为摄影师和高端消费者精心打磨,让他们几乎能以云 不过需要注意的是,(至少目前)Capsule 需要一台**自托管**的服务器,这需要一定的技术知识。这并非一个开箱即用的解决方案,而是一个功能强大、正在积极开发中的开源项目。它诞生于热爱,因此我(作为作者)不要求任何金钱报酬。最好的回报形式是技术贡献和反馈! -## 截图 - - - ## Capsule 适合谁使用? - **摄影师**:Capsule 专为希望与客户和同行存储、分享照片的摄影师而设计。 diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 33bbab5a..98c4377f 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -1,8 +1,15 @@ - + # Capsule +

+ + + Capsule on iPhone: the Library timeline showing a grid of photos + +

+ 開放原始碼、聯邦式、E2E 加密的相片管理與分享服務,專為專業人士與進階玩家打造。 > 免責聲明:本專案仍在積極開發中。請為此儲存庫加星號以獲取最新更新! @@ -30,10 +37,6 @@ Capsule 為攝影師與進階玩家精心打造,讓你能像使用雲端相片 不過,值得注意的是(至少目前而言)Capsule 需要**自行架設**伺服器,這需要一定的技術知識。這並非開箱即用的解決方案,而是一個功能完善、持續積極開發的開源專案。它是出於熱情而誕生的,因此身為作者的我不要求任何金錢報酬。最好的回報形式就是技術貢獻與意見回饋! -## 截圖 - - - ## Capsule 適合誰使用? - **攝影師**:Capsule 專為想要與客戶及同儕儲存並分享相片的攝影師而設計。 diff --git a/capsule-docs/src/content/docs/design/dependencies.md b/capsule-docs/src/content/docs/design/dependencies.md index 2ef22ed4..53bc1062 100644 --- a/capsule-docs/src/content/docs/design/dependencies.md +++ b/capsule-docs/src/content/docs/design/dependencies.md @@ -47,6 +47,7 @@ Mechanically, every Rust version is pinned once in the root `Cargo.toml` `[works | CBOR | `ciborium` (+ `serde_bytes`, `half`) | All CBOR; the canonical-encoding rules are owned by [Metadata — Canonical CBOR Encoding](/design/metadata/#canonical-cbor-encoding). | — | | Serialization | `serde` + `serde_json` | Derives and JSON surfaces. | — | | CLI | `clap` (derive) | `capsule-cli`, xtask argument parsing where non-trivial. | — | +| Tooling image processing | `image` (`jpeg` + `png` features only) | **Build tooling only**: `xtask screenshot-seed` / `screenshot-compose`, the README hero screenshot pipeline (`mise run screenshot-ios`) — decode, orient and resize the seed photos, composite the capture. Seed downloads shell out to `curl`, so xtask gains no HTTP stack. | No product crate may depend on it: product media decoding, metadata and derivatives belong to Rawshift (`AGENTS.md`). | | Test runner | `cargo-nextest`; `testcontainers` (+ podman) for real backing services | The Unit/Smoke tiers per [Principles — Validation Tiers](/design/principles/#validation-tiers). Binary-level smokes spawn the built binary through Cargo’s own `CARGO_BIN_EXE_` from a `tests/` target (`capsule-cli/tests/cull_round_trip.rs`, slice S-D16) — a real process boundary with no crate to add. | No `assert_cmd`/`predicates`: they would buy assertion sugar over `std::process::Command`, not capability, and the minimalism rule does not trade a dependency for sugar. | | FFI bindings | `uniffi` | One workspace version — consolidation is slice S-F1. | — | | Browser FFI (WASM) | `wasm-bindgen` (+ `wasm-bindgen-cli`, mise-pinned) | `capsule-wasm` — the guest web client's share-link open surface (slice S-E1): parse the URL fragment secret, Argon2id passphrase unwrap, scope decapsulation, STREAM blob decrypt. Compiles `capsule-core` (`--no-default-features`: `crypto` + `cbor` + `sharing`) to `wasm32-unknown-unknown`; `wasm-bindgen-cli` (the `build-wasm` mise task, `--target web`) post-processes the cdylib into the browser JS glue. The CLI and crate are pinned to the **same** version (`0.2.100`) — wasm-bindgen requires it. Randomness rides the getrandom `wasm_js` backend already pinned in `.cargo/config.toml`. | Not wasm-pack: it fetches a version-matched wasm-bindgen at run time, breaking the offline gate; the `cargo build` + `wasm-bindgen` path uses only pre-installed toolchains. | diff --git a/capsule-swift/README.md b/capsule-swift/README.md index 4cd06577..7d9128b0 100644 --- a/capsule-swift/README.md +++ b/capsule-swift/README.md @@ -70,6 +70,28 @@ mise exec -- tuist generate # generates Capsule.xcworkspace Re-run `mise run build-ffi-apple` whenever the Rust core changes. The generated Xcode project/workspace and the `.ffi/` build output are not committed. +## Regenerating the README Screenshot + +`mise run screenshot-ios` regenerates `images/readme-hero-{light,dark}.png`, the +hero image at the top of the repo README: + +1. Fetches the CC0 seed photos pinned in `xtask/screenshot/seed.toml` into + `target/screenshot/cache/`. The first run downloads about 335 MB; later + runs use the cache. +2. Builds the app and erases a dedicated `Capsule README` simulator (iPhone + 17 Pro, newest iOS 26.x). It then imports the photos, grants Photos access, + and pins the status bar to 9:41. +3. Captures the Library timeline in light and dark, then composites each + capture with its screen mask and a soft shadow. + +Re-running is safe. Every run starts from an erased simulator, and the seed set +is byte-reproducible for a given anchor day on a given machine (the task prints +its digest). Capture dates are relative to the run date, and the app titles +sections against the real clock, so a run on a different day re-dates the +headers. A run in early January warns when some sections would carry a year +suffix. To change the photos, edit the manifest. Its header explains the +ordering and date rules, and a unit test enforces them. + ## Running on the iOS Simulator After `mise run setup-swift`, pick a simulator diff --git a/images/readme-hero-dark.png b/images/readme-hero-dark.png new file mode 100644 index 00000000..b194e041 Binary files /dev/null and b/images/readme-hero-dark.png differ diff --git a/images/readme-hero-light.png b/images/readme-hero-light.png new file mode 100644 index 00000000..9451ce1e Binary files /dev/null and b/images/readme-hero-light.png differ diff --git a/mise-tasks/screenshot-ios b/mise-tasks/screenshot-ios new file mode 100755 index 00000000..8817792d --- /dev/null +++ b/mise-tasks/screenshot-ios @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +#MISE description="Capture the README hero screenshot (light + dark) from a seeded iOS simulator (macOS only)" +#MISE depends=["build-ffi-apple"] +# Safe to re-run: the dedicated simulator is erased every run, the seed set is byte-reproducible +# for a given anchor day on a given machine (xtask screenshot-seed prints its digest), and +# composition is deterministic. Re-running overwrites images/readme-hero-{light,dark}.png. The +# rendered section headers follow the real date, so a run on a different day re-dates them. +# +# Knobs (environment): SCREENSHOT_DEVICE_TYPE (default "iPhone 17 Pro"), SCREENSHOT_IOS_MAJOR +# (default 26), SCREENSHOT_ANCHOR (YYYY-MM-DD capture-date anchor, default today). +set -euo pipefail + +if [ "$(uname)" != "Darwin" ]; then + echo "screenshot-ios needs macOS with Xcode and an iOS simulator runtime; nothing was captured." >&2 + exit 1 +fi +for tool in xcrun xcodebuild jq shasum; do + command -v "$tool" >/dev/null || { echo "screenshot-ios: missing required tool: $tool" >&2; exit 1; } +done + +readonly BUNDLE_ID="com.justin13888.capsule.Capsule" +readonly DEVICE_NAME="Capsule README" +readonly DEVICE_TYPE="${SCREENSHOT_DEVICE_TYPE:-iPhone 17 Pro}" +readonly IOS_MAJOR="${SCREENSHOT_IOS_MAJOR:-26}" +readonly WORK="target/screenshot" +readonly DERIVED="$WORK/DerivedData" +readonly APP="$DERIVED/Build/Products/Debug-iphonesimulator/Capsule.app" +# Photos, thumbnails and Liquid Glass settle asynchronously: capture until two consecutive frames +# match, but never sooner than SETTLE_MIN_S after launch and never later than SETTLE_MAX_S. +readonly SETTLE_MIN_S=6 +readonly SETTLE_MAX_S=30 + +step_started=$SECONDS +step() { + echo "==> [$((SECONDS - step_started))s] $*" +} + +step "seed: fetch, verify and normalize the manifest photos" +anchor_args=() +[ -n "${SCREENSHOT_ANCHOR:-}" ] && anchor_args=(--anchor "$SCREENSHOT_ANCHOR") +seed_digest=$(cargo run -q -p xtask -- screenshot-seed "${anchor_args[@]+"${anchor_args[@]}"}") +echo " seed digest: $seed_digest" + +step "build: tuist generate + xcodebuild (Debug, iOS Simulator)" +(cd capsule-swift && mise exec -- tuist generate --no-open) +xcodebuild -workspace capsule-swift/Capsule.xcworkspace -scheme Capsule -configuration Debug \ + -destination 'generic/platform=iOS Simulator' -derivedDataPath "$DERIVED" \ + CODE_SIGNING_ALLOWED=NO build | (cd capsule-swift && mise exec -- xcbeautify) +[ -d "$APP" ] || { echo "screenshot-ios: build produced no app at $APP" >&2; exit 1; } + +step "simulator: resolve iOS $IOS_MAJOR.x runtime and $DEVICE_TYPE" +runtime=$(xcrun simctl list runtimes available -j | jq -r --arg major "$IOS_MAJOR." ' + [.runtimes[] | select(.platform == "iOS" and (.version | startswith($major)))] + | sort_by(.version | split(".") | map(tonumber)) | last | .identifier // empty') +if [ -z "$runtime" ]; then + echo "screenshot-ios: no iOS $IOS_MAJOR.x simulator runtime installed; try: xcodebuild -downloadPlatform iOS" >&2 + exit 1 +fi +device_type=$(xcrun simctl list devicetypes -j | jq -r --arg name "$DEVICE_TYPE" ' + .devicetypes[] | select(.name == $name) | .identifier' | head -n1) +if [ -z "$device_type" ]; then + echo "screenshot-ios: this Xcode has no \"$DEVICE_TYPE\" device type; update Xcode or set SCREENSHOT_DEVICE_TYPE" >&2 + exit 1 +fi +udid=$(xcrun simctl list devices -j | jq -r --arg rt "$runtime" --arg name "$DEVICE_NAME" --arg dt "$device_type" ' + .devices[$rt][]? | select(.name == $name and .deviceTypeIdentifier == $dt) | .udid' | head -n1) +if [ -z "$udid" ]; then + udid=$(xcrun simctl create "$DEVICE_NAME" "$device_type" "$runtime") + echo " created $DEVICE_NAME ($udid)" +fi +echo " device $udid ($device_type, $runtime)" +# A runtime upgrade leaves the previous run's device behind; it is ours, so reclaim it. +xcrun simctl list devices -j | jq -r --arg name "$DEVICE_NAME" --arg keep "$udid" ' + .devices[][] | select(.name == $name and .udid != $keep) | .udid' | + while read -r stale; do + echo " deleting stale $DEVICE_NAME device $stale" + xcrun simctl delete "$stale" + done + +cleanup() { + xcrun simctl status_bar "$udid" clear >/dev/null 2>&1 || true + xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +step "simulator: erase to a clean state and boot" +xcrun simctl shutdown "$udid" >/dev/null 2>&1 || true +xcrun simctl erase "$udid" +xcrun simctl boot "$udid" +xcrun simctl bootstatus "$udid" -b >/dev/null + +step "simulator: install app, grant Photos, import seed, set timeline density" +xcrun simctl install "$udid" "$APP" +xcrun simctl privacy "$udid" grant photos "$BUNDLE_ID" +xcrun simctl addmedia "$udid" "$WORK"/seed/*.jpg +# Typed write: launch-argument defaults arrive as strings, and the app reads an Int. +xcrun simctl spawn "$udid" defaults write "$BUNDLE_ID" timeline.columnCount -int 3 +xcrun simctl status_bar "$udid" override --time "9:41" \ + --dataNetwork wifi --wifiMode active --wifiBars 3 \ + --cellularMode active --cellularBars 4 --operatorName "" \ + --batteryState discharging --batteryLevel 100 + +capture_settled() { + local out="$1" prev="" hash elapsed=0 frame="$WORK/frame.png" + sleep "$SETTLE_MIN_S" + while [ "$elapsed" -lt "$SETTLE_MAX_S" ]; do + xcrun simctl io "$udid" screenshot --type=png --mask=alpha "$frame" >/dev/null + hash=$(shasum -a 256 "$frame" | cut -d' ' -f1) + if [ "$hash" = "$prev" ]; then + mv "$frame" "$out" + return 0 + fi + prev=$hash + sleep 1 + elapsed=$((elapsed + 1)) + done + echo "screenshot-ios: screen never settled within ${SETTLE_MAX_S}s ($out)" >&2 + return 1 +} + +for appearance in light dark; do + step "capture: $appearance" + xcrun simctl ui "$udid" appearance "$appearance" + xcrun simctl launch --terminate-running-process "$udid" "$BUNDLE_ID" \ + -AppleLanguages "(en)" -AppleLocale en_US >/dev/null + capture_settled "$WORK/raw-$appearance.png" + cargo run -q -p xtask -- screenshot-compose \ + --input "$WORK/raw-$appearance.png" --output "images/readme-hero-$appearance.png" +done + +step "done: images/readme-hero-light.png, images/readme-hero-dark.png (seed digest $seed_digest)" diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 59e58657..1c1d6d8d 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -16,5 +16,16 @@ regex = "1" semver = "1" serde = { workspace = true } serde_json = "1" -toml_edit = "0.22" +toml_edit = { version = "0.22", features = ["serde"] } uuid = { workspace = true } +# `screenshot-seed` / `screenshot-compose`: the README hero screenshot pipeline. +image = { workspace = true } +jiff = { workspace = true } +sha2 = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +# Reads back the EXIF block `screenshot-seed` writes (already a capsule-core dependency). +kamadak-exif = "0.5" +tempfile = "3" diff --git a/xtask/screenshot/seed.toml b/xtask/screenshot/seed.toml new file mode 100644 index 00000000..ffb95249 --- /dev/null +++ b/xtask/screenshot/seed.toml @@ -0,0 +1,447 @@ +# Seed photos for the README hero screenshot (`mise run screenshot-ios`). +# +# Every photo is CC0 (Unsplash's pre-2017 licence, mirrored on Wikimedia Commons) and pinned by +# the SHA-256 of its original file; `xtask screenshot-seed` refuses bytes that do not match. +# +# Listed newest first, exactly as the Library timeline renders them: the first rows here are the +# first rows of the screenshot. Photos are grouped into capture days of 3 or 6 so every 3-column +# row is full. `day_offset` counts days before the run date (see `manifest.rs` for why dates are +# relative); `time` is local wall-clock time on that day. + +# ── Golden hour on the coast (2 days ago) ── + +[[photo]] +id = "rocky-shore-dusk" +day_offset = 2 +time = "17:40" +url = "https://upload.wikimedia.org/wikipedia/commons/8/86/Daniel_Mallek_2014-10-08_%28Unsplash%29.jpg" +sha256 = "7b4b231efbfb5e525e5c687843388c3984d35f6eee16e4f74b49a7ab92dfea20" +source_page = "https://commons.wikimedia.org/wiki/File:Daniel_Mallek_2014-10-08_(Unsplash).jpg" +author = "Daniel Mallek" +license = "CC0-1.0" + +[[photo]] +id = "beach-sunset-surf" +day_offset = 2 +time = "17:33" +url = "https://upload.wikimedia.org/wikipedia/commons/8/85/Sunset_over_the_beach_%28Unsplash%29.jpg" +sha256 = "d3165fc31be7a88435fc725bd6d9dc8e1f95a8ec085dbd0441dbb244569ccb5f" +source_page = "https://commons.wikimedia.org/wiki/File:Sunset_over_the_beach_(Unsplash).jpg" +author = "Bagus Ghufron" +license = "CC0-1.0" + +[[photo]] +id = "tidal-flats-sunset" +day_offset = 2 +time = "17:26" +url = "https://upload.wikimedia.org/wikipedia/commons/8/85/Moody_beach_sunset._%28Unsplash%29.jpg" +sha256 = "57b9294a899d3c08b20d95e4ded70ab181e1699fd14457351e1eb8a2f3cce609" +source_page = "https://commons.wikimedia.org/wiki/File:Moody_beach_sunset._(Unsplash).jpg" +author = "Hugo Kemmel" +license = "CC0-1.0" + +[[photo]] +id = "long-exposure-rocks" +day_offset = 2 +time = "17:19" +url = "https://upload.wikimedia.org/wikipedia/commons/4/4f/Foamy_shore_%28Unsplash%29.jpg" +sha256 = "f9e92bb701475ba96f6bb87150e61713861a85215505b47e9495cfe8ad3f5635" +source_page = "https://commons.wikimedia.org/wiki/File:Foamy_shore_(Unsplash).jpg" +author = "Jonathan Bean" +license = "CC0-1.0" + +[[photo]] +id = "kayak-bow-dusk" +day_offset = 2 +time = "17:12" +url = "https://upload.wikimedia.org/wikipedia/commons/1/1f/Carl_Heyerdahl_2016-01-06_%28Unsplash_V1BxFWI84-k%29.jpg" +sha256 = "08b8a1715ea4909817199411259b3d9a9ec87a145e61457ad1df4de49f09aa56" +source_page = "https://commons.wikimedia.org/wiki/File:Carl_Heyerdahl_2016-01-06_(Unsplash_V1BxFWI84-k).jpg" +author = "Carl Heyerdahl" +license = "CC0-1.0" + +[[photo]] +id = "aerial-breaking-wave" +day_offset = 2 +time = "17:05" +url = "https://upload.wikimedia.org/wikipedia/commons/4/4d/Palm_Coast%2C_United_States_%28Unsplash%29.jpg" +sha256 = "c07b565f173847fbc628bc4417219a68bdf305e6b14e3578530313b67545b3ab" +source_page = "https://commons.wikimedia.org/wiki/File:Palm_Coast,_United_States_(Unsplash).jpg" +author = "Lance Asper" +license = "CC0-1.0" + +# ── Bakery morning (4 days ago) ── + +[[photo]] +id = "sourdough-on-board" +day_offset = 4 +time = "08:15" +url = "https://upload.wikimedia.org/wikipedia/commons/b/ba/Ben_Garratt_2016_%28Unsplash%29.jpg" +sha256 = "065d19af1be896993cd506af15bc9dc36506948dc8f10481c8ee2ea5d23359d8" +source_page = "https://commons.wikimedia.org/wiki/File:Ben_Garratt_2016_(Unsplash).jpg" +author = "Ben Garratt" +license = "CC0-1.0" + +[[photo]] +id = "cappuccino-and-beans" +day_offset = 4 +time = "08:08" +url = "https://upload.wikimedia.org/wikipedia/commons/6/68/Galway_City%2C_Ireland_%28Unsplash%29.jpg" +sha256 = "b0ba56fe504f35499d080ff6153c356b8a675f2e8ec71f4860ec7a9793cb16a5" +source_page = "https://commons.wikimedia.org/wiki/File:Galway_City,_Ireland_(Unsplash).jpg" +author = "fireskystudios.com" +license = "CC0-1.0" + +[[photo]] +id = "floured-loaves" +day_offset = 4 +time = "08:01" +url = "https://upload.wikimedia.org/wikipedia/commons/4/4d/London%2C_United_Kingdom_%28Unsplash_oQh4FabMPs0%29.jpg" +sha256 = "170bad310cd86b2ea131006f243d47a8f033dfb591f1f3baf2cf9d97492c48cc" +source_page = "https://commons.wikimedia.org/wiki/File:London,_United_Kingdom_(Unsplash_oQh4FabMPs0).jpg" +author = "Toa Heftiba" +license = "CC0-1.0" + +# ── Misty morning (6 days ago) ── + +[[photo]] +id = "fence-in-morning-fog" +day_offset = 6 +time = "06:50" +url = "https://upload.wikimedia.org/wikipedia/commons/d/d2/Fog_In_Desertland_%28Unsplash%29.jpg" +sha256 = "335046e50b931b9bf20158cca48b10e35befffa09ba8a180f7b7c820deeb3eac" +source_page = "https://commons.wikimedia.org/wiki/File:Fog_In_Desertland_(Unsplash).jpg" +author = "Carsten Stalljohann" +license = "CC0-1.0" + +[[photo]] +id = "foggy-hillside" +day_offset = 6 +time = "06:43" +url = "https://upload.wikimedia.org/wikipedia/commons/2/2a/Fog_In_Desolate_Fields_%28Unsplash%29.jpg" +sha256 = "eae7c7b092d9c46e07955a3d40d709b7169a0d1745406098cf2d5a48765663d0" +source_page = "https://commons.wikimedia.org/wiki/File:Fog_In_Desolate_Fields_(Unsplash).jpg" +author = "Michael Rosner-Hyman" +license = "CC0-1.0" + +[[photo]] +id = "treetops-above-fog" +day_offset = 6 +time = "06:36" +url = "https://upload.wikimedia.org/wikipedia/commons/4/43/Fog_over_the_countryside_%28Unsplash%29.jpg" +sha256 = "beff8392034a0d8c378c4cc3021127c2fcada046678559584f84a759e7681f37" +source_page = "https://commons.wikimedia.org/wiki/File:Fog_over_the_countryside_(Unsplash).jpg" +author = "Ricardo Gomez Angel" +license = "CC0-1.0" + +[[photo]] +id = "boulder-in-still-water" +day_offset = 6 +time = "06:29" +url = "https://upload.wikimedia.org/wikipedia/commons/6/65/Flying_Through_Fog_%28Unsplash%29.jpg" +sha256 = "256bad0219f718fef7a4be0ab89aa3bb02e4c3e64e065842de5f350c7d326b72" +source_page = "https://commons.wikimedia.org/wiki/File:Flying_Through_Fog_(Unsplash).jpg" +author = "Mario Azzi" +license = "CC0-1.0" + +[[photo]] +id = "rowboats-in-mist" +day_offset = 6 +time = "06:22" +url = "https://upload.wikimedia.org/wikipedia/commons/4/4b/Placid_lake_boats_%28Unsplash%29.jpg" +sha256 = "991385dbd6f77cd38630ea7d6f6a985d7c999ba4652dcfd7fd31855f3d5f8c95" +source_page = "https://commons.wikimedia.org/wiki/File:Placid_lake_boats_(Unsplash).jpg" +author = "Mario Ho" +license = "CC0-1.0" + +[[photo]] +id = "forest-in-fog" +day_offset = 6 +time = "06:15" +url = "https://upload.wikimedia.org/wikipedia/commons/b/b9/Transmission_towers_in_fog_%28Unsplash%29.jpg" +sha256 = "91f136e712c4cd8f0d8b45b25f6996e08aa787f1120fda658fe2b316a6f72923" +source_page = "https://commons.wikimedia.org/wiki/File:Transmission_towers_in_fog_(Unsplash).jpg" +author = "Neven Krcmarek" +license = "CC0-1.0" + +# ── Alpine lakes (9 days ago) ── + +[[photo]] +id = "alpenglow-peak" +day_offset = 9 +time = "10:30" +url = "https://upload.wikimedia.org/wikipedia/commons/8/85/Burning_Mountain_%28Unsplash%29.jpg" +sha256 = "a61a556f33f79d2a17f42d6cbf531c8668007dc70bfff65c68409a7646ace9c5" +source_page = "https://commons.wikimedia.org/wiki/File:Burning_Mountain_(Unsplash).jpg" +author = "Kaidi Guo" +license = "CC0-1.0" + +[[photo]] +id = "lake-wanaka-ridges" +day_offset = 9 +time = "10:23" +url = "https://upload.wikimedia.org/wikipedia/commons/b/bb/Large_mountain_lake_%28Unsplash%29.jpg" +sha256 = "790f2b239dfbc7d63880e853e09a85b19e578c0176d71b57fac40d65db319051" +source_page = "https://commons.wikimedia.org/wiki/File:Large_mountain_lake_(Unsplash).jpg" +author = "Robert Magnusson" +license = "CC0-1.0" + +[[photo]] +id = "mountain-lake-reflection" +day_offset = 9 +time = "10:16" +url = "https://upload.wikimedia.org/wikipedia/commons/b/b7/Flat_mountain_over_a_lake_%28Unsplash%29.jpg" +sha256 = "db158f8636aa2df90a99990fdd960b576b250dde5acb025f1abb0ae635f48a54" +source_page = "https://commons.wikimedia.org/wiki/File:Flat_mountain_over_a_lake_(Unsplash).jpg" +author = "Cameron Kirby" +license = "CC0-1.0" + +[[photo]] +id = "alpine-meadow" +day_offset = 9 +time = "10:09" +url = "https://upload.wikimedia.org/wikipedia/commons/f/fa/The_French_Alps_%28Unsplash%29.jpg" +sha256 = "5a7c3dba15a7989b8ce209b15fe2b0c6bd19043c305759e9c8b2c65c51e09b4f" +source_page = "https://commons.wikimedia.org/wiki/File:The_French_Alps_(Unsplash).jpg" +author = "Thomas Jarrand" +license = "CC0-1.0" + +[[photo]] +id = "pink-snow-ridge" +day_offset = 9 +time = "10:02" +url = "https://upload.wikimedia.org/wikipedia/commons/d/da/Red_sunset_on_a_mountain_%28Unsplash%29.jpg" +sha256 = "97a6a4b79986327e519dec7c5acf0c9e77398b56e5c4dec4d8bd7b0c9278af80" +source_page = "https://commons.wikimedia.org/wiki/File:Red_sunset_on_a_mountain_(Unsplash).jpg" +author = "Nathan Anderson" +license = "CC0-1.0" + +[[photo]] +id = "misty-lake-reflection" +day_offset = 9 +time = "09:55" +url = "https://upload.wikimedia.org/wikipedia/commons/e/e3/Lake_Oeschinen_%28Unsplash%29.jpg" +sha256 = "7ca9cf1700a89e86f0ab93ad0019d39b541de13967bfca25d015c229303d5a38" +source_page = "https://commons.wikimedia.org/wiki/File:Lake_Oeschinen_(Unsplash).jpg" +author = "Kimon Maritz" +license = "CC0-1.0" + +# ── City afternoon (13 days ago) ── + +[[photo]] +id = "golden-facade" +day_offset = 13 +time = "14:10" +url = "https://upload.wikimedia.org/wikipedia/commons/c/c1/Architecture_in_London_%28Unsplash%29.jpg" +sha256 = "d110763e704d10335a7bb8e783d14b79d8b66bde958b0abe846fda9cbe9fc49d" +source_page = "https://commons.wikimedia.org/wiki/File:Architecture_in_London_(Unsplash).jpg" +author = "Karel Vokřál" +license = "CC0-1.0" + +[[photo]] +id = "light-and-concrete" +day_offset = 13 +time = "14:03" +url = "https://upload.wikimedia.org/wikipedia/commons/c/cc/Modern_architecture_%28Unsplash%29.jpg" +sha256 = "ca97ab8272e1349a8e3853562bb3009d898430d82ba89ba1138c8dd592606f67" +source_page = "https://commons.wikimedia.org/wiki/File:Modern_architecture_(Unsplash).jpg" +author = "Mihail Ribkin" +license = "CC0-1.0" + +[[photo]] +id = "orange-cafe-chairs" +day_offset = 13 +time = "13:56" +url = "https://upload.wikimedia.org/wikipedia/commons/7/78/Coffee_shop_entrance_in_Leiden_%28Unsplash%29.jpg" +sha256 = "f83bdbd4d48f3a957105e6dfcf83cb4f976b0495e182bc3f485ea9b53c37e954" +source_page = "https://commons.wikimedia.org/wiki/File:Coffee_shop_entrance_in_Leiden_(Unsplash).jpg" +author = "Alisa Anton" +license = "CC0-1.0" + +# ── Desert dusk (17 days ago) ── + +[[photo]] +id = "dunes-at-sunset" +day_offset = 17 +time = "18:20" +url = "https://upload.wikimedia.org/wikipedia/commons/3/31/Sahara_Desert_%28Unsplash_c5X870PwmRE%29.jpg" +sha256 = "fd50c9eac73ae3ab1567e5bba35ddc1229404d164522367f39baa16e4c3e6e50" +source_page = "https://commons.wikimedia.org/wiki/File:Sahara_Desert_(Unsplash_c5X870PwmRE).jpg" +author = "Azer Koçulu" +license = "CC0-1.0" + +[[photo]] +id = "rippled-dune" +day_offset = 17 +time = "18:13" +url = "https://upload.wikimedia.org/wikipedia/commons/0/09/Desert_%28Unsplash%29.jpg" +sha256 = "75f7ee42b7822281b138aab1a0fcd8c78cef193c63768ef63332ca3251bc2a62" +source_page = "https://commons.wikimedia.org/wiki/File:Desert_(Unsplash).jpg" +author = "Piotr Chrobot" +license = "CC0-1.0" + +[[photo]] +id = "dunes-below-mountains" +day_offset = 17 +time = "18:06" +url = "https://upload.wikimedia.org/wikipedia/commons/5/52/Lonely_Desert_Hike_%28Unsplash%29.jpg" +sha256 = "9747c9c421338e83679dab3c18e3f06c980b9a33efd02424c47e8e6d48a1709e" +source_page = "https://commons.wikimedia.org/wiki/File:Lonely_Desert_Hike_(Unsplash).jpg" +author = "Andrew DesLauriers" +license = "CC0-1.0" + +[[photo]] +id = "van-on-dusty-road" +day_offset = 17 +time = "17:59" +url = "https://upload.wikimedia.org/wikipedia/commons/e/e6/Road_dust_under_setting_sun_%28Unsplash%29.jpg" +sha256 = "8bd80940bc44732b8772bcb2e6d1e3440a2976f9c7fb3f4831f3a59149f2c4a9" +source_page = "https://commons.wikimedia.org/wiki/File:Road_dust_under_setting_sun_(Unsplash).jpg" +author = "FORREST CAVALE" +license = "CC0-1.0" + +[[photo]] +id = "great-dune" +day_offset = 17 +time = "17:52" +url = "https://upload.wikimedia.org/wikipedia/commons/8/8b/Hiking_Through_Sand_Dunes_%28Unsplash%29.jpg" +sha256 = "c413d4f5ab60e34282f657fd5c17d80e6b3f620a0ad8a6c40b4a52707c5b3996" +source_page = "https://commons.wikimedia.org/wiki/File:Hiking_Through_Sand_Dunes_(Unsplash).jpg" +author = "Keith Hardy" +license = "CC0-1.0" + +[[photo]] +id = "desert-sunrise" +day_offset = 17 +time = "17:45" +url = "https://upload.wikimedia.org/wikipedia/commons/b/b0/Desert_Sunrise_%28Unsplash%29.jpg" +sha256 = "37cce31855a0ad445146a4dbfa87ee129c54b860d165bb1a7a205eb60551f084" +source_page = "https://commons.wikimedia.org/wiki/File:Desert_Sunrise_(Unsplash).jpg" +author = "Red Morley Hewitt" +license = "CC0-1.0" + +# ── Still life at home (22 days ago) ── + +[[photo]] +id = "stems-in-a-vase" +day_offset = 22 +time = "11:00" +url = "https://upload.wikimedia.org/wikipedia/commons/a/a1/Flowers_-_2_%28Unsplash%29.jpg" +sha256 = "f8f254638d33cbb3c084c6be699d7ff405e1e0b4de4943ab28a93bc50d0d33a1" +source_page = "https://commons.wikimedia.org/wiki/File:Flowers_-_2_(Unsplash).jpg" +author = "Maarten Deckers" +license = "CC0-1.0" + +[[photo]] +id = "plant-on-the-sill" +day_offset = 22 +time = "10:53" +url = "https://upload.wikimedia.org/wikipedia/commons/3/36/Wicker_plant_and_big_window_%28Unsplash%29.jpg" +sha256 = "843b1f85ae82c5bf085560f8fbebcba146680c26587e929f7f6553f9568833c1" +source_page = "https://commons.wikimedia.org/wiki/File:Wicker_plant_and_big_window_(Unsplash).jpg" +author = "Amanda Mocci" +license = "CC0-1.0" + +[[photo]] +id = "bouquet-in-a-jug" +day_offset = 22 +time = "10:46" +url = "https://upload.wikimedia.org/wikipedia/commons/0/07/Valentine%27s_bouquet_%28Unsplash%29.jpg" +sha256 = "68c290449552989e60820049c75382b59436a267465577d2d8b037a97766a5fa" +source_page = "https://commons.wikimedia.org/wiki/File:Valentine%27s_bouquet_(Unsplash).jpg" +author = "Leonardo Wong" +license = "CC0-1.0" + +# ── Lakes and harbours (27 days ago) ── + +[[photo]] +id = "island-at-golden-hour" +day_offset = 27 +time = "16:45" +url = "https://upload.wikimedia.org/wikipedia/commons/f/fa/Plantation_island_on_quiet_lake_%28Unsplash%29.jpg" +sha256 = "65767476974761f98d4bbd59a7b404b00d1ab4037d7078610b65d7bed0a1cb6f" +source_page = "https://commons.wikimedia.org/wiki/File:Plantation_island_on_quiet_lake_(Unsplash).jpg" +author = "Jasmin Causevic" +license = "CC0-1.0" + +[[photo]] +id = "harbour-rowboats" +day_offset = 27 +time = "16:38" +url = "https://upload.wikimedia.org/wikipedia/commons/b/b3/Fill_art_2014-11-20_%28Unsplash%29.jpg" +sha256 = "7585810e7a6ba9860f863e1cded8ee881295310028717f38cae7bcff9e54ebbc" +source_page = "https://commons.wikimedia.org/wiki/File:Fill_art_2014-11-20_(Unsplash).jpg" +author = "Fill art" +license = "CC0-1.0" + +[[photo]] +id = "red-canoe" +day_offset = 27 +time = "16:31" +url = "https://upload.wikimedia.org/wikipedia/commons/0/03/Michael_Niessl_2016_%28Unsplash%29.jpg" +sha256 = "23adeff61211bf2d3585cddddf7afc00d293c1649a053d7be907e8dd55ed2d6d" +source_page = "https://commons.wikimedia.org/wiki/File:Michael_Niessl_2016_(Unsplash).jpg" +author = "Michael Niessl" +license = "CC0-1.0" + +[[photo]] +id = "moored-fishing-boats" +day_offset = 27 +time = "16:24" +url = "https://upload.wikimedia.org/wikipedia/commons/9/9c/Ship_%28Unsplash%29.jpg" +sha256 = "8c0a1df958309fb6756c8498b6c07d114cf4d68ac1553b6e0e43cdf8a7831955" +source_page = "https://commons.wikimedia.org/wiki/File:Ship_(Unsplash).jpg" +author = "Leo Nard" +license = "CC0-1.0" + +[[photo]] +id = "lake-at-dusk" +day_offset = 27 +time = "16:17" +url = "https://upload.wikimedia.org/wikipedia/commons/7/7d/Lake_Wenatchee_%28Unsplash%29.jpg" +sha256 = "2e82afa814d6e2032d2fd7e730951f31e42f579f8a313dd3a8cf193af24153ef" +source_page = "https://commons.wikimedia.org/wiki/File:Lake_Wenatchee_(Unsplash).jpg" +author = "Anubhav Saxena" +license = "CC0-1.0" + +[[photo]] +id = "boathouse-at-night" +day_offset = 27 +time = "16:10" +url = "https://upload.wikimedia.org/wikipedia/commons/a/a7/The_Blue_Boat_House_%28Unsplash%29.jpg" +sha256 = "9115484ab35fa48093ca002c91bfc21fc2fc3611ab19dc36b264030dfd149660" +source_page = "https://commons.wikimedia.org/wiki/File:The_Blue_Boat_House_(Unsplash).jpg" +author = "Sebastian Davenport-Handley" +license = "CC0-1.0" + +# ── Autumn (33 days ago) ── + +[[photo]] +id = "autumn-orchard-rows" +day_offset = 33 +time = "13:30" +url = "https://upload.wikimedia.org/wikipedia/commons/d/d4/Autumn_orchard_%28Unsplash%29.jpg" +sha256 = "e2c4a3de7a572e991d5f46da438bf50798c4a81f4a80c1ec2c6d43e742d6296d" +source_page = "https://commons.wikimedia.org/wiki/File:Autumn_orchard_(Unsplash).jpg" +author = "Lukas Budimaier" +license = "CC0-1.0" + +[[photo]] +id = "maple-leaf-on-pebbles" +day_offset = 33 +time = "13:23" +url = "https://upload.wikimedia.org/wikipedia/commons/c/cd/Germany_autumn_%28Unsplash%29.jpg" +sha256 = "295771eee9b06e2760106b0d306a8022f408d5ea5f2116ad8b53d7592f096783" +source_page = "https://commons.wikimedia.org/wiki/File:Germany_autumn_(Unsplash).jpg" +author = "Sabrina Bachmann" +license = "CC0-1.0" + +[[photo]] +id = "autumn-road-from-above" +day_offset = 33 +time = "13:16" +url = "https://upload.wikimedia.org/wikipedia/commons/c/cf/Autumn_road_from_above_%28Unsplash%29.jpg" +sha256 = "a3a71488421563b5b98e44ec7d6c3a16917a4051a2470fc5c79a497a1f057309" +source_page = "https://commons.wikimedia.org/wiki/File:Autumn_road_from_above_(Unsplash).jpg" +author = "Aaron Burden" +license = "CC0-1.0" diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 53d621c6..2364c0ee 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -18,11 +18,14 @@ //! locale's translation-data file. //! - `architecture-check` rejects implicit workspace packages, retired dependencies, //! buildable review-only code, and stale component references (see [`architecture`]). +//! - `screenshot-seed` / `screenshot-compose` are the platform-independent halves of the +//! README hero screenshot pipeline driven by `mise run screenshot-ios` (see [`screenshot`]). mod architecture; mod drop_kat; mod i18n; mod i18n_guard; +mod screenshot; mod share_kat; mod translate_readme; @@ -34,7 +37,7 @@ use regex::{Captures, Regex}; use semver::Version; use toml_edit::Item; -const USAGE: &str = "usage: xtask | i18n [--check] | i18n-guard | translate-readme [--check | --extract] | share-kat [] | drop-kat [] | architecture-check>"; +const USAGE: &str = "usage: xtask | i18n [--check] | i18n-guard | translate-readme [--check | --extract] | share-kat [] | drop-kat [] | architecture-check | screenshot-seed [--anchor YYYY-MM-DD] | screenshot-compose --input --output >"; /// Default output path (repo-relative) for the share-link KAT fixture the bun test consumes. const SHARE_KAT_OUT: &str = "capsule-web/src/generated/share-kat.json"; @@ -43,6 +46,13 @@ const SHARE_KAT_OUT: &str = "capsule-web/src/generated/share-kat.json"; const DROP_KAT_OUT: &str = "capsule-web/src/generated/drop-kat.json"; fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); let mut args = std::env::args().skip(1); match args.next().as_deref() { Some("set-version") => { @@ -77,6 +87,8 @@ fn main() -> Result<()> { let out = args.next().unwrap_or_else(|| DROP_KAT_OUT.to_string()); drop_kat::run(&repo_root(), &out) } + Some("screenshot-seed") => screenshot::run_seed(&repo_root(), args), + Some("screenshot-compose") => screenshot::run_compose(args), Some(other) => bail!("unknown command `{other}`; {USAGE}"), None => bail!("{USAGE}"), } diff --git a/xtask/src/screenshot.rs b/xtask/src/screenshot.rs new file mode 100644 index 00000000..5277a739 --- /dev/null +++ b/xtask/src/screenshot.rs @@ -0,0 +1,91 @@ +//! The README hero screenshot pipeline's platform-independent halves. `mise run screenshot-ios` +//! (`mise-tasks/screenshot-ios`) drives them around the macOS-only simulator capture: +//! +//! 1. `xtask screenshot-seed [--anchor YYYY-MM-DD]` — fetch, verify and normalize the CC0 seed +//! photos listed in `xtask/screenshot/seed.toml` into `target/screenshot/seed/` ([`seed`]). +//! 2. *(macOS)* seed a freshly erased simulator, launch the app, capture light and dark. +//! 3. `xtask screenshot-compose --input --output ` — mask, shadow and pad +//! the capture into the image the README embeds ([`compose`]). + +mod capture_exif; +mod compose; +mod manifest; +mod seed; + +use std::fs; +use std::path::{Path, PathBuf}; + +use eyre::{Context, ContextCompat, Result, bail}; +use jiff::civil::Date; + +/// The manifest, relative to the repo root. +const MANIFEST: &str = "xtask/screenshot/seed.toml"; +/// Scratch space under the (git-ignored) Cargo target directory. +const TARGET: &str = "target/screenshot"; +/// Timeline density the hero is captured at; `mise-tasks/screenshot-ios` writes the same value +/// into the app's `timeline.columnCount` default. +const HERO_COLUMNS: usize = 3; + +/// `screenshot-seed [--anchor YYYY-MM-DD]`. Prints the seed digest on stdout for the caller. +pub(crate) fn run_seed(root: &Path, mut args: impl Iterator) -> Result<()> { + let mut anchor = None; + while let Some(arg) = args.next() { + match arg.as_str() { + "--anchor" => { + let raw = args.next().context("--anchor needs a YYYY-MM-DD date")?; + anchor = Some( + raw.parse::() + .with_context(|| format!("`{raw}` is not a YYYY-MM-DD date"))?, + ); + } + other => bail!( + "unknown argument `{other}`; usage: xtask screenshot-seed [--anchor YYYY-MM-DD]" + ), + } + } + // The simulator shares the host's clock and time zone, so "today" here is "today" there — + // and the app titles sections against that real clock, not against the anchor. + let today = jiff::Zoned::now().date(); + let anchor = anchor.unwrap_or(today); + if anchor > today { + bail!("--anchor {anchor} is in the future; the app would title those days as upcoming"); + } + + let path = root.join(MANIFEST); + let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let manifest = + manifest::Manifest::parse(&text).with_context(|| format!("invalid {MANIFEST}"))?; + manifest.check_full_rows(HERO_COLUMNS)?; + let stale = manifest.previous_year_photos(anchor, today)?; + if !stale.is_empty() { + let first = stale.first().map_or("", |p| p.id.as_str()); + tracing::warn!( + count = stale.len(), + first = %first, + "some capture days fall in an earlier year; their section headers will carry a year suffix" + ); + } + let layout = seed::Layout::under(&root.join(TARGET)); + let digest = seed::run(&manifest, &layout, anchor, &seed::CurlFetcher)?; + println!("{digest}"); + Ok(()) +} + +/// `screenshot-compose --input --output `. +pub(crate) fn run_compose(mut args: impl Iterator) -> Result<()> { + const USAGE: &str = "usage: xtask screenshot-compose --input --output "; + let (mut input, mut output) = (None::, None::); + while let Some(arg) = args.next() { + let slot = match arg.as_str() { + "--input" => &mut input, + "--output" => &mut output, + other => bail!("unknown argument `{other}`; {USAGE}"), + }; + *slot = Some(args.next().context(USAGE)?.into()); + } + compose::run( + &input.context(USAGE)?, + &output.context(USAGE)?, + &compose::HERO, + ) +} diff --git a/xtask/src/screenshot/capture_exif.rs b/xtask/src/screenshot/capture_exif.rs new file mode 100644 index 00000000..e9ce2155 --- /dev/null +++ b/xtask/src/screenshot/capture_exif.rs @@ -0,0 +1,131 @@ +//! A minimal EXIF (TIFF) block carrying only a capture date. +//! +//! Seed photos are re-encoded without their original metadata — camera, GPS and editing history +//! have no place in a screenshot fixture — and get exactly the tags Photos needs to date them: +//! `DateTimeOriginal` and `DateTimeDigitized`, plus `Orientation = 1` because the pixels are +//! already rotated upright. Hand-built because the layout is fixed and tiny; a general EXIF +//! writer would be a dependency for ~40 bytes of structure. + +use jiff::civil::DateTime; + +const TAG_ORIENTATION: u16 = 0x0112; +const TAG_EXIF_IFD: u16 = 0x8769; +const TAG_EXIF_VERSION: u16 = 0x9000; +const TAG_DATE_TIME_ORIGINAL: u16 = 0x9003; +const TAG_DATE_TIME_DIGITIZED: u16 = 0x9004; + +const TYPE_ASCII: u16 = 2; +const TYPE_SHORT: u16 = 3; +const TYPE_LONG: u16 = 4; +const TYPE_UNDEFINED: u16 = 7; + +/// `YYYY:MM:DD HH:MM:SS` plus its NUL terminator. +const DATE_LEN: u32 = 20; + +/// Raw TIFF bytes (big-endian) for the JPEG APP1 segment; the encoder adds the `Exif\0\0` prefix. +pub(crate) fn capture_date_block(captured_at: DateTime) -> Vec { + let date = captured_at.strftime("%Y:%m:%d %H:%M:%S").to_string(); + debug_assert_eq!(date.len() + 1, DATE_LEN as usize); + + // Layout: header (8) | IFD0: 2 entries (30) | Exif IFD: 3 entries (42) | two date strings. + let ifd0_offset: u32 = 8; + let exif_ifd_offset = ifd0_offset + ifd_len(2); + let original_offset = exif_ifd_offset + ifd_len(3); + let digitized_offset = original_offset + DATE_LEN; + + let mut out = Vec::with_capacity((digitized_offset + DATE_LEN) as usize); + out.extend_from_slice(b"MM"); + out.extend_from_slice(&42u16.to_be_bytes()); + out.extend_from_slice(&ifd0_offset.to_be_bytes()); + + // Entries within an IFD must be sorted by tag. + out.extend_from_slice(&2u16.to_be_bytes()); + entry(&mut out, TAG_ORIENTATION, TYPE_SHORT, 1, [0, 1, 0, 0]); + entry( + &mut out, + TAG_EXIF_IFD, + TYPE_LONG, + 1, + exif_ifd_offset.to_be_bytes(), + ); + out.extend_from_slice(&0u32.to_be_bytes()); + + out.extend_from_slice(&3u16.to_be_bytes()); + entry(&mut out, TAG_EXIF_VERSION, TYPE_UNDEFINED, 4, *b"0232"); + entry( + &mut out, + TAG_DATE_TIME_ORIGINAL, + TYPE_ASCII, + DATE_LEN, + original_offset.to_be_bytes(), + ); + entry( + &mut out, + TAG_DATE_TIME_DIGITIZED, + TYPE_ASCII, + DATE_LEN, + digitized_offset.to_be_bytes(), + ); + out.extend_from_slice(&0u32.to_be_bytes()); + + for _ in 0..2 { + out.extend_from_slice(date.as_bytes()); + out.push(0); + } + out +} + +/// Byte length of an IFD with `entries` entries: count + 12 bytes each + next-IFD pointer. +const fn ifd_len(entries: u32) -> u32 { + 2 + 12 * entries + 4 +} + +fn entry(out: &mut Vec, tag: u16, kind: u16, count: u32, value: [u8; 4]) { + out.extend_from_slice(&tag.to_be_bytes()); + out.extend_from_slice(&kind.to_be_bytes()); + out.extend_from_slice(&count.to_be_bytes()); + out.extend_from_slice(&value); +} + +#[cfg(test)] +mod tests { + use exif::{In, Tag, Value}; + + use super::*; + + fn read(block: Vec) -> exif::Exif { + exif::Reader::new() + .read_raw(block) + .expect("well-formed TIFF") + } + + #[test] + fn round_trips_the_capture_date_through_a_standard_reader() { + let block = capture_date_block(jiff::civil::date(2026, 9, 20).at(17, 40, 5, 0)); + let exif = read(block); + for tag in [Tag::DateTimeOriginal, Tag::DateTimeDigitized] { + let field = exif.get_field(tag, In::PRIMARY).expect("date tag present"); + assert_eq!(field.display_value().to_string(), "2026-09-20 17:40:05"); + } + } + + #[test] + fn declares_upright_orientation_and_nothing_identifying() { + let exif = read(capture_date_block( + jiff::civil::date(2026, 1, 2).at(3, 4, 5, 0), + )); + let orientation = exif.get_field(Tag::Orientation, In::PRIMARY).unwrap(); + assert!(matches!(orientation.value, Value::Short(ref v) if v == &[1])); + let tags: Vec = exif.fields().map(|f| f.tag).collect(); + // The reader resolves the Exif IFD pointer rather than listing it as a field. + assert_eq!( + tags, + [ + Tag::Orientation, + Tag::ExifVersion, + Tag::DateTimeOriginal, + Tag::DateTimeDigitized + ] + ); + } +} diff --git a/xtask/src/screenshot/compose.rs b/xtask/src/screenshot/compose.rs new file mode 100644 index 00000000..68020664 --- /dev/null +++ b/xtask/src/screenshot/compose.rs @@ -0,0 +1,287 @@ +//! `xtask screenshot-compose`: turn a raw simulator capture into the README hero image — the +//! phone screen with device-shaped corners and a soft drop shadow, on a transparent canvas so it +//! sits cleanly on both GitHub themes. +//! +//! The screen shape comes from the capture itself: the mise task asks `simctl io screenshot` for +//! `--mask=alpha`, which emits Apple's exact (continuous-curvature) display mask. An opaque +//! capture — taken without the mask — falls back to a circular-arc rounded rectangle of +//! [`Style::fallback_corner_radius`]. +//! +//! Output is deterministic: the same capture always yields byte-identical PNG bytes. + +use std::fs; +use std::path::Path; + +use eyre::{Context, Result, eyre}; +use image::imageops::fast_blur; +use image::{ + DynamicImage, GrayImage, ImageDecoder, ImageEncoder, ImageReader, Luma, Rgba, RgbaImage, +}; +use tracing::info; + +/// Every tunable of the composition, in capture pixels (3x for the iPhone 17 Pro capture). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Style { + /// Corner radius applied when the capture carries no alpha mask of its own. + /// 62 pt is the iPhone 16/17 Pro display radius. + pub(crate) fallback_corner_radius: f32, + /// How far the shadow drops below the screen. + pub(crate) shadow_offset_y: u32, + /// Gaussian spread of the shadow. + pub(crate) shadow_sigma: f32, + /// Peak shadow opacity (0–1). + pub(crate) shadow_opacity: f32, +} + +/// The README hero style. +pub(crate) const HERO: Style = Style { + fallback_corner_radius: 62.0 * 3.0, + shadow_offset_y: 30, + shadow_sigma: 45.0, + shadow_opacity: 0.28, +}; + +impl Style { + /// Transparent margin on every side: room for the full blur plus the drop, so nothing clips. + pub(crate) fn padding(&self) -> u32 { + (self.shadow_sigma * 3.0).ceil() as u32 + self.shadow_offset_y + } +} + +/// Read `input`, compose with `style`, write a PNG to `output`. The capture's colour profile (the +/// simulator may tag it Display P3) is carried into the output so its colours are not reinterpreted. +pub(crate) fn run(input: &Path, output: &Path, style: &Style) -> Result<()> { + let mut decoder = ImageReader::open(input) + .with_context(|| format!("reading capture {}", input.display()))? + .with_guessed_format()? + .into_decoder()?; + let icc = decoder.icc_profile()?; + let capture = DynamicImage::from_decoder(decoder)?.into_rgba8(); + let composed = compose(&capture, style); + let png = encode_png(&composed, icc)?; + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + fs::write(output, &png).with_context(|| format!("writing {}", output.display()))?; + info!( + input = %input.display(), + output = %output.display(), + width = composed.width(), + height = composed.height(), + bytes = png.len(), + "composed hero image" + ); + Ok(()) +} + +/// Compose `capture` onto a padded transparent canvas with a drop shadow. +pub(crate) fn compose(capture: &RgbaImage, style: &Style) -> RgbaImage { + let screen = with_screen_mask(capture, style.fallback_corner_radius); + let pad = style.padding(); + let (w, h) = (screen.width() + 2 * pad, screen.height() + 2 * pad); + + // Shadow: the screen's own alpha, dropped and blurred. + let mut silhouette = GrayImage::new(w, h); + for (x, y, px) in screen.enumerate_pixels() { + silhouette.put_pixel(x + pad, y + pad + style.shadow_offset_y, Luma([px[3]])); + } + let shadow = fast_blur(&silhouette, style.shadow_sigma); + + let mut canvas = RgbaImage::from_fn(w, h, |x, y| { + let a = (f32::from(shadow.get_pixel(x, y)[0]) * style.shadow_opacity).round() as u8; + Rgba([0, 0, 0, a]) + }); + for (x, y, px) in screen.enumerate_pixels() { + let dst = canvas.get_pixel_mut(x + pad, y + pad); + *dst = over(*px, *dst); + } + canvas +} + +/// The capture with its screen shape as alpha: its own mask when it has one, otherwise a rounded +/// rectangle of `radius`. +fn with_screen_mask(capture: &RgbaImage, radius: f32) -> RgbaImage { + if capture.pixels().any(|p| p[3] < u8::MAX) { + return capture.clone(); + } + let (w, h) = (capture.width() as f32, capture.height() as f32); + let mut out = capture.clone(); + for (x, y, px) in out.enumerate_pixels_mut() { + let coverage = rounded_rect_coverage(x as f32 + 0.5, y as f32 + 0.5, w, h, radius); + px[3] = (coverage * 255.0).round() as u8; + } + out +} + +/// Anti-aliased coverage (0–1) of the pixel centred at `(px, py)` by a `w`×`h` rounded rectangle, +/// from the signed distance to its edge. +fn rounded_rect_coverage(px: f32, py: f32, w: f32, h: f32, r: f32) -> f32 { + let r = r.min(w / 2.0).min(h / 2.0); + let dx = (px - w / 2.0).abs() - (w / 2.0 - r); + let dy = (py - h / 2.0).abs() - (h / 2.0 - r); + let outside = dx.max(0.0).hypot(dy.max(0.0)); + let inside = dx.max(dy).min(0.0); + let distance = outside + inside - r; + (0.5 - distance).clamp(0.0, 1.0) +} + +/// Porter–Duff source-over for straight (non-premultiplied) alpha. +fn over(src: Rgba, dst: Rgba) -> Rgba { + let sa = f32::from(src[3]) / 255.0; + let da = f32::from(dst[3]) / 255.0; + let oa = sa + da * (1.0 - sa); + if oa <= 0.0 { + return Rgba([0, 0, 0, 0]); + } + let channel = |i: usize| { + let c = (f32::from(src[i]) * sa + f32::from(dst[i]) * da * (1.0 - sa)) / oa; + c.round() as u8 + }; + Rgba([ + channel(0), + channel(1), + channel(2), + (oa * 255.0).round() as u8, + ]) +} + +/// PNG with no ancillary chunks beyond the colour profile (no timestamps), so equal pixels and +/// profile mean equal bytes. +fn encode_png(image: &RgbaImage, icc: Option>) -> Result> { + let mut out = Vec::new(); + let mut encoder = image::codecs::png::PngEncoder::new_with_quality( + &mut out, + image::codecs::png::CompressionType::Best, + image::codecs::png::FilterType::Adaptive, + ); + if let Some(icc) = icc { + encoder + .set_icc_profile(icc) + .map_err(|e| eyre!("embedding ICC profile: {e}"))?; + } + encoder.write_image( + image.as_raw(), + image.width(), + image.height(), + image::ExtendedColorType::Rgba8, + )?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const STYLE: Style = Style { + fallback_corner_radius: 20.0, + shadow_offset_y: 6, + shadow_sigma: 5.0, + shadow_opacity: 0.3, + }; + + fn opaque(w: u32, h: u32) -> RgbaImage { + RgbaImage::from_fn(w, h, |x, y| { + Rgba([(x % 200) as u8 + 20, (y % 200) as u8 + 20, 90, 255]) + }) + } + + #[test] + fn canvas_is_the_capture_plus_padding_on_every_side() { + let out = compose(&opaque(120, 260), &STYLE); + let pad = STYLE.padding(); + assert_eq!((out.width(), out.height()), (120 + 2 * pad, 260 + 2 * pad)); + } + + #[test] + fn screen_interior_is_preserved_exactly() { + let capture = opaque(120, 260); + let out = compose(&capture, &STYLE); + let pad = STYLE.padding(); + assert_eq!( + out.get_pixel(60 + pad, 130 + pad), + capture.get_pixel(60, 130) + ); + } + + #[test] + fn opaque_capture_gets_rounded_transparent_corners() { + let out = compose(&opaque(120, 260), &STYLE); + let pad = STYLE.padding(); + let corner = out.get_pixel(pad, pad); + assert!( + corner[3] < 128, + "screen corner must be cut away, got {corner:?}" + ); + let edge_mid = out.get_pixel(pad, pad + 130); + assert_eq!(edge_mid[3], 255, "straight edges stay opaque"); + } + + #[test] + fn capture_with_its_own_mask_is_not_re_masked() { + let mut capture = opaque(120, 260); + capture.put_pixel(0, 0, Rgba([0, 0, 0, 0])); + // A pixel the fallback radius would have cut, but the capture's own mask keeps. + capture.put_pixel(2, 2, Rgba([10, 20, 30, 255])); + let out = compose(&capture, &STYLE); + let pad = STYLE.padding(); + assert_eq!(*out.get_pixel(2 + pad, 2 + pad), Rgba([10, 20, 30, 255])); + } + + #[test] + fn shadow_falls_below_the_screen_and_fades_out() { + let out = compose(&opaque(120, 260), &STYLE); + let pad = STYLE.padding(); + let below = out.get_pixel(pad + 60, pad + 260 + STYLE.shadow_offset_y / 2); + let above = out.get_pixel(pad + 60, pad - STYLE.shadow_offset_y / 2); + assert!( + below[3] > above[3], + "drop shadow is heavier below: {below:?} vs {above:?}" + ); + assert!(below[3] as f32 <= STYLE.shadow_opacity * 255.0 + 1.0); + assert_eq!( + out.get_pixel(0, 0)[3], + 0, + "canvas corners fully transparent" + ); + assert_eq!(out.get_pixel(out.width() - 1, out.height() - 1)[3], 0); + } + + #[test] + fn output_bytes_are_deterministic() { + let capture = opaque(90, 180); + let a = encode_png(&compose(&capture, &STYLE), None).unwrap(); + let b = encode_png(&compose(&capture, &STYLE), None).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn run_carries_the_capture_colour_profile_through() { + let tmp = tempfile::tempdir().unwrap(); + let (input, output) = (tmp.path().join("raw.png"), tmp.path().join("out/hero.png")); + let profile = b"stand-in ICC profile bytes".to_vec(); + fs::write( + &input, + encode_png(&opaque(40, 80), Some(profile.clone())).unwrap(), + ) + .unwrap(); + + run(&input, &output, &STYLE).unwrap(); + let mut decoder = ImageReader::open(&output) + .unwrap() + .with_guessed_format() + .unwrap() + .into_decoder() + .unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(profile)); + } + + #[test] + fn coverage_is_full_inside_empty_outside_and_partial_on_the_edge() { + let inside = rounded_rect_coverage(50.0, 50.0, 100.0, 100.0, 20.0); + assert!((inside - 1.0).abs() < f32::EPSILON, "{inside}"); + let cut_corner = rounded_rect_coverage(0.5, 0.5, 100.0, 100.0, 20.0); + assert!(cut_corner.abs() < f32::EPSILON, "{cut_corner}"); + let edge = rounded_rect_coverage(0.0, 50.0, 100.0, 100.0, 20.0); + assert!((edge - 0.5).abs() < 1e-6, "{edge}"); + } +} diff --git a/xtask/src/screenshot/manifest.rs b/xtask/src/screenshot/manifest.rs new file mode 100644 index 00000000..03cc749c --- /dev/null +++ b/xtask/src/screenshot/manifest.rs @@ -0,0 +1,291 @@ +//! The seed manifest (`xtask/screenshot/seed.toml`): which photos seed the README screenshot, +//! where their bytes come from, and when each one claims to have been captured. +//! +//! Capture dates are stored **relative to an anchor day** (`day_offset` days before it, at a +//! wall-clock `time`) rather than as absolute timestamps. The iOS timeline titles a day section +//! `Sat, September 12` only while it falls in the current year (and `Today` / `Yesterday` for the +//! two most recent days), so absolute dates would drift into year-suffixed headers as the calendar +//! moves on. Anchoring to the run date keeps every capture reading the same way. + +use std::collections::HashSet; + +use eyre::{Result, bail, ensure}; +use jiff::civil::{Date, DateTime, Time}; +use serde::Deserialize; + +/// The smallest `day_offset` allowed: offsets 0 and 1 would render as `Today` / `Yesterday`. +pub(crate) const MIN_DAY_OFFSET: u16 = 2; + +/// Licences a seed photo may carry: no attribution or share-alike obligation at all. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +pub(crate) enum License { + #[serde(rename = "CC0-1.0")] + Cc0, + #[serde(rename = "PDM-1.0")] + PublicDomainMark, +} + +/// One seed photo. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Photo { + /// Stable kebab-case id; names the normalized output file. + pub(crate) id: String, + /// Days before the anchor day this photo was "captured". + pub(crate) day_offset: u16, + /// Wall-clock capture time (`HH:MM`) on that day, in the simulator's local time zone. + pub(crate) time: Time, + /// Immutable original-file URL (HTTPS). + pub(crate) url: String, + /// Lowercase hex SHA-256 of the bytes at `url`. + pub(crate) sha256: String, + /// Human-facing provenance page (licence, author). + pub(crate) source_page: String, + pub(crate) author: String, + pub(crate) license: License, +} + +impl Photo { + /// The capture timestamp this photo gets for a given anchor day. + pub(crate) fn captured_at(&self, anchor: Date) -> Result { + let day = anchor.checked_sub(jiff::Span::new().days(i64::from(self.day_offset)))?; + Ok(day.to_datetime(self.time)) + } +} + +/// The parsed, validated manifest. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Manifest { + #[serde(rename = "photo")] + pub(crate) photos: Vec, +} + +impl Manifest { + /// Parse and validate manifest TOML. + pub(crate) fn parse(text: &str) -> Result { + let manifest: Self = toml_edit::de::from_str(text)?; + manifest.validate()?; + Ok(manifest) + } + + /// Require every capture day to hold a whole number of `columns`-wide grid rows, so no day + /// section in the screenshot ends in a ragged, half-empty row. + pub(crate) fn check_full_rows(&self, columns: usize) -> Result<()> { + for day in self.photos.chunk_by(|a, b| a.day_offset == b.day_offset) { + ensure!( + day.len() % columns == 0, + "day_offset {} holds {} photos; use a multiple of {columns} so its grid rows are full", + day[0].day_offset, + day.len() + ); + } + Ok(()) + } + + /// Photos whose capture day falls in an earlier calendar year than `today` for this `anchor`. + /// The timeline titles those sections with a year suffix (`December 30, 2025`), which a run in + /// the first days of January would put into the screenshot. + pub(crate) fn previous_year_photos(&self, anchor: Date, today: Date) -> Result> { + let mut out = Vec::new(); + for photo in &self.photos { + if photo.captured_at(anchor)?.year() < today.year() { + out.push(photo); + } + } + Ok(out) + } + + /// Reject anything that would make the seed set ambiguous or non-reproducible. + /// + /// Photos must be listed **newest first** — the order the timeline renders them — so the + /// manifest reads top-to-bottom like the screenshot: `day_offset` never decreases, and within + /// one day `time` strictly decreases. + fn validate(&self) -> Result<()> { + ensure!(!self.photos.is_empty(), "manifest lists no photos"); + let mut ids = HashSet::new(); + let mut hashes = HashSet::new(); + let mut previous: Option<&Photo> = None; + for photo in &self.photos { + let id = &photo.id; + ensure!(is_kebab(id), "photo id `{id}` is not kebab-case"); + ensure!(ids.insert(id.as_str()), "duplicate photo id `{id}`"); + ensure!( + is_sha256_hex(&photo.sha256), + "photo `{id}`: sha256 must be 64 lowercase hex characters" + ); + ensure!( + hashes.insert(photo.sha256.as_str()), + "photo `{id}`: duplicate sha256 (the same file is listed twice)" + ); + ensure!( + photo.url.starts_with("https://"), + "photo `{id}`: url must be https" + ); + ensure!( + photo.day_offset >= MIN_DAY_OFFSET, + "photo `{id}`: day_offset must be >= {MIN_DAY_OFFSET} (0 and 1 render as Today/Yesterday)" + ); + if let Some(prev) = previous { + let newer_first = prev.day_offset < photo.day_offset + || (prev.day_offset == photo.day_offset && prev.time > photo.time); + if !newer_first { + bail!( + "photo `{id}` is not older than `{}`: list photos newest first with distinct times", + prev.id + ); + } + } + previous = Some(photo); + } + Ok(()) + } +} + +fn is_kebab(s: &str) -> bool { + !s.is_empty() + && !s.starts_with('-') + && !s.ends_with('-') + && !s.contains("--") + && s.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 + && s.bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const HASH_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HASH_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn photo(id: &str, day_offset: u16, time: &str, sha256: &str, license: &str) -> String { + format!( + r#" +[[photo]] +id = "{id}" +day_offset = {day_offset} +time = "{time}" +url = "https://example.test/{id}.jpg" +sha256 = "{sha256}" +source_page = "https://example.test/{id}" +author = "Someone" +license = "{license}" +"# + ) + } + + fn err(text: &str) -> String { + Manifest::parse(text).unwrap_err().to_string() + } + + #[test] + fn parses_a_valid_manifest_in_listed_order() { + let text = + photo("a", 2, "17:40", HASH_A, "CC0-1.0") + &photo("b", 2, "17:33", HASH_B, "PDM-1.0"); + let manifest = Manifest::parse(&text).unwrap(); + let ids: Vec<_> = manifest.photos.iter().map(|p| p.id.as_str()).collect(); + assert_eq!(ids, ["a", "b"]); + assert_eq!(manifest.photos[1].license, License::PublicDomainMark); + } + + #[test] + fn rejects_a_licence_outside_the_public_domain_set() { + let text = photo("a", 2, "10:00", HASH_A, "CC-BY-4.0"); + assert!(err(&text).contains("CC-BY-4.0"), "{}", err(&text)); + } + + #[test] + fn rejects_duplicate_ids_and_hashes() { + let dup_id = + photo("a", 2, "10:00", HASH_A, "CC0-1.0") + &photo("a", 3, "10:00", HASH_B, "CC0-1.0"); + assert!(err(&dup_id).contains("duplicate photo id")); + let dup_hash = + photo("a", 2, "10:00", HASH_A, "CC0-1.0") + &photo("b", 3, "10:00", HASH_A, "CC0-1.0"); + assert!(err(&dup_hash).contains("duplicate sha256")); + } + + #[test] + fn rejects_malformed_fields() { + assert!(err(&photo("Bad_Id", 2, "10:00", HASH_A, "CC0-1.0")).contains("kebab-case")); + assert!(err(&photo("a", 2, "10:00", "ABC", "CC0-1.0")).contains("sha256")); + let http = photo("a", 2, "10:00", HASH_A, "CC0-1.0") + .replace("https://example.test/a.jpg", "http://x/a.jpg"); + assert!(err(&http).contains("https")); + let unknown = photo("a", 2, "10:00", HASH_A, "CC0-1.0") + "caption = \"nope\"\n"; + assert!(err(&unknown).contains("caption")); + } + + #[test] + fn rejects_offsets_that_render_as_today_or_yesterday() { + assert!(err(&photo("a", 1, "10:00", HASH_A, "CC0-1.0")).contains("day_offset")); + } + + #[test] + fn requires_newest_first_order_with_distinct_times() { + let older_day_first = + photo("a", 3, "10:00", HASH_A, "CC0-1.0") + &photo("b", 2, "10:00", HASH_B, "CC0-1.0"); + assert!(err(&older_day_first).contains("newest first")); + let same_time = + photo("a", 2, "10:00", HASH_A, "CC0-1.0") + &photo("b", 2, "10:00", HASH_B, "CC0-1.0"); + assert!(err(&same_time).contains("newest first")); + } + + #[test] + fn capture_time_is_relative_to_the_anchor() { + let manifest = Manifest::parse(&photo("a", 3, "17:40", HASH_A, "CC0-1.0")).unwrap(); + let at = manifest.photos[0] + .captured_at(jiff::civil::date(2026, 3, 1)) + .unwrap(); + assert_eq!(at, jiff::civil::date(2026, 2, 26).at(17, 40, 0, 0)); + } + + #[test] + fn full_rows_rule_counts_photos_per_capture_day() { + let three: String = ["a", "b", "c"] + .iter() + .zip(["10:03", "10:02", "10:01"]) + .enumerate() + .map(|(i, (id, t))| photo(id, 2, t, &format!("{i}").repeat(64), "CC0-1.0")) + .collect(); + let manifest = Manifest::parse(&three).unwrap(); + assert!(manifest.check_full_rows(3).is_ok()); + let err = manifest.check_full_rows(2).unwrap_err().to_string(); + assert!(err.contains("day_offset 2 holds 3 photos"), "{err}"); + } + + #[test] + fn flags_captures_that_fall_in_the_previous_year() { + let text = + photo("a", 2, "10:00", HASH_A, "CC0-1.0") + &photo("b", 9, "10:00", HASH_B, "CC0-1.0"); + let manifest = Manifest::parse(&text).unwrap(); + let jan5 = jiff::civil::date(2027, 1, 5); + let ids: Vec<_> = manifest + .previous_year_photos(jan5, jan5) + .unwrap() + .iter() + .map(|p| p.id.as_str()) + .collect(); + assert_eq!(ids, ["b"]); + let sep = jiff::civil::date(2026, 9, 23); + assert!(manifest.previous_year_photos(sep, sep).unwrap().is_empty()); + } + + /// The checked-in manifest: valid, full rows at the hero's 3 columns, and CC0-only. + #[test] + fn checked_in_manifest_is_valid() { + let manifest = Manifest::parse(include_str!("../../screenshot/seed.toml")).unwrap(); + manifest + .check_full_rows(super::super::HERO_COLUMNS) + .unwrap(); + assert!( + manifest.photos.len() >= 30, + "enough photos to fill the scrolled grid" + ); + } +} diff --git a/xtask/src/screenshot/seed.rs b/xtask/src/screenshot/seed.rs new file mode 100644 index 00000000..dd0cdfc0 --- /dev/null +++ b/xtask/src/screenshot/seed.rs @@ -0,0 +1,575 @@ +//! `xtask screenshot-seed`: turn the manifest into a directory of upright, dated, metadata-free +//! JPEGs ready for `simctl addmedia`. +//! +//! Two stages, each idempotent: +//! +//! 1. **Cache.** Every original is fetched once into `target/screenshot/cache/.jpg` and +//! verified against the manifest's pin; a verified cache entry is never fetched again, and bytes +//! that fail the pin are deleted and reported rather than used. +//! 2. **Normalize.** Each original is decoded, rotated upright, downscaled to [`MAX_EDGE`] and +//! re-encoded with only a capture date ([`super::capture_exif`]) and its ICC profile. The same +//! manifest, anchor day and originals always produce byte-identical files; the whole directory +//! is rebuilt in a sibling and swapped in, so a failed run never leaves a half-written seed set. + +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Instant; + +use eyre::{Context, Result, bail, ensure, eyre}; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; +use image::{DynamicImage, ImageDecoder, ImageEncoder, ImageReader}; +use jiff::civil::Date; +use sha2::{Digest, Sha256}; +use tracing::{debug, info, info_span, warn}; + +use super::capture_exif; +use super::manifest::{Manifest, Photo}; + +/// Longest edge of a normalized seed photo, in pixels. The grid shows ~400 px tiles at 3x and the +/// viewer fills a 1206 px-wide screen, so 2048 keeps full-screen detail at a fraction of the bytes. +pub(crate) const MAX_EDGE: u32 = 2048; + +/// Fixed JPEG quality for re-encoding. +const JPEG_QUALITY: u8 = 90; + +/// Identifies this tool to the file host, per the Wikimedia User-Agent policy. +const USER_AGENT: &str = "CapsuleScreenshotSeed/1.0 (https://github.com/Capsulsaurus/Capsule)"; + +/// Downloads one URL to a local path. A seam so the cache logic is testable offline. +pub(crate) trait Fetcher: Sync { + fn fetch(&self, url: &str, dest: &Path) -> Result<()>; +} + +/// Production fetcher: `curl`, which ships with macOS and every Linux dev box, retries transient +/// failures (including HTTP 429 with `Retry-After`) and keeps an HTTP stack out of xtask. +pub(crate) struct CurlFetcher; + +impl Fetcher for CurlFetcher { + fn fetch(&self, url: &str, dest: &Path) -> Result<()> { + let status = Command::new("curl") + .args(["--fail", "--silent", "--show-error", "--location"]) + .args([ + "--retry", + "6", + "--retry-delay", + "5", + "--connect-timeout", + "20", + ]) + .args(["--user-agent", USER_AGENT, "--output"]) + .arg(dest) + .arg(url) + .status() + .context("running curl (is it installed?)")?; + ensure!(status.success(), "curl exited with {status} for {url}"); + Ok(()) + } +} + +/// Whether [`ensure_cached`] had to download. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum CacheOutcome { + Hit, + Fetched, +} + +/// Paths the seed stage reads and writes. +pub(crate) struct Layout { + pub(crate) cache: PathBuf, + pub(crate) out: PathBuf, +} + +impl Layout { + pub(crate) fn under(target: &Path) -> Self { + Self { + cache: target.join("cache"), + out: target.join("seed"), + } + } +} + +/// Run both stages. Returns the combined SHA-256 of the output files (in manifest order), which a +/// second run must reproduce exactly — the idempotency check the mise task prints. +pub(crate) fn run( + manifest: &Manifest, + layout: &Layout, + anchor: Date, + fetcher: &dyn Fetcher, +) -> Result { + let started = Instant::now(); + fs::create_dir_all(&layout.cache) + .with_context(|| format!("creating {}", layout.cache.display()))?; + + // Fetch sequentially: politeness to the file host matters more than wall-clock here, and a + // warm cache makes this loop free. + let mut originals = Vec::with_capacity(manifest.photos.len()); + for photo in &manifest.photos { + let (path, outcome) = ensure_cached(photo, &layout.cache, fetcher)?; + debug!(id = %photo.id, ?outcome, "original ready"); + originals.push(path); + } + + let staging = layout.out.with_extension("staging"); + if staging.exists() { + fs::remove_dir_all(&staging).with_context(|| format!("clearing {}", staging.display()))?; + } + fs::create_dir_all(&staging)?; + + let names = normalize_all(manifest, &originals, &staging, anchor)?; + + if layout.out.exists() { + fs::remove_dir_all(&layout.out) + .with_context(|| format!("replacing {}", layout.out.display()))?; + } + fs::rename(&staging, &layout.out)?; + + let mut combined = Sha256::new(); + for name in &names { + combined.update(name.as_bytes()); + combined.update(Sha256::digest(fs::read(layout.out.join(name))?)); + } + let digest = hex::encode(combined.finalize()); + info!( + photos = names.len(), + out = %layout.out.display(), + %anchor, + seed_digest = %digest, + elapsed_ms = started.elapsed().as_millis(), + "seed set ready" + ); + Ok(digest) +} + +/// Make sure the verified original for `photo` is in the cache; return its path. +pub(crate) fn ensure_cached( + photo: &Photo, + cache: &Path, + fetcher: &dyn Fetcher, +) -> Result<(PathBuf, CacheOutcome)> { + let path = cache.join(format!("{}.jpg", photo.sha256)); + if path.exists() { + if sha256_file(&path)? == photo.sha256 { + return Ok((path, CacheOutcome::Hit)); + } + warn!(id = %photo.id, path = %path.display(), "cached original fails its pin; refetching"); + fs::remove_file(&path)?; + } + + let partial = path.with_extension("part"); + let span = info_span!("fetch", id = %photo.id, url = %photo.url); + let _guard = span.enter(); + let started = Instant::now(); + fetcher + .fetch(&photo.url, &partial) + .with_context(|| format!("fetching photo `{}`", photo.id))?; + let actual = sha256_file(&partial)?; + if actual != photo.sha256 { + fs::remove_file(&partial)?; + bail!( + "photo `{}`: downloaded bytes do not match the manifest pin (expected {}, got {actual}); \ + the source changed — review it and re-pin the manifest", + photo.id, + photo.sha256 + ); + } + fs::rename(&partial, &path)?; + info!( + bytes = fs::metadata(&path)?.len(), + elapsed_ms = started.elapsed().as_millis(), + "fetched" + ); + Ok((path, CacheOutcome::Fetched)) +} + +/// Normalize every original into `dir` in parallel; returns the output file names in manifest order. +fn normalize_all( + manifest: &Manifest, + originals: &[PathBuf], + dir: &Path, + anchor: Date, +) -> Result> { + let jobs: Vec<(usize, &Photo, &PathBuf)> = manifest + .photos + .iter() + .zip(originals) + .enumerate() + .map(|(i, (p, o))| (i, p, o)) + .collect(); + let workers = std::thread::available_parallelism().map_or(4, std::num::NonZero::get); + let chunk = jobs.len().div_ceil(workers).max(1); + + std::thread::scope(|scope| { + let handles: Vec<_> = jobs + .chunks(chunk) + .map(|batch| { + scope.spawn(move || -> Result> { + batch + .iter() + .map(|&(i, photo, original)| { + let started = Instant::now(); + let name = output_name(i, photo); + let bytes = fs::read(original)?; + let jpeg = normalize(&bytes, photo.captured_at(anchor)?) + .with_context(|| format!("normalizing photo `{}`", photo.id))?; + fs::write(dir.join(&name), &jpeg)?; + debug!( + id = %photo.id, + bytes = jpeg.len(), + elapsed_ms = started.elapsed().as_millis(), + "normalized" + ); + Ok((i, name)) + }) + .collect() + }) + }) + .collect(); + let mut names = Vec::with_capacity(jobs.len()); + for handle in handles { + names.extend( + handle + .join() + .map_err(|_| eyre!("normalize worker panicked"))??, + ); + } + names.sort_unstable(); + Ok(names.into_iter().map(|(_, n)| n).collect()) + }) +} + +/// `NN-.jpg`, numbered in manifest (newest-first) order. +pub(crate) fn output_name(index: usize, photo: &Photo) -> String { + format!("{:02}-{}.jpg", index + 1, photo.id) +} + +/// Decode, orient upright, bound the long edge, and re-encode with only a capture date. +pub(crate) fn normalize(original: &[u8], captured_at: jiff::civil::DateTime) -> Result> { + let mut decoder = ImageReader::new(Cursor::new(original)) + .with_guessed_format()? + .into_decoder()?; + let orientation = decoder.orientation()?; + let icc = decoder.icc_profile()?; + let mut image = DynamicImage::from_decoder(decoder)?; + image.apply_orientation(orientation); + + let (w, h) = (image.width(), image.height()); + if w.max(h) > MAX_EDGE { + // Round to the nearest pixel so the aspect ratio survives the downscale. + let scale = f64::from(MAX_EDGE) / f64::from(w.max(h)); + let nw = ((f64::from(w) * scale).round() as u32).max(1); + let nh = ((f64::from(h) * scale).round() as u32).max(1); + image = image.resize_exact(nw, nh, FilterType::Lanczos3); + } + let rgb = image.into_rgb8(); + + let mut out = Vec::new(); + let mut encoder = JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY); + // Pixels are RGB now; a Gray or CMYK profile from the source would misdescribe them. + if let Some(icc) = icc.filter(|p| is_rgb_profile(p)) { + encoder + .set_icc_profile(icc) + .map_err(|e| eyre!("embedding ICC profile: {e}"))?; + } + encoder + .set_exif_metadata(capture_exif::capture_date_block(captured_at)) + .map_err(|e| eyre!("embedding EXIF: {e}"))?; + encoder.write_image( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + )?; + Ok(out) +} + +/// Whether an ICC profile describes RGB data (header bytes 16..20, the data colour space). +pub(crate) fn is_rgb_profile(profile: &[u8]) -> bool { + profile.get(16..20) == Some(b"RGB ".as_slice()) +} + +fn sha256_file(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use exif::{In, Tag}; + use image::{Rgb, RgbImage}; + + use super::*; + + /// Serves canned bytes per URL and records every fetch. + struct FakeFetcher { + bytes: Vec<(String, Vec)>, + calls: Mutex>, + } + + impl FakeFetcher { + fn new(bytes: Vec<(String, Vec)>) -> Self { + Self { + bytes, + calls: Mutex::new(Vec::new()), + } + } + fn calls(&self) -> usize { + self.calls.lock().unwrap().len() + } + } + + impl Fetcher for FakeFetcher { + fn fetch(&self, url: &str, dest: &Path) -> Result<()> { + self.calls.lock().unwrap().push(url.to_owned()); + let (_, bytes) = self + .bytes + .iter() + .find(|(u, _)| u == url) + .ok_or_else(|| eyre!("404 {url}"))?; + fs::write(dest, bytes)?; + Ok(()) + } + } + + /// A small gradient JPEG with a given size — distinct content per seed value. + fn jpeg(width: u32, height: u32, seed: u8) -> Vec { + let img = RgbImage::from_fn(width, height, |x, y| { + Rgb([(x % 256) as u8, (y % 256) as u8, seed]) + }); + let mut out = Vec::new(); + JpegEncoder::new_with_quality(&mut out, 95) + .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) + .unwrap(); + out + } + + fn manifest_for(photos: &[(&str, &[u8])]) -> Manifest { + use std::fmt::Write as _; + let mut text = String::new(); + for (i, (id, bytes)) in photos.iter().enumerate() { + write!( + text, + "[[photo]]\nid = \"{id}\"\nday_offset = {}\ntime = \"12:00\"\nurl = \"https://example.test/{id}.jpg\"\n\ + sha256 = \"{}\"\nsource_page = \"https://example.test/{id}\"\nauthor = \"A\"\nlicense = \"CC0-1.0\"\n", + i + 2, + hex::encode(Sha256::digest(bytes)) + ) + .unwrap(); + } + Manifest::parse(&text).unwrap() + } + + fn fetcher_for(photos: &[(&str, &[u8])]) -> FakeFetcher { + FakeFetcher::new( + photos + .iter() + .map(|(id, b)| (format!("https://example.test/{id}.jpg"), b.to_vec())) + .collect(), + ) + } + + #[test] + fn cache_fetches_once_then_hits() { + let tmp = tempfile::tempdir().unwrap(); + let bytes = jpeg(8, 8, 1); + let photos = [("a", bytes.as_slice())]; + let manifest = manifest_for(&photos); + let fetcher = fetcher_for(&photos); + + let (path, first) = ensure_cached(&manifest.photos[0], tmp.path(), &fetcher).unwrap(); + let (_, second) = ensure_cached(&manifest.photos[0], tmp.path(), &fetcher).unwrap(); + assert_eq!((first, second), (CacheOutcome::Fetched, CacheOutcome::Hit)); + assert_eq!(fetcher.calls(), 1); + assert_eq!(fs::read(path).unwrap(), bytes); + } + + #[test] + fn corrupted_cache_entry_is_replaced() { + let tmp = tempfile::tempdir().unwrap(); + let bytes = jpeg(8, 8, 1); + let photos = [("a", bytes.as_slice())]; + let manifest = manifest_for(&photos); + let fetcher = fetcher_for(&photos); + fs::write( + tmp.path() + .join(format!("{}.jpg", manifest.photos[0].sha256)), + b"stale", + ) + .unwrap(); + + let (path, outcome) = ensure_cached(&manifest.photos[0], tmp.path(), &fetcher).unwrap(); + assert_eq!(outcome, CacheOutcome::Fetched); + assert_eq!(fs::read(path).unwrap(), bytes); + } + + #[test] + fn bytes_that_fail_the_pin_are_rejected_and_not_cached() { + let tmp = tempfile::tempdir().unwrap(); + let pinned = jpeg(8, 8, 1); + let manifest = manifest_for(&[("a", pinned.as_slice())]); + let served = jpeg(8, 8, 2); + let fetcher = fetcher_for(&[("a", served.as_slice())]); + + let err = ensure_cached(&manifest.photos[0], tmp.path(), &fetcher).unwrap_err(); + assert!( + format!("{err:#}").contains("do not match the manifest pin"), + "{err:#}" + ); + assert_eq!( + fs::read_dir(tmp.path()).unwrap().count(), + 0, + "nothing left behind" + ); + } + + #[test] + fn normalize_rotates_by_the_source_orientation() { + // A 60×20 landscape JPEG whose EXIF says "rotate 90° clockwise" (Orientation = 6). + let mut exif = + capture_exif::capture_date_block(jiff::civil::date(2020, 1, 1).at(0, 0, 0, 0)); + exif[18..20].copy_from_slice(&6u16.to_be_bytes()); + let img = RgbImage::from_pixel(60, 20, Rgb([200, 100, 50])); + let mut src = Vec::new(); + let mut encoder = JpegEncoder::new_with_quality(&mut src, 95); + encoder.set_exif_metadata(exif).unwrap(); + encoder + .write_image(img.as_raw(), 60, 20, image::ExtendedColorType::Rgb8) + .unwrap(); + + let out = normalize(&src, jiff::civil::date(2026, 1, 1).at(9, 0, 0, 0)).unwrap(); + let decoded = image::load_from_memory(&out).unwrap(); + assert_eq!( + (decoded.width(), decoded.height()), + (20, 60), + "stored upright" + ); + let exif = exif::Reader::new() + .read_from_container(&mut Cursor::new(&out)) + .unwrap(); + let orientation = exif.get_field(Tag::Orientation, In::PRIMARY).unwrap(); + assert_eq!( + orientation.value.get_uint(0), + Some(1), + "and declared upright" + ); + } + + #[test] + fn only_rgb_icc_profiles_are_carried_over() { + let mut header = vec![0u8; 128]; + header[16..20].copy_from_slice(b"RGB "); + assert!(is_rgb_profile(&header)); + header[16..20].copy_from_slice(b"GRAY"); + assert!(!is_rgb_profile(&header)); + header[16..20].copy_from_slice(b"CMYK"); + assert!(!is_rgb_profile(&header)); + assert!(!is_rgb_profile(b"short")); + } + + #[test] + fn normalize_bounds_the_long_edge_and_keeps_aspect() { + let out = normalize( + &jpeg(3000, 1500, 7), + jiff::civil::date(2026, 1, 1).at(9, 0, 0, 0), + ) + .unwrap(); + let img = image::load_from_memory(&out).unwrap(); + assert_eq!((img.width(), img.height()), (MAX_EDGE, MAX_EDGE / 2)); + } + + #[test] + fn normalize_leaves_small_images_at_their_size() { + let out = normalize( + &jpeg(640, 480, 7), + jiff::civil::date(2026, 1, 1).at(9, 0, 0, 0), + ) + .unwrap(); + let img = image::load_from_memory(&out).unwrap(); + assert_eq!((img.width(), img.height()), (640, 480)); + } + + #[test] + fn normalize_writes_the_capture_date() { + let out = normalize( + &jpeg(64, 64, 7), + jiff::civil::date(2026, 9, 20).at(17, 40, 0, 0), + ) + .unwrap(); + let exif = exif::Reader::new() + .read_from_container(&mut Cursor::new(&out)) + .unwrap(); + let field = exif.get_field(Tag::DateTimeOriginal, In::PRIMARY).unwrap(); + assert_eq!(field.display_value().to_string(), "2026-09-20 17:40:00"); + } + + #[test] + fn normalize_is_byte_deterministic() { + let src = jpeg(2500, 1200, 3); + let at = jiff::civil::date(2026, 5, 5).at(5, 5, 5, 0); + assert_eq!(normalize(&src, at).unwrap(), normalize(&src, at).unwrap()); + } + + #[test] + fn run_is_idempotent_and_fetches_only_once() { + let tmp = tempfile::tempdir().unwrap(); + let (a, b) = (jpeg(40, 30, 1), jpeg(30, 40, 2)); + let photos = [("first", a.as_slice()), ("second", b.as_slice())]; + let manifest = manifest_for(&photos); + let fetcher = fetcher_for(&photos); + let layout = Layout::under(tmp.path()); + let anchor = jiff::civil::date(2026, 9, 22); + + let first = run(&manifest, &layout, anchor, &fetcher).unwrap(); + let second = run(&manifest, &layout, anchor, &fetcher).unwrap(); + assert_eq!(first, second); + assert_eq!(fetcher.calls(), 2, "second run served from cache"); + + let mut names: Vec<_> = fs::read_dir(&layout.out) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + names.sort(); + assert_eq!(names, ["01-first.jpg", "02-second.jpg"]); + assert!(!layout.out.with_extension("staging").exists()); + } + + #[test] + fn run_rebuilds_the_directory_without_stale_files() { + let tmp = tempfile::tempdir().unwrap(); + let a = jpeg(40, 30, 1); + let photos = [("only", a.as_slice())]; + let layout = Layout::under(tmp.path()); + fs::create_dir_all(&layout.out).unwrap(); + fs::write(layout.out.join("99-removed.jpg"), b"old").unwrap(); + + run( + &manifest_for(&photos), + &layout, + jiff::civil::date(2026, 9, 22), + &fetcher_for(&photos), + ) + .unwrap(); + assert!(!layout.out.join("99-removed.jpg").exists()); + } + + #[test] + fn a_different_anchor_changes_only_the_dates() { + let tmp = tempfile::tempdir().unwrap(); + let a = jpeg(40, 30, 1); + let photos = [("only", a.as_slice())]; + let manifest = manifest_for(&photos); + let fetcher = fetcher_for(&photos); + let layout = Layout::under(tmp.path()); + let file = layout.out.join("01-only.jpg"); + let d1 = run(&manifest, &layout, jiff::civil::date(2026, 9, 22), &fetcher).unwrap(); + let pixels1 = image::open(&file).unwrap().into_rgb8(); + let d2 = run(&manifest, &layout, jiff::civil::date(2026, 9, 23), &fetcher).unwrap(); + let pixels2 = image::open(&file).unwrap().into_rgb8(); + assert_ne!(d1, d2, "the embedded capture date moved"); + assert_eq!(pixels1, pixels2, "the image itself did not"); + } +} diff --git a/xtask/translations/readme/ar.json b/xtask/translations/readme/ar.json index 76843430..ee7fc86a 100644 --- a/xtask/translations/readme/ar.json +++ b/xtask/translations/readme/ar.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "ومع ذلك، من المهم ملاحظة أن Capsule (على الأقل حاليًا) يتطلب خادمًا **مستضافًا ذاتيًا** يستلزم بعض المعرفة التقنية. هذا ليس حلاً جاهزًا للاستخدام الفوري بل مشروع مفتوح المصدر قادر ويُطوَّر بنشاط. لقد أُنشئ بدافع الشغف، لذا فأنا (بصفتي المؤلف) لا أطلب أي تعويض مالي. أفضل شكل من أشكال التعويض هو المساهمات التقنية والملاحظات!" }, - { - "source": "## Screenshots", - "target": "## لقطات الشاشة" - }, { "source": "## Who is Capsule for?", "target": "## لمن يُصمَّم Capsule؟" diff --git a/xtask/translations/readme/de.json b/xtask/translations/readme/de.json index f29560d3..5eb9a623 100644 --- a/xtask/translations/readme/de.json +++ b/xtask/translations/readme/de.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "Es ist jedoch wichtig zu beachten, dass Capsule (zumindest derzeit) einen **selbst gehosteten** Server erfordert, was einiges an technischem Wissen voraussetzt. Dies ist keine schlüsselfertige Lösung, sondern ein leistungsfähiges und aktiv weiterentwickeltes Open-Source-Projekt. Es entstand aus Leidenschaft, weshalb ich (als Autor) keine finanzielle Entschädigung erwarte. Die beste Form der Entschädigung sind technische Beiträge und Feedback!" }, - { - "source": "## Screenshots", - "target": "## Screenshots" - }, { "source": "## Who is Capsule for?", "target": "## Für wen ist Capsule gedacht?" diff --git a/xtask/translations/readme/es.json b/xtask/translations/readme/es.json index 7d450491..fd0972c2 100644 --- a/xtask/translations/readme/es.json +++ b/xtask/translations/readme/es.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "Sin embargo, es importante señalar que (al menos por ahora) Capsule requiere un servidor **autoalojado**, lo que exige ciertos conocimientos técnicos. No se trata de una solución llave en mano, sino de un proyecto de código abierto capaz y desarrollado activamente. Se creó por pasión, así que yo (como autor) no pido ninguna compensación económica. ¡La mejor forma de compensación son las contribuciones técnicas y los comentarios!" }, - { - "source": "## Screenshots", - "target": "## Capturas de pantalla" - }, { "source": "## Who is Capsule for?", "target": "## ¿Para quién es Capsule?" diff --git a/xtask/translations/readme/fr.json b/xtask/translations/readme/fr.json index 119cb117..f048b20f 100644 --- a/xtask/translations/readme/fr.json +++ b/xtask/translations/readme/fr.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "Cependant, il est important de noter que (du moins actuellement) Capsule nécessite un serveur **auto-hébergé**, ce qui requiert certaines connaissances techniques. Il ne s'agit pas d'une solution clé en main, mais plutôt d'un projet open source performant et activement développé. Il a été créé par passion, et je (en tant qu'auteur) ne demande donc aucune compensation financière. La meilleure forme de compensation reste les contributions techniques et les retours !" }, - { - "source": "## Screenshots", - "target": "## Captures d'écran" - }, { "source": "## Who is Capsule for?", "target": "## À qui s'adresse Capsule ?" diff --git a/xtask/translations/readme/hi.json b/xtask/translations/readme/hi.json index 66fe4384..00eb2c84 100644 --- a/xtask/translations/readme/hi.json +++ b/xtask/translations/readme/hi.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "हालांकि, यह ध्यान रखना महत्वपूर्ण है कि (कम से कम अभी के लिए) Capsule को एक **सेल्फ़-होस्टेड** सर्वर की आवश्यकता होती है जिसके लिए कुछ तकनीकी ज्ञान चाहिए। यह कोई टर्न-की समाधान नहीं है, बल्कि एक सक्षम और सक्रिय रूप से विकसित हो रहा ओपन-सोर्स प्रोजेक्ट है। यह जुनून से बनाया गया था, इसलिए मैं (लेखक के रूप में) किसी भी आर्थिक मुआवज़े की मांग नहीं करता। मुआवज़े का सबसे अच्छा रूप तकनीकी योगदान और फ़ीडबैक है!" }, - { - "source": "## Screenshots", - "target": "## स्क्रीनशॉट" - }, { "source": "## Who is Capsule for?", "target": "## Capsule किसके लिए है?" diff --git a/xtask/translations/readme/it.json b/xtask/translations/readme/it.json index df14e301..979a14ff 100644 --- a/xtask/translations/readme/it.json +++ b/xtask/translations/readme/it.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "Tuttavia, è importante notare che (almeno attualmente) Capsule richiede un server **self-hosted**, il che richiede alcune conoscenze tecniche. Non si tratta di una soluzione chiavi in mano, bensì di un progetto open-source capace e sviluppato attivamente. È stato creato per passione, quindi io (come autore) non chiedo alcun compenso monetario. La migliore forma di compenso sono i contributi tecnici e i feedback!" }, - { - "source": "## Screenshots", - "target": "## Screenshot" - }, { "source": "## Who is Capsule for?", "target": "## A chi è rivolto Capsule?" diff --git a/xtask/translations/readme/ja.json b/xtask/translations/readme/ja.json index 752053c4..f4df496a 100644 --- a/xtask/translations/readme/ja.json +++ b/xtask/translations/readme/ja.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "ただし、(少なくとも現時点では)Capsuleには**セルフホスト**型のサーバーが必要であり、ある程度の技術的知識が求められる点に注意してください。これはターンキー型のソリューションではなく、能力が高く積極的に開発が続けられているオープンソースプロジェクトです。このプロジェクトは情熱から生まれたものであり、作者である私は金銭的な対価を一切求めていません。最良の対価は、技術的な貢献とフィードバックです!" }, - { - "source": "## Screenshots", - "target": "## スクリーンショット" - }, { "source": "## Who is Capsule for?", "target": "## Capsuleは誰のためのものですか?" diff --git a/xtask/translations/readme/ko.json b/xtask/translations/readme/ko.json index 0828c799..6c7c7931 100644 --- a/xtask/translations/readme/ko.json +++ b/xtask/translations/readme/ko.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "다만 (적어도 현재는) Capsule이 어느 정도의 기술적 지식이 필요한 **셀프 호스팅** 서버를 필요로 한다는 점은 중요하게 알아두셔야 합니다. 이는 즉시 사용 가능한 솔루션이 아니라 역량 있고 활발하게 개발되고 있는 오픈소스 프로젝트입니다. 이 프로젝트는 열정으로 만들어졌으며, 저(작성자)는 어떠한 금전적 대가도 요구하지 않습니다. 가장 좋은 형태의 보상은 기술적 기여와 피드백입니다!" }, - { - "source": "## Screenshots", - "target": "## 스크린샷" - }, { "source": "## Who is Capsule for?", "target": "## Capsule은 누구를 위한 것인가요?" diff --git a/xtask/translations/readme/pt-BR.json b/xtask/translations/readme/pt-BR.json index e1a629f0..57b604ee 100644 --- a/xtask/translations/readme/pt-BR.json +++ b/xtask/translations/readme/pt-BR.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "No entanto, é importante notar que (pelo menos atualmente) o Capsule requer um servidor **auto-hospedado**, o que exige algum conhecimento técnico. Esta não é uma solução pronta para uso, mas sim um projeto de código aberto capaz e em desenvolvimento ativo. Ele foi criado por paixão e, por isso, eu (como autor) não peço nenhuma compensação monetária. A melhor forma de compensação é contribuições técnicas e feedback!" }, - { - "source": "## Screenshots", - "target": "## Capturas de tela" - }, { "source": "## Who is Capsule for?", "target": "## Para quem é o Capsule?" diff --git a/xtask/translations/readme/ru.json b/xtask/translations/readme/ru.json index 5e7bbeee..787b347b 100644 --- a/xtask/translations/readme/ru.json +++ b/xtask/translations/readme/ru.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "Однако важно отметить, что (по крайней мере на данный момент) Capsule требует **самостоятельно развёрнутого (self-hosted)** сервера, что предполагает определённые технические знания. Это не готовое «под ключ» решение, а скорее функциональный и активно развивающийся проект с открытым исходным кодом. Он создан из увлечения, поэтому я (как автор) не прошу никакой денежной компенсации. Лучшая форма благодарности — технический вклад и обратная связь!" }, - { - "source": "## Screenshots", - "target": "## Скриншоты" - }, { "source": "## Who is Capsule for?", "target": "## Для кого предназначен Capsule?" diff --git a/xtask/translations/readme/zh-Hans.json b/xtask/translations/readme/zh-Hans.json index 5149ff85..fe52c7a6 100644 --- a/xtask/translations/readme/zh-Hans.json +++ b/xtask/translations/readme/zh-Hans.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "不过需要注意的是,(至少目前)Capsule 需要一台**自托管**的服务器,这需要一定的技术知识。这并非一个开箱即用的解决方案,而是一个功能强大、正在积极开发中的开源项目。它诞生于热爱,因此我(作为作者)不要求任何金钱报酬。最好的回报形式是技术贡献和反馈!" }, - { - "source": "## Screenshots", - "target": "## 截图" - }, { "source": "## Who is Capsule for?", "target": "## Capsule 适合谁使用?" diff --git a/xtask/translations/readme/zh-Hant.json b/xtask/translations/readme/zh-Hant.json index 89149063..086c80f7 100644 --- a/xtask/translations/readme/zh-Hant.json +++ b/xtask/translations/readme/zh-Hant.json @@ -59,10 +59,6 @@ "source": "However, it is important to note that (at least currently) Capsule requires a **self-hosted** server which requires some technical knowledge. This is not a turn-key solution but rather a capable and actively-developed open-source project. It was created out of passion and so I (as the author) do not ask for any monetary compensation. The best form of compensation is technical contributions and feedback!", "target": "不過,值得注意的是(至少目前而言)Capsule 需要**自行架設**伺服器,這需要一定的技術知識。這並非開箱即用的解決方案,而是一個功能完善、持續積極開發的開源專案。它是出於熱情而誕生的,因此身為作者的我不要求任何金錢報酬。最好的回報形式就是技術貢獻與意見回饋!" }, - { - "source": "## Screenshots", - "target": "## 截圖" - }, { "source": "## Who is Capsule for?", "target": "## Capsule 適合誰使用?"