From c1501b8ea2c5fba605f3a0a847ff74b7c05baa53 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 22 Jul 2026 12:06:39 +0800 Subject: [PATCH 1/3] another fix --- services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js b/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js index a4904a6..fe52d31 100644 --- a/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js +++ b/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js @@ -35,14 +35,17 @@ export default async function init() { const micropip = pyodide.pyimport("micropip"); await micropip.install("pydantic"); - // Install pyeye1's own wheel from pkg/ next to this shim. + // Install pyeye1's own wheel from pkg/ next to this shim. `no-cache` revalidates against the server so a + // rebuilt wheel is picked up immediately -- a stale cached wheel paired with a fresh shim (or vice versa) + // silently breaks the render/analysis payload contract between the two. const installLocalWheel = async (path) => { - const bytes = new Uint8Array(await fetch(new URL(path, import.meta.url)).then((r) => r.arrayBuffer())); + const wheelResp = await fetch(new URL(path, import.meta.url), { cache: "no-cache" }); + const bytes = new Uint8Array(await wheelResp.arrayBuffer()); pyodide.FS.writeFile(`/tmp/${path}`, bytes); pyodide.runPython(`import sys\nsys.path.insert(0, "/tmp/${path}")`); }; - const pkg = await fetch(new URL("package.json", import.meta.url)).then((r) => r.json()); + const pkg = await fetch(new URL("package.json", import.meta.url), { cache: "no-cache" }).then((r) => r.json()); await installLocalWheel(`${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`); // et-ws is its own ws-module mounted at /modules/et-ws/; delegate its wheel install to its shim. const { installWheel: installEtWs } = await import("/modules/et-ws/et_ws.js"); From 9a8c66634c8e9638260c06e82192b1104e735f29 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 22 Jul 2026 14:11:02 +0800 Subject: [PATCH 2/3] Log images to server tty --- Cargo.lock | 509 +++++++++++++++++- Cargo.toml | 1 + services/storage/Cargo.toml | 1 + services/storage/src/routes.rs | 52 +- services/storage/tests/image_logging.rs | 29 + services/storage/tests/put.rs | 28 + .../ws-modules/pydata1/pkg/et_ws_pydata1.js | 7 + services/ws-modules/pydata1/pkg/package.json | 2 +- .../ws-modules/pydata1/pydata1/__init__.py | 25 +- services/ws-modules/pydata1/pyproject.toml | 2 +- .../ws-modules/pyeye1/pkg/et_ws_pyeye1.js | 42 +- .../ws-modules/pyeye1/pyeye1/eye_detection.py | 54 +- services/ws-modules/pyeye1/pyproject.toml | 2 +- .../pyeye1/tests/test_run_workflow.py | 141 +++++ services/ws-server/static/app.js | 57 +- services/ws-server/static/index.html | 10 +- services/ws-server/static/meters.js | 11 +- services/ws-server/static/style.css | 34 ++ uv.lock | 4 +- 19 files changed, 973 insertions(+), 38 deletions(-) create mode 100644 services/storage/tests/image_logging.rs diff --git a/Cargo.lock b/Cargo.lock index 45072f4..36cb20b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -437,6 +455,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_colours" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14eec43e0298190790f41679fe69ef7a829d2a2ddd78c8c00339e84710e435fe" +dependencies = [ + "rgb", +] + [[package]] name = "anstream" version = "1.0.0" @@ -517,6 +544,17 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "argon2" version = "0.6.0-rc.8" @@ -544,6 +582,15 @@ dependencies = [ "serde", ] +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ascii" version = "1.1.0" @@ -568,7 +615,7 @@ dependencies = [ "asn1-rs-derive 0.4.0", "asn1-rs-impl 0.1.0", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -584,7 +631,7 @@ dependencies = [ "asn1-rs-derive 0.6.0", "asn1-rs-impl 0.2.0", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 2.0.18", @@ -748,6 +795,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.17.1" @@ -931,6 +1021,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.13.0" @@ -940,6 +1036,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "bitvec" version = "1.1.1" @@ -1068,6 +1173,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -1314,7 +1425,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -1481,7 +1592,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22" dependencies = [ - "nom", + "nom 7.1.3", "proc-macro2", "quote", "syn 2.0.118", @@ -1552,6 +1663,17 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "const-hex" version = "1.19.1" @@ -2020,6 +2142,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix 1.1.4", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -3630,7 +3766,7 @@ checksum = "dbd676fbbab537128ef0278adb5576cf363cff6aa22a7b24effe97347cfab61e" dependencies = [ "asn1-rs 0.5.2", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -3644,7 +3780,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs 0.7.2", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -4138,6 +4274,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -4165,6 +4307,26 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -4340,6 +4502,7 @@ dependencies = [ "tokio", "tracing", "utoipa", + "viuer", ] [[package]] @@ -4857,6 +5020,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "smallvec 1.15.2", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -4929,6 +5108,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fd-lock" version = "4.0.4" @@ -5616,7 +5801,7 @@ dependencies = [ "byteorder", "crossbeam-channel", "flate2", - "nom", + "nom 7.1.3", "num-traits", ] @@ -6131,11 +6316,16 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", + "exr", "gif", "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rgb", + "tiff", "zune-core", "zune-jpeg", ] @@ -6160,6 +6350,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "impl-more" version = "0.1.9" @@ -6252,6 +6448,17 @@ dependencies = [ "command-error", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "inventory" version = "0.3.24" @@ -6483,7 +6690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117" dependencies = [ "miette 5.10.0", - "nom", + "nom 7.1.3", "thiserror 1.0.69", ] @@ -6629,6 +6836,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -6660,6 +6873,16 @@ dependencies = [ "cc", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -6799,6 +7022,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -6856,6 +7088,15 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", +] + [[package]] name = "md-5" version = "0.10.6" @@ -7168,6 +7409,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "node_resolver" version = "0.92.0" @@ -7219,6 +7469,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify" version = "8.2.0" @@ -7284,12 +7549,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -7309,6 +7595,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -7839,6 +8136,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "path-clean" version = "0.1.0" @@ -8259,6 +8562,19 @@ name = "profiling" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.118", +] [[package]] name = "progenitor" @@ -8425,6 +8741,29 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" version = "0.1.30" @@ -8500,13 +8839,22 @@ dependencies = [ "suffix_array", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "qr2term" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6867c60b38e9747a079a19614dbb5981a53f21b9a56c265f3bfdf6011a50a957" dependencies = [ - "crossterm", + "crossterm 0.28.1", "qrcode", ] @@ -8717,6 +9065,64 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -8778,6 +9184,12 @@ dependencies = [ "yasna 0.6.0", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -9029,6 +9441,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -9198,7 +9619,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -9956,6 +10377,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -10888,6 +11318,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.53" @@ -11716,6 +12160,17 @@ dependencies = [ "wtf8", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "v_htmlescape" version = "0.15.8" @@ -11740,6 +12195,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "viuer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3a696d8afebb0cae4b263ff8cae3286b20ea88df0a82187b13ee6fe8398dce" +dependencies = [ + "ansi_colours", + "base64 0.22.1", + "console", + "crossterm 0.29.0", + "image", + "tempfile", + "termcolor", +] + [[package]] name = "vsimd" version = "0.8.0" @@ -13377,7 +13847,7 @@ dependencies = [ "data-encoding", "der-parser 8.2.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.6.1", "rusticata-macros", "thiserror 1.0.69", @@ -13394,7 +13864,7 @@ dependencies = [ "data-encoding", "der-parser 10.0.0", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry 0.8.1", "ring", "rusticata-macros", @@ -13408,6 +13878,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yaml_parser" version = "0.2.1" @@ -13646,6 +14122,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index ffd011a..49b3b7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -179,6 +179,7 @@ tree-sitter = "0.25" tree-sitter-zig = "1" utoipa = { version = "5", features = ["actix_extras", "yaml"] } uuid = { version = "1", features = ["serde", "v4", "v7"] } +viuer = { version = "0.11", features = ["print-file"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" wasm-bindgen-test = "0.3" diff --git a/services/storage/Cargo.toml b/services/storage/Cargo.toml index 8ccb4a2..ff2ec61 100644 --- a/services/storage/Cargo.toml +++ b/services/storage/Cargo.toml @@ -28,6 +28,7 @@ thiserror.workspace = true tokio = { workspace = true, features = ["full"] } tracing.workspace = true utoipa = { workspace = true, optional = true } +viuer.workspace = true [dev-dependencies] actix-rt.workspace = true diff --git a/services/storage/src/routes.rs b/services/storage/src/routes.rs index 5d183b1..c1c8796 100644 --- a/services/storage/src/routes.rs +++ b/services/storage/src/routes.rs @@ -24,7 +24,7 @@ use std::path::PathBuf; use actix_web::{HttpRequest, HttpResponse, web}; use edge_toolkit::ws_server::AgentRegistry; use futures_util::StreamExt as _; -use tracing::info; +use tracing::{info, warn}; use crate::{StorageConfig, StorageError}; @@ -97,15 +97,61 @@ where let path = agent_dir.join(&filename); info!("Agent {} storing file: {:?}", agent_id, path); - let mut file = tokio::fs::File::create(path).await?; + let mut file = tokio::fs::File::create(&path).await?; + let mut bytes_written: u64 = 0; while let Some(chunk) = payload.next().await { let chunk = chunk?; - let _copied: u64 = tokio::io::copy(&mut chunk.as_ref(), &mut file).await?; + let copied = tokio::io::copy(&mut chunk.as_ref(), &mut file).await?; + bytes_written = bytes_written.saturating_add(copied); + } + + // This handler is the only path a file reaches storage through, so it is where every stored file can be + // watched exactly once with an accurate byte count and zero extra I/O -- a separate filesystem watcher + // would duplicate that work and risk observing a write mid-flight. + if is_image_filename(&filename) { + info!("Agent {} stored image {:?} ({} bytes)", agent_id, path, bytes_written); + show_image_on_tty(&path); } Ok(HttpResponse::Ok().finish()) } +/// Render a thumbnail of the image at `path` directly to stdout, so the operator actually *sees* what was +/// stored rather than just its filename and byte count. +/// +/// This bypasses `tracing` entirely and writes straight to the terminal: the escape sequences (or the +/// half-block art `viuer` falls back to) are tty-only presentation, not structured log data, and would +/// otherwise get shipped to the OTLP log exporter as log-record noise. Capped to 48 terminal columns so a +/// large capture doesn't flood the scrollback; decode/render failures (a corrupt upload, a non-terminal +/// stdout) only cost tty visibility, not the request, so they're reported to stderr rather than via `?`. +#[expect( + clippy::single_call_fn, + reason = "distinct step of put_file; kept separate for readability and testing" +)] +fn show_image_on_tty(path: &std::path::Path) { + let config = viuer::Config { + absolute_offset: false, + width: Some(48), + ..viuer::Config::default() + }; + if let Err(error) = viuer::print_from_file(path, &config) { + warn!("failed to render stored image {} to tty: {error}", path.display()); + } +} + +/// Return whether `filename`'s extension marks it as an image, for the tty log line in [`put_file`]. +/// +/// Extension-only: the handler streams bytes straight to disk without buffering, so sniffing magic bytes +/// would need a peek-buffer or a post-write read-back, while the filename is already on hand for free. +#[must_use] +pub fn is_image_filename(filename: &std::path::Path) -> bool { + const IMAGE_EXTENSIONS: [&str; 6] = ["png", "jpg", "jpeg", "gif", "webp", "bmp"]; + filename + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| IMAGE_EXTENSIONS.iter().any(|known| known.eq_ignore_ascii_case(ext))) +} + /// Download a file previously written to the named agent's storage bucket. #[cfg(feature = "openapi-spec")] #[utoipa::path( diff --git a/services/storage/tests/image_logging.rs b/services/storage/tests/image_logging.rs new file mode 100644 index 0000000..0accab5 --- /dev/null +++ b/services/storage/tests/image_logging.rs @@ -0,0 +1,29 @@ +//! Tests for `is_image_filename`, the extension check behind the storage service's tty log line for +//! stored images (see `put_file` in `src/routes.rs`). + +#![cfg(test)] + +use std::path::Path; + +use et_storage_service::routes::is_image_filename; + +#[test] +fn recognizes_common_image_extensions_case_insensitively() { + for ext in ["png", "jpg", "jpeg", "gif", "webp", "bmp", "PNG", "Jpg"] { + let filename = format!("capture.{ext}"); + assert!( + is_image_filename(Path::new(&filename)), + "{filename} should be recognized as an image" + ); + } +} + +#[test] +fn rejects_non_image_extensions_and_extensionless_names() { + for name in ["notes.txt", "data.json", "archive.tar.gz", "no-extension", ".hidden"] { + assert!( + !is_image_filename(Path::new(name)), + "{name} should not be recognized as an image" + ); + } +} diff --git a/services/storage/tests/put.rs b/services/storage/tests/put.rs index e494950..8362e20 100644 --- a/services/storage/tests/put.rs +++ b/services/storage/tests/put.rs @@ -107,6 +107,34 @@ async fn writes_file_for_registered_agent() { assert_eq!(written, body); } +/// An image PUT under `cargo test` has no real terminal on stdout, so `viuer`'s decode-and-render step +/// (triggered by the `.png` extension) is expected to fail internally. The route must still store the file +/// and return 200 regardless -- tty display is a best-effort side effect, never a reason to fail the upload. +#[actix_rt::test] +async fn stores_an_image_and_returns_200_even_though_tty_rendering_cannot_succeed_in_tests() { + let tmp = tempfile::tempdir().unwrap(); + let config = storage_config(&tmp); + let registry = registry_with_agent("agent-1"); + let app = test::init_service( + App::new() + .app_data(web::Data::new(registry)) + .app_data(web::Data::new(config.clone())) + .configure(|cfg| configure::<()>(cfg, &config)), + ) + .await; + + let body = b"\x89PNG\r\n\x1a\nnot a real png, just image-extension-shaped bytes".as_ref(); + let req = test::TestRequest::put() + .uri("/storage/agent-1/capture.png") + .set_payload(body) + .to_request(); + let resp = test::call_service(&app, req).await; + + assert_eq!(resp.status(), StatusCode::OK); + let written = fs_err::read(tmp.path().join("agent-1").join("capture.png")).unwrap(); + assert_eq!(written, body); +} + #[actix_rt::test] async fn surfaces_io_failure_as_500() { // Point the storage root at a *file* (not a directory). The handler's diff --git a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js index a1278b4..3160b8b 100644 --- a/services/ws-modules/pydata1/pkg/et_ws_pydata1.js +++ b/services/ws-modules/pydata1/pkg/et_ws_pydata1.js @@ -5,6 +5,7 @@ const PYODIDE_BASE_PATH = "/modules/pyodide/"; let pyodide = null; let pyMod = null; +let moduleVersion = null; function loadPyodideScript() { return new Promise((resolve, reject) => { @@ -67,6 +68,7 @@ export default async function init() { pyodide.runPython(`import sys\nsys.path.insert(0, "/tmp/${wheelName}")`); }; const pkg = await fetch(new URL("package.json", import.meta.url)).then((r) => r.json()); + moduleVersion = pkg.version; const ownWheel = `${pkg.name.replace(/-/g, "_")}-${pkg.version}-py3-none-any.whl`; await injectWheel(ownWheel); @@ -115,6 +117,10 @@ export async function run() { const el = document.getElementById("module-output"); if (el) el.value = (el.value ? el.value + "\n" : "") + msg; }; + // Diagnostic marker: this is the version fetched from package.json + injected as the wheel filename, so + // seeing the right number here after a version bump proves the module reloaded fresh (no stale-cache reuse + // of the package.json/wheel fetches below, neither of which is cache-busted). + log(`pydata1 version: ${moduleVersion}`); // The Python side runs `Client(base_url=...)` against this origin and // does PUT/GET itself via the generated client + pyodide-http patch. @@ -125,6 +131,7 @@ export async function run() { pyodide.toPy(sleep), pyodide.toPy(log), pyodide.toPy(() => {}), + pyodide.toPy(() => document.getElementById("upload-consent")?.checked ?? false), ); } catch (err) { log(`pydata1 run failed: ${String(err)}`); diff --git a/services/ws-modules/pydata1/pkg/package.json b/services/ws-modules/pydata1/pkg/package.json index e154368..5fe123d 100644 --- a/services/ws-modules/pydata1/pkg/package.json +++ b/services/ws-modules/pydata1/pkg/package.json @@ -8,5 +8,5 @@ "main": "et_ws_pydata1.js", "name": "et-ws-pydata1", "type": "module", - "version": "0.1.0" + "version": "0.1.2" } diff --git a/services/ws-modules/pydata1/pydata1/__init__.py b/services/ws-modules/pydata1/pydata1/__init__.py index 8691c65..9c2e369 100644 --- a/services/ws-modules/pydata1/pydata1/__init__.py +++ b/services/ws-modules/pydata1/pydata1/__init__.py @@ -8,7 +8,7 @@ from et_rest_client.types import File -async def run(agent_id, base_url, sleep_ms, log, set_status) -> None: +async def run(agent_id, base_url, sleep_ms, log, set_status, upload_consent) -> None: """Execute the data1 workflow: store, fetch, verify.""" # httpx ships its own transport stack; in Pyodide that stack has no # network access. `pyodide_http.patch_all()` swaps httpx's transports @@ -48,6 +48,29 @@ async def run(agent_id, base_url, sleep_ms, log, set_status) -> None: set_status(msg) raise RuntimeError("Data mismatch") + # Diagnostic: exercises the exact same "read the page's upload-consent checkbox, then gate a storage + # PUT on it" mechanism pyeye1 uses, but fully within pydata1's headless, camera-free, fully-observable + # workflow -- so the checkbox/upload plumbing itself can be verified without needing a real camera or a + # remote device to drive it. + consent = upload_consent() + log(f"pydata1: upload_consent() returned {consent}") + if consent: + consent_filename = "consent_test.txt" + consent_content = f"consent test at {datetime.now(timezone.utc).isoformat()}" + try: + async with Client(base_url=base_url) as c: + await put_file.asyncio_detailed( + agent_id, + consent_filename, + client=c, + body=File(payload=consent_content.encode("utf-8")), + ) + log(f"pydata1: consent-gated upload succeeded: {consent_filename}") + except Exception as exc: + log(f"pydata1: consent-gated upload failed: {exc}") + else: + log("pydata1: upload consent not granted, skipping consent-gated upload") + await sleep_ms(2000) log("pydata1: workflow complete") set_status("pydata1: workflow complete") diff --git a/services/ws-modules/pydata1/pyproject.toml b/services/ws-modules/pydata1/pyproject.toml index 92c806f..2bad0a6 100644 --- a/services/ws-modules/pydata1/pyproject.toml +++ b/services/ws-modules/pydata1/pyproject.toml @@ -4,7 +4,7 @@ description = "Python data 1" license = "Apache-2.0 OR MIT" name = "et-ws-pydata1" requires-python = ">=3.10" -version = "0.1.0" +version = "0.1.2" [build-system] build-backend = "uv_build" diff --git a/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js b/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js index fe52d31..5f98eac 100644 --- a/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js +++ b/services/ws-modules/pyeye1/pkg/et_ws_pyeye1.js @@ -120,6 +120,8 @@ function platformFor(state) { set_status: setStatus, should_stop: () => runtime !== state, cleanup: () => cleanup(state), + upload_consent: () => document.getElementById("upload-consent")?.checked ?? false, + save_eye_capture: () => saveEyeCapture(state), }; } @@ -180,8 +182,7 @@ function render(resultsJson) { const color = EYE_COLORS[eye.label] ?? "#fffdfa"; ctx.strokeStyle = color; ctx.strokeRect(left, top, Math.max(right - left, 1), Math.max(bottom - top, 1)); - ctx.fillStyle = color; - ctx.fillText(eye.label === "left_eye" ? "L" : "R", left, Math.max(top - 4, 12)); + drawOutlinedText(ctx, eye.label === "left_eye" ? "L" : "R", left, Math.max(top - 4, 12), color, 3 / scale); } ctx.lineWidth = 2 / scale; @@ -198,6 +199,26 @@ function render(resultsJson) { renderAnalysis(ctx, payload.analysis); } +// Encodes whatever the overlay canvas currently shows (the cropped eye band plus its box/iris/verdict +// overlay) as a PNG and uploads it to the connected agent's storage bucket. Python decides *whether* and +// *when* to call this (gated on the upload-consent checkbox and fired at most once per run); this primitive +// only performs the one browser-side operation of turning the current frame into stored bytes. +async function saveEyeCapture(state) { + const canvas = element("video-output-canvas", HTMLCanvasElement); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (result) => (result ? resolve(result) : reject(new Error("canvas.toBlob returned null"))), + "image/png", + ); + }); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const agentId = state.client.get_agent_id(); + const filename = `pyeye1-eye-capture-${Date.now()}.png`; + const resp = await fetch(`/storage/${agentId}/${filename}`, { method: "PUT", body: bytes }); + if (!resp.ok) throw new Error(`eye capture upload failed: ${resp.status} ${resp.statusText}`); + log(`eye capture saved to storage: ${filename} (${bytes.length} bytes)`); +} + // Screening-verdict overlay, top-left. Python sends analysis=null until the first window completes, and each // screening reports status "insufficient_data" until it has enough non-blink samples to rate. function renderAnalysis(ctx, analysis) { @@ -211,11 +232,24 @@ function renderAnalysis(ctx, analysis) { } if (lines.length > 0) lines.push("screening demo -- not a medical diagnosis"); lines.forEach((line, index) => { - ctx.fillStyle = line.includes("DETECTED") ? "#ffb84d" : "#d7e0e8"; - ctx.fillText(line, 8, 20 + index * 20); + const color = line.includes("DETECTED") ? "#1e90ff" : "#d7e0e8"; + drawOutlinedText(ctx, line, 8, 20 + index * 20, color); }); } +// A single fill color can't stay legible against a live video feed: skin tone, lighting, and background all +// vary per frame, and no one color reads well everywhere (the original amber DETECTED text all but vanished +// against warm skin). A dark outline behind the fill guarantees contrast regardless of what's underneath, +// the same trick broadcast captions and video overlays use. +function drawOutlinedText(ctx, text, x, y, fillColor, lineWidth = 3) { + ctx.lineJoin = "round"; + ctx.lineWidth = lineWidth; + ctx.strokeStyle = "rgba(0, 0, 0, 0.85)"; + ctx.strokeText(text, x, y); + ctx.fillStyle = fillColor; + ctx.fillText(text, x, y); +} + function cleanup(state) { if (runtime === state) runtime = null; state?.landmarker?.close?.(); diff --git a/services/ws-modules/pyeye1/pyeye1/eye_detection.py b/services/ws-modules/pyeye1/pyeye1/eye_detection.py index 9583692..2675feb 100644 --- a/services/ws-modules/pyeye1/pyeye1/eye_detection.py +++ b/services/ws-modules/pyeye1/pyeye1/eye_detection.py @@ -101,11 +101,14 @@ async def run(platform) -> None: `platform` supplies the primitives Python cannot implement itself, each a single browser operation with no sequencing, polling, or timeout logic. Sync members: `ws_state()`, `agent_id()`, `send_event(json)`, - `video_size() -> [w, h]`, `render(json)`, `log(str)`, `set_status(str)`, `should_stop()`, `cleanup()`. - Async members: `connect_ws()`, `start_camera()`, `play_video()`, `sleep(ms)`, - `load_landmarker(model_path, bundle_path, wasm_path)`, and `infer()`, which returns one FaceLandmarker - pass as the JSON string `{"faces": [[x0, y0, x1, y1, ...], ...], "width": W, "height": H}` where each - face is the flat list of normalized landmark coordinates. + `video_size() -> [w, h]`, `render(json)`, `log(str)`, `set_status(str)`, `should_stop()`, `cleanup()`, + `upload_consent() -> bool` (the page's data-upload checkbox). Async members: `connect_ws()`, + `start_camera()`, `play_video()`, `sleep(ms)`, `load_landmarker(model_path, bundle_path, wasm_path)`, + `save_eye_capture()` (encodes the current overlay canvas as a PNG and uploads it to the connected + agent's storage bucket), and + `infer()`, which returns one FaceLandmarker pass as the JSON string + `{"faces": [[x0, y0, x1, y1, ...], ...], "width": W, "height": H}` where each face is the flat list of + normalized landmark coordinates. """ platform.set_status(starting_status()) platform.log(model_log_message()) @@ -161,6 +164,8 @@ async def sample_loop(platform) -> None: analysis: WindowAnalysis | None = None crop: Box | None = None last_analysis_ms = 0.0 + indicator_was_active = False + captured_for_episode = False while not platform.should_stop(): loop_started = time.monotonic() @@ -183,11 +188,33 @@ async def sample_loop(platform) -> None: while history and (now_s - history[0]["t"]) * 1000.0 > ANALYSIS_WINDOW_MS: history.popleft() - if now_s * 1000.0 - last_analysis_ms >= ANALYSIS_INTERVAL_MS: + is_analysis_tick = now_s * 1000.0 - last_analysis_ms >= ANALYSIS_INTERVAL_MS + if is_analysis_tick: last_analysis_ms = now_s * 1000.0 analysis = analyze_window(list(history)) platform.set_status(status_text(results, analysis)) platform.send_event(client_event_json(event_payload(results, analysis, width, height))) + + # Save one eye capture per detection -- "detection" means a screening indicator (eye + # misalignment or rhythmic oscillation) newly firing, not merely a face/eyes being visible. + # Edge-triggered on the indicator's own rising edge (not-detected -> detected), tracked + # independently of consent: a new episode resets `captured_for_episode` regardless of + # whether consent is granted yet, so if consent arrives partway through an already-active + # episode, that episode still gets its one capture rather than the edge having been silently + # consumed earlier while consent was still off. Its own try/except keeps a failed upload + # from being folded into (and misreported as) an inference error or aborting the loop; a + # failure still counts as "captured for this episode" so it isn't retried every tick. + indicator_active = analysis["misalignment"]["detected"] or analysis["oscillation"]["detected"] + if indicator_active and not indicator_was_active: + captured_for_episode = False + if indicator_active and not captured_for_episode and platform.upload_consent(): + captured_for_episode = True + try: + await platform.save_eye_capture() + except Exception as exc: + platform.log(f"eye capture failed: {exc}") + platform.send_event(eye_capture_error_event_json(str(exc))) + indicator_was_active = indicator_active platform.render(results_json(results, analysis, crop)) except Exception as exc: message = f"pyeye1 eye movement screening: inference error\n{exc}" @@ -329,6 +356,21 @@ def client_event_json(details: dict[str, object]) -> str: ).model_dump_json() +def eye_capture_error_event_json(error: str) -> str: + """Build the et-client-event JSON envelope for a failed eye-capture upload. + + `platform.log(...)` alone only reaches the browser's own on-page log, invisible to anyone watching the + server tty; this event puts the failure where it can actually be seen server-side, same as a successful + capture already is (via the storage service's own "stored image" log line). + """ + return WsClientEvent( + type="et-client-event", + capability="pyeye1", + action="eye_capture_failed", + details={"error": error}, + ).model_dump_json() + + def status_text(results: Sequence[FaceEyes], analysis: WindowAnalysis | None) -> str: """Render the browser status text used by the eye movement screening demo.""" eye_count = sum(len(result["eyes"]) for result in results) diff --git a/services/ws-modules/pyeye1/pyproject.toml b/services/ws-modules/pyeye1/pyproject.toml index 9575e0e..729ee3c 100644 --- a/services/ws-modules/pyeye1/pyproject.toml +++ b/services/ws-modules/pyeye1/pyproject.toml @@ -4,7 +4,7 @@ description = "Python eye screening (MediaPipe)" license = "Apache-2.0 OR MIT" name = "et-ws-pyeye1" requires-python = ">=3.10" -version = "0.1.0" +version = "0.1.1" [dependency-groups] dev = ["pytest", "pytest-cov"] diff --git a/services/ws-modules/pyeye1/tests/test_run_workflow.py b/services/ws-modules/pyeye1/tests/test_run_workflow.py index 2cf054f..a04b5fc 100644 --- a/services/ws-modules/pyeye1/tests/test_run_workflow.py +++ b/services/ws-modules/pyeye1/tests/test_run_workflow.py @@ -22,6 +22,33 @@ ) +def fake_analysis(*, misalignment: bool = False, oscillation: bool = False) -> dict: + """Build a minimal WindowAnalysis-shaped dict with a controlled misalignment/oscillation verdict. + + Used to drive the capture-triggering logic directly (rising edge of a screening indicator) without + needing real multi-second landmark sequences to organically produce a detection through the actual + gaze-analysis math, which is already covered by test_gaze_analysis.py. + """ + return { + "window_ms": 1000.0, + "samples": 20, + "valid_samples": 20, + "misalignment": { + "status": "ok", + "detected": misalignment, + "horizontal_deviation": 0.2 if misalignment else 0.0, + "vertical_deviation": 0.0, + }, + "oscillation": { + "status": "ok", + "detected": oscillation, + "axis": "horizontal" if oscillation else None, + "frequency_hz": 4.0 if oscillation else None, + "amplitude": 0.1 if oscillation else None, + }, + } + + def synthetic_face() -> list[float]: """Build one flat 478*2 landmark list with open eyes and centered irises.""" values = [0.0] * (MESH_LANDMARK_COUNT * 2) @@ -56,6 +83,8 @@ def __init__(self, stop_after: int = 5) -> None: self.landmarker_args: tuple[str, str, str] | None = None self.played = False self.cleaned = False + self.consent = False + self.capture_calls = 0 async def connect_ws(self) -> None: pass @@ -103,6 +132,12 @@ def should_stop(self) -> bool: def cleanup(self) -> None: self.cleaned = True + def upload_consent(self) -> bool: + return self.consent + + async def save_eye_capture(self) -> None: + self.capture_calls += 1 + class NeverConnectingPlatform(FakePlatform): def ws_state(self) -> str: @@ -114,6 +149,19 @@ async def start_camera(self) -> None: raise RuntimeError("Permission denied") +class MidSessionConsentPlatform(FakePlatform): + """Consent flips on only once a few samples have already run, simulating an opt-in mid-session.""" + + def upload_consent(self) -> bool: + return self.infer_calls >= 3 + + +class FailingCapturePlatform(FakePlatform): + async def save_eye_capture(self) -> None: + self.capture_calls += 1 + raise RuntimeError("upload failed") + + class RunWorkflowTests(unittest.IsolatedAsyncioTestCase): async def test_happy_path_drives_the_full_workflow(self) -> None: platform = FakePlatform(stop_after=5) @@ -160,6 +208,99 @@ async def test_camera_failure_propagates_and_still_cleans_up(self) -> None: self.assertTrue(platform.cleaned) self.assertEqual(platform.infer_calls, 0) + async def test_no_capture_while_eyes_are_visible_but_no_indicator_has_fired(self) -> None: + # Regression guard: a face/eyes being visible must never be enough on its own -- only an actual + # screening indicator (misalignment or oscillation) firing counts as a "detection". + platform = FakePlatform(stop_after=5) + platform.consent = True + with ( + patch.object(eye_detection, "ANALYSIS_INTERVAL_MS", 0.0), + patch.object(eye_detection, "analyze_window", return_value=fake_analysis()), + ): + await run(platform) + self.assertEqual(platform.capture_calls, 0) + + async def test_eye_capture_fires_once_on_the_indicators_rising_edge(self) -> None: + platform = FakePlatform(stop_after=5) + platform.consent = True + # not-detected, detected (rising edge -> capture #1), still detected (no repeat), cleared, + # detected again (a new rising edge -> capture #2). + analyses = [ + fake_analysis(), + fake_analysis(misalignment=True), + fake_analysis(misalignment=True), + fake_analysis(), + fake_analysis(oscillation=True), + ] + with ( + patch.object(eye_detection, "ANALYSIS_INTERVAL_MS", 0.0), + patch.object(eye_detection, "analyze_window", side_effect=analyses), + ): + await run(platform) + self.assertEqual(platform.capture_calls, 2) + + async def test_eye_capture_skipped_entirely_without_consent(self) -> None: + platform = FakePlatform(stop_after=5) + with ( + patch.object(eye_detection, "ANALYSIS_INTERVAL_MS", 0.0), + patch.object(eye_detection, "analyze_window", return_value=fake_analysis(misalignment=True)), + ): + await run(platform) + self.assertEqual(platform.capture_calls, 0) + self.assertFalse(any("eye capture" in line for line in platform.logs)) + + async def test_eye_capture_fires_on_first_rising_edge_after_consent_granted_mid_session(self) -> None: + platform = MidSessionConsentPlatform(stop_after=5) + # Indicator is active from the very first sample; consent only turns on at infer_calls >= 3, so the + # capture fires on sample 3 (the first tick where both are true) and not again for samples 4-5, since + # the indicator never drops back down to re-trigger a new rising edge. + with ( + patch.object(eye_detection, "ANALYSIS_INTERVAL_MS", 0.0), + patch.object(eye_detection, "analyze_window", return_value=fake_analysis(misalignment=True)), + ): + await run(platform) + self.assertEqual(platform.capture_calls, 1) + + async def test_eye_capture_failure_is_logged_and_does_not_abort_the_run(self) -> None: + platform = FailingCapturePlatform(stop_after=5) + platform.consent = True + # not-detected, detected (edge #1 -> attempt+fail), not-detected (clears), detected (edge #2 -> + # attempt+fail), not-detected. Two rising edges -> two failed attempts, each independently retried + # rather than the first failure suppressing the second episode's attempt. + analyses = [ + fake_analysis(), + fake_analysis(misalignment=True), + fake_analysis(), + fake_analysis(misalignment=True), + fake_analysis(), + ] + with ( + patch.object(eye_detection, "ANALYSIS_INTERVAL_MS", 0.0), + patch.object(eye_detection, "analyze_window", side_effect=analyses), + ): + await run(platform) + self.assertEqual(platform.capture_calls, 2) + failure_logs = [line for line in platform.logs if "eye capture failed" in line and "upload failed" in line] + self.assertEqual(len(failure_logs), 2) + self.assertEqual(platform.statuses[-1], stopped_status()) + + async def test_eye_capture_failure_is_also_reported_as_a_server_visible_event(self) -> None: + # platform.log() alone only reaches the browser's own log box; a failure must also be sent as a + # client-event so it's visible server-side without anyone having to watch the browser. + platform = FailingCapturePlatform(stop_after=5) + platform.consent = True + with ( + patch.object(eye_detection, "ANALYSIS_INTERVAL_MS", 0.0), + patch.object(eye_detection, "analyze_window", return_value=fake_analysis(oscillation=True)), + ): + await run(platform) + + failure_events = [json.loads(event) for event in platform.events if "eye_capture_failed" in event] + self.assertEqual(len(failure_events), 1) + self.assertEqual(failure_events[0]["capability"], "pyeye1") + self.assertEqual(failure_events[0]["action"], "eye_capture_failed") + self.assertEqual(failure_events[0]["details"]["error"], "upload failed") + if __name__ == "__main__": unittest.main() diff --git a/services/ws-server/static/app.js b/services/ws-server/static/app.js index f6f06d5..2833c5e 100644 --- a/services/ws-server/static/app.js +++ b/services/ws-server/static/app.js @@ -1,6 +1,12 @@ import init, { initTracing, WsClient, WsClientConfig } from "/modules/et-ws-wasm-agent/et_ws_wasm_agent.js"; -console.log("app.js: module loading started"); +// Bump this string on every meaningful app.js edit. index.html loads this file via a plain, non-cache-busted +//