sootsim 0.1.295 → 0.1.297
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -4
- package/detox/index.ts +380 -28
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/dist-cli/bin.js +5 -5
- package/dist-cli/chunks/{agent-ASAYE5YY.js → agent-EYT6T2IB.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-K2D4Q5AA.js → agent-wrapper-UU4WV5UG.js} +2 -2
- package/dist-cli/chunks/app-fonts-6SO36STZ.js +4 -0
- package/dist-cli/chunks/{assert-4FKHHDY7.js → assert-M4AVODJW.js} +2 -2
- package/dist-cli/chunks/{auth-BQPOPEFK.js → auth-JJNP2L7C.js} +2 -2
- package/dist-cli/chunks/{beta-3LNYONDF.js → beta-BIJ5MU6X.js} +2 -2
- package/dist-cli/chunks/{camera-Y5MZ5V4L.js → camera-FGQGSHG5.js} +2 -2
- package/dist-cli/chunks/{chunk-RV67EC7L.js → chunk-2JCUNWU2.js} +2 -2
- package/dist-cli/chunks/chunk-3QVML26F.js +4 -0
- package/dist-cli/chunks/{chunk-XCCDO6I2.js → chunk-445FUUZC.js} +1 -1
- package/dist-cli/chunks/{chunk-T2DHHOWE.js → chunk-4EAJWGB4.js} +2 -2
- package/dist-cli/chunks/chunk-63UBXHOB.js +4 -0
- package/dist-cli/chunks/{chunk-Y6FJ5FCY.js → chunk-6YIHNXMZ.js} +1 -1
- package/dist-cli/chunks/{chunk-DKS75M2I.js → chunk-74ATIHUI.js} +2 -2
- package/dist-cli/chunks/{chunk-UBIGBHUM.js → chunk-7O6HJRGX.js} +2 -2
- package/dist-cli/chunks/{chunk-UWFAWMPE.js → chunk-7XXIMTJR.js} +2 -2
- package/dist-cli/chunks/{chunk-I4QJVACX.js → chunk-AC2HNBEI.js} +1 -1
- package/dist-cli/chunks/chunk-BAFDYXCM.js +9 -0
- package/dist-cli/chunks/chunk-DMJN3NC3.js +12 -0
- package/dist-cli/chunks/{chunk-JPPGM6RZ.js → chunk-E4O5A2QP.js} +2 -2
- package/dist-cli/chunks/{chunk-FH7DQ2HB.js → chunk-EKA2DXOV.js} +2 -2
- package/dist-cli/chunks/{chunk-S6BEA6OC.js → chunk-ERRHAM2H.js} +1 -1
- package/dist-cli/chunks/{chunk-MEYVMDZR.js → chunk-FC2ULGKP.js} +2 -2
- package/dist-cli/chunks/{chunk-RNW36VM5.js → chunk-H6RQAFD5.js} +4 -4
- package/dist-cli/chunks/{chunk-2RI6RC6I.js → chunk-HNQ2FYAG.js} +2 -2
- package/dist-cli/chunks/{chunk-CVDC2ER2.js → chunk-HRPE3IND.js} +2 -2
- package/dist-cli/chunks/chunk-HTX6BHJZ.js +4 -0
- package/dist-cli/chunks/{chunk-6VF477QO.js → chunk-I4G2BKZB.js} +1 -1
- package/dist-cli/chunks/chunk-I6HFNMYG.js +125 -0
- package/dist-cli/chunks/{chunk-L7V46673.js → chunk-IKQSTS2U.js} +1 -1
- package/dist-cli/chunks/{chunk-7GZ6XF2B.js → chunk-IMXVXXWX.js} +2 -2
- package/dist-cli/chunks/{chunk-E6DZD6JD.js → chunk-IVV2KJTY.js} +1 -1
- package/dist-cli/chunks/{chunk-WFYQNZTT.js → chunk-K36T5764.js} +3 -3
- package/dist-cli/chunks/{chunk-SU5WQ4SQ.js → chunk-KQ7WHS3C.js} +1 -1
- package/dist-cli/chunks/{chunk-TNKZWHD2.js → chunk-LGU5JGT6.js} +1 -1
- package/dist-cli/chunks/{chunk-U2QPKFOY.js → chunk-O4IGQ7XT.js} +2 -2
- package/dist-cli/chunks/{chunk-UAZ3NVKO.js → chunk-OBKNUUIF.js} +2 -2
- package/dist-cli/chunks/chunk-OTYWESTV.js +4 -0
- package/dist-cli/chunks/chunk-QDL6YYL4.js +4 -0
- package/dist-cli/chunks/{chunk-XQRUWC3F.js → chunk-QRFC2ZZC.js} +2 -2
- package/dist-cli/chunks/chunk-RZQ7SBBQ.js +4 -0
- package/dist-cli/chunks/chunk-SDHGPSEC.js +4 -0
- package/dist-cli/chunks/{chunk-X5FTDIHQ.js → chunk-U37NEIQF.js} +1 -1
- package/dist-cli/chunks/chunk-V4N34AOX.js +15 -0
- package/dist-cli/chunks/{chunk-K7LQB4IF.js → chunk-VTYVJJHN.js} +1 -1
- package/dist-cli/chunks/chunk-VVSQCLBU.js +6 -0
- package/dist-cli/chunks/chunk-W2QIWNCW.js +6 -0
- package/dist-cli/chunks/{chunk-QPA44BZC.js → chunk-WDZ4PVKV.js} +11 -11
- package/dist-cli/chunks/chunk-WFXFYXUA.js +30 -0
- package/dist-cli/chunks/chunk-X5J5A63F.js +23 -0
- package/dist-cli/chunks/chunk-XKACQ3MD.js +4 -0
- package/dist-cli/chunks/{chunk-ETDZQUGI.js → chunk-XLT6Y654.js} +3 -3
- package/dist-cli/chunks/{chunk-4NJIXTVJ.js → chunk-Y7TQDOU5.js} +1 -1
- package/dist-cli/chunks/{chunk-7FYIMLU4.js → chunk-YNN2AELC.js} +17 -17
- package/dist-cli/chunks/{chunk-BX2NUJGR.js → chunk-YTTBKONB.js} +1 -1
- package/dist-cli/chunks/{chunk-3WQISFAK.js → chunk-Z3VP4WF6.js} +1 -1
- package/dist-cli/chunks/cleanup-Q4GUW4DU.js +25 -0
- package/dist-cli/chunks/{cli-version-I6BOBK57.js → cli-version-Y74RBK4R.js} +2 -2
- package/dist-cli/chunks/compat-CSFBMTVO.js +20 -0
- package/dist-cli/chunks/config-4ZW7VYBV.js +13 -0
- package/dist-cli/chunks/control-BFFHTY4I.js +4 -0
- package/dist-cli/chunks/daemon-746DWYWS.js +4 -0
- package/dist-cli/chunks/{debug-VG2JOF6H.js → debug-EEYUHHSR.js} +3 -3
- package/dist-cli/chunks/{desktop-GH2POW6X.js → desktop-Q4NXGDCY.js} +3 -3
- package/dist-cli/chunks/{detox-X6T265L5.js → detox-UMSQ33AB.js} +2 -2
- package/dist-cli/chunks/{device-VDOOSAXO.js → device-7STN6X5G.js} +2 -2
- package/dist-cli/chunks/{diagnose-6D6WNAGL.js → diagnose-7L67FGJY.js} +2 -2
- package/dist-cli/chunks/disk-cleanup-IISQYADA.js +4 -0
- package/dist-cli/chunks/drivers-RD6OW2D5.js +4 -0
- package/dist-cli/chunks/{film-UZWYISAB.js → film-2MP5BL6J.js} +3 -3
- package/dist-cli/chunks/flow-MKSEPFWS.js +4 -0
- package/dist-cli/chunks/help-4ADSAOGD.js +4 -0
- package/dist-cli/chunks/{hidden-runtime-alias-UGMJ4LJX.js → hidden-runtime-alias-HTJVTEHX.js} +2 -2
- package/dist-cli/chunks/home-paths-KOZFBUVS.js +4 -0
- package/dist-cli/chunks/inspect-6D7Q27PN.js +4 -0
- package/dist-cli/chunks/install-desktop-WZBAUSKT.js +4 -0
- package/dist-cli/chunks/{login-K43TRKUC.js → login-XWITDS34.js} +4 -4
- package/dist-cli/chunks/{logout-L43CR6M2.js → logout-C6EVSNGO.js} +2 -2
- package/dist-cli/chunks/{maestro-7VZFYLNS.js → maestro-Y3RDSX5X.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-PMYHUVH4.js → maestro-generate-WHUYPFMX.js} +3 -3
- package/dist-cli/chunks/{mode-QQS5XFFX.js → mode-3D6W3Y2Y.js} +2 -2
- package/dist-cli/chunks/{optional-demo-registry-2V327HDG.js → optional-demo-registry-MRBZKL5R.js} +2 -2
- package/dist-cli/chunks/{perf-CL5P3JVP.js → perf-CRMSVQX4.js} +2 -2
- package/dist-cli/chunks/{permissions-RGVCEC2B.js → permissions-RFASUZIH.js} +2 -2
- package/dist-cli/chunks/{record-ASW6UFQT.js → record-SKX3SUNS.js} +3 -3
- package/dist-cli/chunks/report-issue-I7VXCIGQ.js +29 -0
- package/dist-cli/chunks/runtime-OJ26HJM7.js +4 -0
- package/dist-cli/chunks/{screenshot-command-FEJGQMQF.js → screenshot-command-PIMVRGAW.js} +3 -3
- package/dist-cli/chunks/{screenshots-capture-3JPZ5WMX.js → screenshots-capture-RRZUJDIO.js} +2 -2
- package/dist-cli/chunks/serve-JHBXNJ77.js +44 -0
- package/dist-cli/chunks/setup-A3I3R4FB.js +32 -0
- package/dist-cli/chunks/{skills-T6EEX5P6.js → skills-7KS6HD4G.js} +2 -2
- package/dist-cli/chunks/{storage-ZFUK4EQA.js → storage-H2ODISXF.js} +2 -2
- package/dist-cli/chunks/store-ARKALTX7.js +4 -0
- package/dist-cli/chunks/telemetry-ZMRUVMCZ.js +4 -0
- package/dist-cli/chunks/{timeline-EIURYQVC.js → timeline-5N5AUCOX.js} +2 -2
- package/dist-cli/chunks/upgrade-UM4K5PH4.js +7 -0
- package/dist-cli/chunks/upload-WGPI5XX6.js +4 -0
- package/dist-cli/chunks/version-B2MBTUMC.js +6 -0
- package/dist-cli/chunks/web-ZM5Y5GRE.js +4 -0
- package/dist-cli/chunks/{what-happened-L5ZN7S5G.js → what-happened-K443NB2H.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +3 -2
- package/dist-lib/detox/index.cjs +312 -27
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +41 -14
- package/dist-lib/host/bridge-host.cjs +215 -84
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +2 -2
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +2 -3
- package/dist-lib/menu.mjs +2 -3
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +2 -2
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +366 -154
- package/dist-lib/vite.cjs +2 -2
- package/package.json +2 -1
- package/scripts/postinstall.cjs +2 -78
- package/skills/contrast/SKILL.md +1 -0
- package/skills/sootsim-setup/SKILL.md +25 -0
- package/src/config.ts +4 -0
- package/src/disk-cleanup.ts +5 -26
- package/src/home-paths.ts +58 -23
- package/src/host/bridge-host.ts +152 -57
- package/src/menu.ts +0 -1
- package/src/runtime-assets.ts +35 -0
- package/src/runtime-delivery.ts +5 -8
- package/src/skills/builtin/compat-check.ts +32 -65
- package/dist-cli/chunks/app-fonts-36N7AVQ3.js +0 -4
- package/dist-cli/chunks/chunk-4ODQVUBH.js +0 -6
- package/dist-cli/chunks/chunk-4V2KYK5T.js +0 -4
- package/dist-cli/chunks/chunk-7GVBNEQ3.js +0 -9
- package/dist-cli/chunks/chunk-COEKUTWV.js +0 -4
- package/dist-cli/chunks/chunk-D2DU3ROC.js +0 -6
- package/dist-cli/chunks/chunk-DXCQP3VE.js +0 -12
- package/dist-cli/chunks/chunk-HT6WYHQL.js +0 -6
- package/dist-cli/chunks/chunk-I7AWRAXM.js +0 -125
- package/dist-cli/chunks/chunk-OQ7SSRYK.js +0 -4
- package/dist-cli/chunks/chunk-QKZVANQG.js +0 -31
- package/dist-cli/chunks/chunk-TGYZYSDB.js +0 -15
- package/dist-cli/chunks/chunk-TXURXDZN.js +0 -4
- package/dist-cli/chunks/chunk-ZJZYGLJ2.js +0 -4
- package/dist-cli/chunks/cleanup-SMCA44QC.js +0 -25
- package/dist-cli/chunks/compat-FCM2ZQLV.js +0 -35
- package/dist-cli/chunks/config-PZSANSAI.js +0 -13
- package/dist-cli/chunks/control-PM554MHE.js +0 -4
- package/dist-cli/chunks/daemon-5KXEZZLJ.js +0 -4
- package/dist-cli/chunks/disk-cleanup-HTOIDDKK.js +0 -4
- package/dist-cli/chunks/drivers-CKLBQDXG.js +0 -4
- package/dist-cli/chunks/flow-VOS3RKB3.js +0 -4
- package/dist-cli/chunks/help-LVMDERFQ.js +0 -4
- package/dist-cli/chunks/home-paths-3LYT47SY.js +0 -4
- package/dist-cli/chunks/inspect-FHCSOWGQ.js +0 -4
- package/dist-cli/chunks/install-desktop-KLMCVDGZ.js +0 -4
- package/dist-cli/chunks/runtime-GB3ZE27C.js +0 -4
- package/dist-cli/chunks/serve-SYXRHRQO.js +0 -44
- package/dist-cli/chunks/setup-QLGL6MC6.js +0 -99
- package/dist-cli/chunks/store-WCDTSWJC.js +0 -4
- package/dist-cli/chunks/telemetry-D7SL4WW7.js +0 -4
- package/dist-cli/chunks/upgrade-Q7ZGWMCJ.js +0 -6
- package/dist-cli/chunks/upload-7XEICYJX.js +0 -4
- package/dist-cli/chunks/version-PDG4V26R.js +0 -6
- package/dist-cli/chunks/web-AMW2TRWB.js +0 -4
package/README.md
CHANGED
|
@@ -188,7 +188,7 @@ bridge always addresses the **shell worker**, never an undifferentiated sim.
|
|
|
188
188
|
| SootSimBridgeHost (serve/daemon) | The bridge process (`sootsim serve`, or autostarted as the daemon via launchd/systemd). HTTP+WS hub on `:7668`: relays commands between CLI clients and registered sims; also serves runtime HTTP, `/__server-scan`, and fetch/WS proxies. | `src/host/bridge-host.ts`, `cli/commands/serve.ts`, `cli/commands/daemon.ts`, `src/home-paths.ts` |
|
|
189
189
|
| AgentHost + sessions | Agent-routing extension of the host: owns the single FIFO reader per agent session and fans `agent:event`/`session-status` pushes to every WS subscriber. Backed by agent-sessions + attached-projects stores. | `src/host/agent-host.ts`, `src/agent-sessions.ts`, `src/attached-projects.ts`, `src/agent-daemon-client.ts` |
|
|
190
190
|
| host proxies + dev-server scan | Host-side helpers so a cross-origin tenant worker can reach local dev servers and same-origin APIs: `/__server-scan` discovers running metro/expo/vxrn/one servers; fetch + websocket proxies relay guest-app network through the daemon. | `src/host/fetch-proxy-handler.ts`, `src/host/websocket-proxy.ts`, `scripts/dev-server-scanner.ts`, `src/dev-bundle-resolution.ts` |
|
|
191
|
-
| runtime delivery + sootsim home | Versioned engine runtime management. The machinery
|
|
191
|
+
| runtime delivery + sootsim home | Versioned engine runtime management. The shared machinery owns manifest fetch, sha256 verification, channels, and auto-update. SootSim binds it to `https://contrast.dev` and `~/.sootsim/runtimes/<version>`. A repo can select `runtimeVersion` without changing the machine default. | `src/runtime-delivery.ts`, `packages/contrast-runtime-delivery/`, `src/home-paths.ts`, `src/runtime-assets.ts`, `scripts/postinstall.cjs` |
|
|
192
192
|
| `sootsim/vite` + `sootsim/metro` (runtime serving) | Published bundler plugins. Both serve the installed engine runtime at `/__soot/` from `~/.sootsim/runtimes/<version>` so an app's own dev server can host the sim shell; the vite plugin can also launch the Electron app. | `src/vite-plugin-one.ts`, `src/metro-plugin.ts`, `src/runtime-assets.ts` |
|
|
193
193
|
| `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, applies worklets/babel transforms, and instantiates `SootSimBridgeHost` in its dev server. Distinct from the published `sootsim/vite` export. | `src/vite-plugin.ts`, `src/worklets-babel.ts`, `packages/compat/src/stub-manifest.ts` |
|
|
194
194
|
| skills registry | SootSim agent skills (`sootsim-setup`, `sootsim-debug`, `sootsim-test`, `sootsim-visual`, and the generated `contrast` index) installable with `sootsim skill`. | `skills/*.md`, `src/skills/registry.ts`, `src/skills/types.ts` |
|
|
@@ -261,6 +261,8 @@ sootsim open 8081 # load a running metro/expo dev server
|
|
|
261
261
|
sootsim open /settings # route via React Native Linking
|
|
262
262
|
sootsim describe # dump UI tree
|
|
263
263
|
sootsim get errors 5 # recent runtime failures
|
|
264
|
+
sootsim compat # scan native package compatibility
|
|
265
|
+
sootsim report-issue "…" # preview an opt-in compatibility report
|
|
264
266
|
sootsim find --testid cta # inspect one node deeply
|
|
265
267
|
sootsim debug enable animated,layout
|
|
266
268
|
sootsim debug trace shell on 240
|
|
@@ -450,7 +452,7 @@ verifies its sha256, and unpacks it under `~/.sootsim`:
|
|
|
450
452
|
├── runtimes/
|
|
451
453
|
│ ├── <active>/ unpacked engine assets (served at /__soot/)
|
|
452
454
|
│ └── <rollback>/ previous version for rollback
|
|
453
|
-
├── cache/
|
|
455
|
+
├── cache/ legacy downloads; empty after cleanup
|
|
454
456
|
├── profiles/ persistent named app storage, without browser caches
|
|
455
457
|
├── electron/userData/ desktop app storage, with bounded browser caches
|
|
456
458
|
├── daemon.json lockfile: pid, ports, active runtime, heartbeat
|
|
@@ -467,13 +469,20 @@ disabled for simulator profiles and globally capped for app chrome. Cookies,
|
|
|
467
469
|
localStorage, IndexedDB, service workers, recordings, and captured frames are
|
|
468
470
|
user-owned data and are not removed by the default cleanup command.
|
|
469
471
|
|
|
472
|
+
Per-repo configuration is optional. When `sootsim.config.ts` sets
|
|
473
|
+
`runtimeVersion`, `sootsim open` installs that version without changing the
|
|
474
|
+
machine default and serves it from a version-specific localhost origin. Running
|
|
475
|
+
from a monorepo root resolves the only React Native app from package workspaces,
|
|
476
|
+
then shares that app root across config, font, and splash discovery. The fallback
|
|
477
|
+
scan is bounded and prunes dependency, build, native, fixture, and example trees.
|
|
478
|
+
|
|
470
479
|
The first interactive CLI command after this cleanup generation finishes its
|
|
471
480
|
requested work, then launches the same safe default plan in a detached
|
|
472
481
|
maintenance process. The app and terminal command are usable before the legacy
|
|
473
482
|
filesystem scan begins. The worker prints the reclaimed size to stderr, records
|
|
474
483
|
completion, and does not run the scan again. Open browser profiles and a runtime
|
|
475
|
-
served by
|
|
476
|
-
later command after they close. The explicit `sootsim cleanup --aggressive`
|
|
484
|
+
served by a live daemon or development bridge are left untouched. The automatic
|
|
485
|
+
pass retries on a later command after they close. The explicit `sootsim cleanup --aggressive`
|
|
477
486
|
command remains the only path that removes the rollback runtime or user-created
|
|
478
487
|
recordings.
|
|
479
488
|
|
package/detox/index.ts
CHANGED
|
@@ -15,6 +15,12 @@ import {
|
|
|
15
15
|
waitForSootsimGestureHandler,
|
|
16
16
|
} from './gestures'
|
|
17
17
|
import { by, type Matcher } from './matchers'
|
|
18
|
+
import {
|
|
19
|
+
MOTION_CHANGE_DEFINITION,
|
|
20
|
+
measureMotionChange,
|
|
21
|
+
type MotionChangeDefinition,
|
|
22
|
+
type MotionChangeMeasurement,
|
|
23
|
+
} from './motion-change.cjs'
|
|
18
24
|
import type { SootSimElement } from './element-types'
|
|
19
25
|
import type { Browser, BrowserContext, Page } from 'playwright'
|
|
20
26
|
import type { SootsimEventMap, SootsimEventName } from 'sootsim-engine/sootsim-event'
|
|
@@ -554,6 +560,32 @@ function decodePngDataUrl(dataUrl: string): Buffer {
|
|
|
554
560
|
return Buffer.from(match[1], 'base64')
|
|
555
561
|
}
|
|
556
562
|
|
|
563
|
+
type FastScreenshotData = {
|
|
564
|
+
label: string
|
|
565
|
+
offsetMs: number
|
|
566
|
+
actualMs: number
|
|
567
|
+
dataUrl: string
|
|
568
|
+
frameIndex?: number
|
|
569
|
+
virtualTimestampMs?: number
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function writeFastScreenshotFrames<T extends FastScreenshotData>(
|
|
573
|
+
namePrefix: string,
|
|
574
|
+
frames: T[],
|
|
575
|
+
): Array<Omit<T, 'dataUrl'> & { path: string }> {
|
|
576
|
+
ensureScreenshotDir()
|
|
577
|
+
return frames.map((frame) => {
|
|
578
|
+
const safeName = `${namePrefix}-${frame.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
579
|
+
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
|
|
580
|
+
fs.writeFileSync(filePath, decodePngDataUrl(frame.dataUrl))
|
|
581
|
+
const { dataUrl: _dataUrl, ...metadata } = frame
|
|
582
|
+
return {
|
|
583
|
+
...metadata,
|
|
584
|
+
path: filePath,
|
|
585
|
+
}
|
|
586
|
+
})
|
|
587
|
+
}
|
|
588
|
+
|
|
557
589
|
// single-shot canvas capture — no frame-stability poll. used for multi-stage
|
|
558
590
|
// animation captures where we explicitly want to sample mid-transition. the
|
|
559
591
|
// usual captureSootsimPng polls until two consecutive frames are byte-equal
|
|
@@ -1347,10 +1379,352 @@ export const device = {
|
|
|
1347
1379
|
await dispatchSootsimEventInPage('sootsim:menuAnimOverride', { value: progress })
|
|
1348
1380
|
},
|
|
1349
1381
|
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1382
|
+
// park every render clock before the interaction, detect the first changed
|
|
1383
|
+
// stepped frame inside the named component, then advance exact 60fps frames
|
|
1384
|
+
// to each requested offset. native extraction also chooses the first frame
|
|
1385
|
+
// at or after an offset, so both sides use the same rounding convention.
|
|
1386
|
+
async takeScreenshotStagesFromVisibleChangeFast(
|
|
1387
|
+
namePrefix: string,
|
|
1388
|
+
stages: Array<{ label: string; offsetMs: number }>,
|
|
1389
|
+
options: {
|
|
1390
|
+
detectionTimeoutMs?: number
|
|
1391
|
+
frame?: { height: number; width: number; x: number; y: number } | null
|
|
1392
|
+
rootFrame?: { height: number; width: number; x: number; y: number } | null
|
|
1393
|
+
trigger: () => unknown
|
|
1394
|
+
},
|
|
1395
|
+
): Promise<{
|
|
1396
|
+
frames: Array<{
|
|
1397
|
+
label: string
|
|
1398
|
+
offsetMs: number
|
|
1399
|
+
actualMs: number
|
|
1400
|
+
path: string
|
|
1401
|
+
frameIndex: number
|
|
1402
|
+
virtualTimestampMs: number
|
|
1403
|
+
}>
|
|
1404
|
+
triggerCompletedMs: number
|
|
1405
|
+
triggerStartMs: number
|
|
1406
|
+
visibleChangeDetection: {
|
|
1407
|
+
actualMs: number
|
|
1408
|
+
changedPixelPercent: number
|
|
1409
|
+
changedPixels: number
|
|
1410
|
+
channelThreshold: number
|
|
1411
|
+
frame: { height: number; width: number; x: number; y: number } | null
|
|
1412
|
+
minimumChangePercent: number
|
|
1413
|
+
rootFrame: { height: number; width: number; x: number; y: number } | null
|
|
1414
|
+
totalPixels: number
|
|
1415
|
+
frameIndex: number
|
|
1416
|
+
virtualTimestampMs: number
|
|
1417
|
+
}
|
|
1418
|
+
}> {
|
|
1419
|
+
const page = getPage()
|
|
1420
|
+
const token = `motion-${process.pid}-${Date.now()}-${Math.random()}`
|
|
1421
|
+
await page.evaluate(measureMotionChange, {
|
|
1422
|
+
definition: MOTION_CHANGE_DEFINITION,
|
|
1423
|
+
installGlobal: true,
|
|
1424
|
+
})
|
|
1425
|
+
const capturePromise: Promise<{
|
|
1426
|
+
frames: Array<
|
|
1427
|
+
FastScreenshotData & {
|
|
1428
|
+
frameIndex: number
|
|
1429
|
+
virtualTimestampMs: number
|
|
1430
|
+
}
|
|
1431
|
+
>
|
|
1432
|
+
visibleChangeDetection: {
|
|
1433
|
+
actualMs: number
|
|
1434
|
+
changedPixelPercent: number
|
|
1435
|
+
changedPixels: number
|
|
1436
|
+
channelThreshold: number
|
|
1437
|
+
frameIndex: number
|
|
1438
|
+
minimumChangePercent: number
|
|
1439
|
+
totalPixels: number
|
|
1440
|
+
virtualTimestampMs: number
|
|
1441
|
+
}
|
|
1442
|
+
}> = page.evaluate(
|
|
1443
|
+
async (input) => {
|
|
1444
|
+
type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
|
|
1445
|
+
type CaptureState = {
|
|
1446
|
+
cancelled: boolean
|
|
1447
|
+
ready: boolean
|
|
1448
|
+
triggerCompleted: boolean
|
|
1449
|
+
}
|
|
1450
|
+
type CaptureWindow = Window & {
|
|
1451
|
+
__sootsimMeasureMotionChange?: (input: {
|
|
1452
|
+
baseline: Uint8ClampedArray
|
|
1453
|
+
candidate: Uint8ClampedArray
|
|
1454
|
+
definition: MotionChangeDefinition
|
|
1455
|
+
height: number
|
|
1456
|
+
label: string
|
|
1457
|
+
width: number
|
|
1458
|
+
}) => MotionChangeMeasurement
|
|
1459
|
+
__sootsimVisibleChangeCaptures?: Record<string, CaptureState>
|
|
1460
|
+
SootSim?: {
|
|
1461
|
+
bridges?: {
|
|
1462
|
+
liveComposite?: {
|
|
1463
|
+
captureFresh: () => Promise<{
|
|
1464
|
+
canvas: HTMLCanvasElement | null
|
|
1465
|
+
frameToken: number
|
|
1466
|
+
}>
|
|
1467
|
+
}
|
|
1468
|
+
lockstepCapture?: {
|
|
1469
|
+
begin: (fps: number) => Promise<{
|
|
1470
|
+
stepFrame: () => Promise<FrameReceipt>
|
|
1471
|
+
stepToOffset: (
|
|
1472
|
+
anchor: FrameReceipt,
|
|
1473
|
+
offsetMs: number,
|
|
1474
|
+
) => Promise<FrameReceipt>
|
|
1475
|
+
end: () => void
|
|
1476
|
+
}>
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
type Snapshot = {
|
|
1482
|
+
actualMs: number
|
|
1483
|
+
bitmap: ImageBitmap
|
|
1484
|
+
label: string
|
|
1485
|
+
offsetMs: number
|
|
1486
|
+
frameIndex: number
|
|
1487
|
+
virtualTimestampMs: number
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const captureWindow = window as unknown as CaptureWindow
|
|
1491
|
+
const live = captureWindow.SootSim?.bridges?.liveComposite
|
|
1492
|
+
const lockstep = captureWindow.SootSim?.bridges?.lockstepCapture
|
|
1493
|
+
if (!live || typeof live.captureFresh !== 'function') {
|
|
1494
|
+
throw new Error('SootSim live composite bridge is not installed')
|
|
1495
|
+
}
|
|
1496
|
+
if (!lockstep || typeof lockstep.begin !== 'function') {
|
|
1497
|
+
throw new Error('SootSim lockstep capture bridge is not installed')
|
|
1498
|
+
}
|
|
1499
|
+
const registry =
|
|
1500
|
+
captureWindow.__sootsimVisibleChangeCaptures ??
|
|
1501
|
+
(captureWindow.__sootsimVisibleChangeCaptures = {})
|
|
1502
|
+
const state: CaptureState = {
|
|
1503
|
+
cancelled: false,
|
|
1504
|
+
ready: false,
|
|
1505
|
+
triggerCompleted: false,
|
|
1506
|
+
}
|
|
1507
|
+
registry[input.token] = state
|
|
1508
|
+
const measureChange = captureWindow.__sootsimMeasureMotionChange
|
|
1509
|
+
if (!measureChange) {
|
|
1510
|
+
throw new Error('SootSim motion change definition is not installed')
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const captureBitmap = async () => {
|
|
1514
|
+
const { canvas } = await live.captureFresh()
|
|
1515
|
+
if (!canvas) throw new Error('SootSim live composite produced no canvas')
|
|
1516
|
+
return createImageBitmap(canvas)
|
|
1517
|
+
}
|
|
1518
|
+
const boundsFor = (bitmap: ImageBitmap) => {
|
|
1519
|
+
const scale =
|
|
1520
|
+
input.rootFrame && input.rootFrame.width > 0
|
|
1521
|
+
? bitmap.width / input.rootFrame.width
|
|
1522
|
+
: 1
|
|
1523
|
+
return {
|
|
1524
|
+
height: input.frame
|
|
1525
|
+
? Math.max(
|
|
1526
|
+
1,
|
|
1527
|
+
Math.min(
|
|
1528
|
+
bitmap.height,
|
|
1529
|
+
Math.ceil((input.frame.y + input.frame.height) * scale),
|
|
1530
|
+
) - Math.max(0, Math.floor(input.frame.y * scale)),
|
|
1531
|
+
)
|
|
1532
|
+
: bitmap.height,
|
|
1533
|
+
left: input.frame ? Math.max(0, Math.floor(input.frame.x * scale)) : 0,
|
|
1534
|
+
top: input.frame ? Math.max(0, Math.floor(input.frame.y * scale)) : 0,
|
|
1535
|
+
width: input.frame
|
|
1536
|
+
? Math.max(
|
|
1537
|
+
1,
|
|
1538
|
+
Math.min(
|
|
1539
|
+
bitmap.width,
|
|
1540
|
+
Math.ceil((input.frame.x + input.frame.width) * scale),
|
|
1541
|
+
) - Math.max(0, Math.floor(input.frame.x * scale)),
|
|
1542
|
+
)
|
|
1543
|
+
: bitmap.width,
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const regionPixels = (bitmap: ImageBitmap) => {
|
|
1547
|
+
const bounds = boundsFor(bitmap)
|
|
1548
|
+
const canvas = document.createElement('canvas')
|
|
1549
|
+
canvas.width = bounds.width
|
|
1550
|
+
canvas.height = bounds.height
|
|
1551
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
1552
|
+
if (!context) throw new Error('SootSim motion detector needs a 2d canvas')
|
|
1553
|
+
context.drawImage(
|
|
1554
|
+
bitmap,
|
|
1555
|
+
bounds.left,
|
|
1556
|
+
bounds.top,
|
|
1557
|
+
bounds.width,
|
|
1558
|
+
bounds.height,
|
|
1559
|
+
0,
|
|
1560
|
+
0,
|
|
1561
|
+
bounds.width,
|
|
1562
|
+
bounds.height,
|
|
1563
|
+
)
|
|
1564
|
+
return context.getImageData(0, 0, bounds.width, bounds.height).data
|
|
1565
|
+
}
|
|
1566
|
+
const encode = (snapshot: Snapshot) => {
|
|
1567
|
+
const canvas = document.createElement('canvas')
|
|
1568
|
+
canvas.width = snapshot.bitmap.width
|
|
1569
|
+
canvas.height = snapshot.bitmap.height
|
|
1570
|
+
const context = canvas.getContext('2d')
|
|
1571
|
+
if (!context) throw new Error('SootSim motion capture needs a 2d canvas')
|
|
1572
|
+
context.drawImage(snapshot.bitmap, 0, 0)
|
|
1573
|
+
snapshot.bitmap.close()
|
|
1574
|
+
return {
|
|
1575
|
+
actualMs: snapshot.actualMs,
|
|
1576
|
+
dataUrl: canvas.toDataURL('image/png'),
|
|
1577
|
+
label: snapshot.label,
|
|
1578
|
+
offsetMs: snapshot.offsetMs,
|
|
1579
|
+
frameIndex: snapshot.frameIndex,
|
|
1580
|
+
virtualTimestampMs: snapshot.virtualTimestampMs,
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const controller = await lockstep.begin(60)
|
|
1585
|
+
try {
|
|
1586
|
+
const baselineBitmap = await captureBitmap()
|
|
1587
|
+
const baseline = regionPixels(baselineBitmap)
|
|
1588
|
+
baselineBitmap.close()
|
|
1589
|
+
state.ready = true
|
|
1590
|
+
while (!state.triggerCompleted) {
|
|
1591
|
+
if (state.cancelled) throw new Error('SootSim motion capture was cancelled')
|
|
1592
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
let visibleStart: FrameReceipt | null = null
|
|
1596
|
+
let detectedMeasurement: MotionChangeMeasurement | null = null
|
|
1597
|
+
const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
|
|
1598
|
+
for (let frame = 0; frame < detectionFrameLimit; frame++) {
|
|
1599
|
+
if (state.cancelled) throw new Error('SootSim motion capture was cancelled')
|
|
1600
|
+
const receipt = await controller.stepFrame()
|
|
1601
|
+
const candidateBitmap = await captureBitmap()
|
|
1602
|
+
const bounds = boundsFor(candidateBitmap)
|
|
1603
|
+
const measurement = measureChange({
|
|
1604
|
+
baseline,
|
|
1605
|
+
candidate: regionPixels(candidateBitmap),
|
|
1606
|
+
definition: input.motionChangeDefinition,
|
|
1607
|
+
height: bounds.height,
|
|
1608
|
+
label: 'SootSim visible-change detector',
|
|
1609
|
+
width: bounds.width,
|
|
1610
|
+
})
|
|
1611
|
+
candidateBitmap.close()
|
|
1612
|
+
if (measurement.passedMinimum) {
|
|
1613
|
+
visibleStart = receipt
|
|
1614
|
+
detectedMeasurement = measurement
|
|
1615
|
+
break
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
if (!visibleStart || !detectedMeasurement) {
|
|
1619
|
+
throw new Error(
|
|
1620
|
+
`SootSim motion had no visible change before ${input.detectionTimeoutMs}ms`,
|
|
1621
|
+
)
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const snapshots: Snapshot[] = []
|
|
1625
|
+
for (const stage of input.stages) {
|
|
1626
|
+
if (state.cancelled) throw new Error('SootSim motion capture was cancelled')
|
|
1627
|
+
const receipt = await controller.stepToOffset(visibleStart, stage.offsetMs)
|
|
1628
|
+
snapshots.push({
|
|
1629
|
+
actualMs: receipt.virtualTimestampMs - visibleStart.virtualTimestampMs,
|
|
1630
|
+
bitmap: await captureBitmap(),
|
|
1631
|
+
frameIndex: receipt.frameIndex,
|
|
1632
|
+
label: stage.label,
|
|
1633
|
+
offsetMs: stage.offsetMs,
|
|
1634
|
+
virtualTimestampMs: receipt.virtualTimestampMs,
|
|
1635
|
+
})
|
|
1636
|
+
}
|
|
1637
|
+
return {
|
|
1638
|
+
frames: snapshots.map(encode),
|
|
1639
|
+
visibleChangeDetection: {
|
|
1640
|
+
actualMs: (visibleStart.frameIndex * 1000) / 60,
|
|
1641
|
+
changedPixelPercent: detectedMeasurement.changePercent,
|
|
1642
|
+
changedPixels: detectedMeasurement.changePixels,
|
|
1643
|
+
channelThreshold: input.motionChangeDefinition.channelThreshold,
|
|
1644
|
+
minimumChangePercent: input.motionChangeDefinition.minimumChangePercent,
|
|
1645
|
+
totalPixels: detectedMeasurement.totalPixels,
|
|
1646
|
+
frameIndex: visibleStart.frameIndex,
|
|
1647
|
+
virtualTimestampMs: visibleStart.virtualTimestampMs,
|
|
1648
|
+
},
|
|
1649
|
+
}
|
|
1650
|
+
} finally {
|
|
1651
|
+
controller.end()
|
|
1652
|
+
delete registry[input.token]
|
|
1653
|
+
}
|
|
1654
|
+
},
|
|
1655
|
+
{
|
|
1656
|
+
detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
|
|
1657
|
+
frame: options.frame ?? null,
|
|
1658
|
+
motionChangeDefinition: MOTION_CHANGE_DEFINITION,
|
|
1659
|
+
rootFrame: options.rootFrame ?? null,
|
|
1660
|
+
stages,
|
|
1661
|
+
token,
|
|
1662
|
+
},
|
|
1663
|
+
)
|
|
1664
|
+
|
|
1665
|
+
await page.waitForFunction(
|
|
1666
|
+
(captureToken) => {
|
|
1667
|
+
const registry = (
|
|
1668
|
+
window as Window & {
|
|
1669
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
1670
|
+
string,
|
|
1671
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
1672
|
+
>
|
|
1673
|
+
}
|
|
1674
|
+
).__sootsimVisibleChangeCaptures
|
|
1675
|
+
return registry?.[captureToken]?.ready === true
|
|
1676
|
+
},
|
|
1677
|
+
token,
|
|
1678
|
+
{ timeout: 5000 },
|
|
1679
|
+
)
|
|
1680
|
+
|
|
1681
|
+
const triggerStartMs = Date.now()
|
|
1682
|
+
let triggerCompletedMs = 0
|
|
1683
|
+
try {
|
|
1684
|
+
await Promise.resolve(options.trigger())
|
|
1685
|
+
triggerCompletedMs = Date.now()
|
|
1686
|
+
await page.evaluate((captureToken) => {
|
|
1687
|
+
const registry = (
|
|
1688
|
+
window as Window & {
|
|
1689
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
1690
|
+
string,
|
|
1691
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
1692
|
+
>
|
|
1693
|
+
}
|
|
1694
|
+
).__sootsimVisibleChangeCaptures
|
|
1695
|
+
const capture = registry?.[captureToken]
|
|
1696
|
+
if (!capture) throw new Error('SootSim motion capture disarmed before trigger')
|
|
1697
|
+
capture.triggerCompleted = true
|
|
1698
|
+
}, token)
|
|
1699
|
+
const capture = await capturePromise
|
|
1700
|
+
return {
|
|
1701
|
+
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
1702
|
+
triggerCompletedMs,
|
|
1703
|
+
triggerStartMs,
|
|
1704
|
+
visibleChangeDetection: {
|
|
1705
|
+
...capture.visibleChangeDetection,
|
|
1706
|
+
frame: options.frame ?? null,
|
|
1707
|
+
rootFrame: options.rootFrame ?? null,
|
|
1708
|
+
},
|
|
1709
|
+
}
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
await page
|
|
1712
|
+
.evaluate((captureToken) => {
|
|
1713
|
+
const registry = (
|
|
1714
|
+
window as Window & {
|
|
1715
|
+
__sootsimVisibleChangeCaptures?: Record<
|
|
1716
|
+
string,
|
|
1717
|
+
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
1718
|
+
>
|
|
1719
|
+
}
|
|
1720
|
+
).__sootsimVisibleChangeCaptures
|
|
1721
|
+
const capture = registry?.[captureToken]
|
|
1722
|
+
if (capture) capture.cancelled = true
|
|
1723
|
+
}, token)
|
|
1724
|
+
.catch(() => {})
|
|
1725
|
+
await capturePromise.catch(() => {})
|
|
1726
|
+
throw error
|
|
1727
|
+
}
|
|
1354
1728
|
},
|
|
1355
1729
|
|
|
1356
1730
|
// FAST multi-stage capture for sub-second animation diffs. each stage
|
|
@@ -1490,18 +1864,7 @@ export const device = {
|
|
|
1490
1864
|
},
|
|
1491
1865
|
{ stages, skipWarmup: options?.skipWarmup === true },
|
|
1492
1866
|
)
|
|
1493
|
-
|
|
1494
|
-
return frames.map((f) => {
|
|
1495
|
-
const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
1496
|
-
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
|
|
1497
|
-
fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
|
|
1498
|
-
return {
|
|
1499
|
-
label: f.label,
|
|
1500
|
-
offsetMs: f.offsetMs,
|
|
1501
|
-
actualMs: f.actualMs,
|
|
1502
|
-
path: filePath,
|
|
1503
|
-
}
|
|
1504
|
-
})
|
|
1867
|
+
return writeFastScreenshotFrames(namePrefix, frames)
|
|
1505
1868
|
},
|
|
1506
1869
|
|
|
1507
1870
|
// batched multi-stage capture for animation diffs. all N captures happen
|
|
@@ -1549,18 +1912,7 @@ export const device = {
|
|
|
1549
1912
|
},
|
|
1550
1913
|
{ stages },
|
|
1551
1914
|
)
|
|
1552
|
-
|
|
1553
|
-
return frames.map((f) => {
|
|
1554
|
-
const safeName = `${namePrefix}-${f.label}`.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
|
1555
|
-
const filePath = path.join(SCREENSHOT_DIR, `${safeName}.png`)
|
|
1556
|
-
fs.writeFileSync(filePath, decodePngDataUrl(f.dataUrl))
|
|
1557
|
-
return {
|
|
1558
|
-
label: f.label,
|
|
1559
|
-
offsetMs: f.offsetMs,
|
|
1560
|
-
actualMs: f.actualMs,
|
|
1561
|
-
path: filePath,
|
|
1562
|
-
}
|
|
1563
|
-
})
|
|
1915
|
+
return writeFastScreenshotFrames(namePrefix, frames)
|
|
1564
1916
|
},
|
|
1565
1917
|
|
|
1566
1918
|
async shake() {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// native recordings and SootSim live frames must use this exact definition.
|
|
2
|
+
// retained static Android crops measured as high as 0.0331% after the channel
|
|
3
|
+
// filter. the smallest corrected component transition with visible motion was
|
|
4
|
+
// 0.1748%, so a 0.1% floor separates every measured static and moving frame.
|
|
5
|
+
const MOTION_CHANGE_DEFINITION = Object.freeze({
|
|
6
|
+
channelThreshold: 24,
|
|
7
|
+
minimumChangePercent: 0.1,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
function measureMotionChange(input) {
|
|
11
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
12
|
+
throw new Error('motion change measurement requires an input object')
|
|
13
|
+
}
|
|
14
|
+
if (input.installGlobal === true) {
|
|
15
|
+
globalThis.__sootsimMeasureMotionChange = measureMotionChange
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const definition = input.definition
|
|
20
|
+
if (
|
|
21
|
+
!definition ||
|
|
22
|
+
typeof definition !== 'object' ||
|
|
23
|
+
!Number.isInteger(definition.channelThreshold) ||
|
|
24
|
+
definition.channelThreshold < 0 ||
|
|
25
|
+
definition.channelThreshold > 255 ||
|
|
26
|
+
!Number.isFinite(definition.minimumChangePercent) ||
|
|
27
|
+
definition.minimumChangePercent <= 0 ||
|
|
28
|
+
definition.minimumChangePercent >= 100
|
|
29
|
+
) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'motion change definition needs a channel threshold from 0 to 255 and a minimum greater than 0% and less than 100%',
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const label =
|
|
36
|
+
typeof input.label === 'string' && input.label.length > 0
|
|
37
|
+
? input.label
|
|
38
|
+
: 'motion change'
|
|
39
|
+
let changePixels = input.changePixels
|
|
40
|
+
let totalPixels = input.totalPixels
|
|
41
|
+
if (input.baseline !== undefined || input.candidate !== undefined) {
|
|
42
|
+
const baseline = input.baseline
|
|
43
|
+
const candidate = input.candidate
|
|
44
|
+
const width = input.width
|
|
45
|
+
const height = input.height
|
|
46
|
+
if (
|
|
47
|
+
!baseline ||
|
|
48
|
+
!candidate ||
|
|
49
|
+
typeof baseline.length !== 'number' ||
|
|
50
|
+
typeof candidate.length !== 'number' ||
|
|
51
|
+
!Number.isInteger(width) ||
|
|
52
|
+
!Number.isInteger(height) ||
|
|
53
|
+
width <= 0 ||
|
|
54
|
+
height <= 0
|
|
55
|
+
) {
|
|
56
|
+
throw new Error(`${label} needs two RGBA pixel arrays and positive dimensions`)
|
|
57
|
+
}
|
|
58
|
+
totalPixels = width * height
|
|
59
|
+
const expectedBytes = totalPixels * 4
|
|
60
|
+
if (baseline.length !== expectedBytes || candidate.length !== expectedBytes) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${label} pixel data has ${baseline.length}/${candidate.length} bytes; expected ${expectedBytes}`,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
changePixels = 0
|
|
66
|
+
for (let offset = 0; offset < expectedBytes; offset += 4) {
|
|
67
|
+
if (
|
|
68
|
+
Math.abs(baseline[offset] - candidate[offset]) > definition.channelThreshold ||
|
|
69
|
+
Math.abs(baseline[offset + 1] - candidate[offset + 1]) >
|
|
70
|
+
definition.channelThreshold ||
|
|
71
|
+
Math.abs(baseline[offset + 2] - candidate[offset + 2]) >
|
|
72
|
+
definition.channelThreshold ||
|
|
73
|
+
Math.abs(baseline[offset + 3] - candidate[offset + 3]) >
|
|
74
|
+
definition.channelThreshold
|
|
75
|
+
) {
|
|
76
|
+
changePixels++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!Number.isInteger(totalPixels) || totalPixels <= 0) {
|
|
82
|
+
throw new Error(`${label} has no positive measured region area`)
|
|
83
|
+
}
|
|
84
|
+
if (!Number.isInteger(changePixels) || changePixels < 0 || changePixels > totalPixels) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`${label} changed pixel count ${changePixels} is outside its ${totalPixels}px measured region`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
const changePercent = (changePixels / totalPixels) * 100
|
|
90
|
+
return {
|
|
91
|
+
changePercent,
|
|
92
|
+
changePixels,
|
|
93
|
+
passedMinimum: changePercent >= definition.minimumChangePercent,
|
|
94
|
+
totalPixels,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function measureProofMotionChange(input) {
|
|
99
|
+
return measureMotionChange({
|
|
100
|
+
...input,
|
|
101
|
+
definition: MOTION_CHANGE_DEFINITION,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
MOTION_CHANGE_DEFINITION,
|
|
107
|
+
measureMotionChange,
|
|
108
|
+
measureProofMotionChange,
|
|
109
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type MotionChangeDefinition = {
|
|
2
|
+
channelThreshold: number
|
|
3
|
+
minimumChangePercent: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
type MotionChangePixels = ArrayLike<number>
|
|
7
|
+
|
|
8
|
+
export type MotionChangeInput = {
|
|
9
|
+
baseline?: MotionChangePixels
|
|
10
|
+
candidate?: MotionChangePixels
|
|
11
|
+
changePixels?: number
|
|
12
|
+
definition: MotionChangeDefinition
|
|
13
|
+
height?: number
|
|
14
|
+
installGlobal?: boolean
|
|
15
|
+
label?: string
|
|
16
|
+
totalPixels?: number
|
|
17
|
+
width?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type MotionChangeMeasurement = {
|
|
21
|
+
changePercent: number
|
|
22
|
+
changePixels: number
|
|
23
|
+
passedMinimum: boolean
|
|
24
|
+
totalPixels: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const MOTION_CHANGE_DEFINITION: Readonly<MotionChangeDefinition>
|
|
28
|
+
|
|
29
|
+
export function measureMotionChange(
|
|
30
|
+
input: MotionChangeInput,
|
|
31
|
+
): MotionChangeMeasurement | null
|
|
32
|
+
|
|
33
|
+
export function measureProofMotionChange(
|
|
34
|
+
input: Omit<MotionChangeInput, 'definition' | 'installGlobal'>,
|
|
35
|
+
): MotionChangeMeasurement
|