@wenathlan/devthink 2.0.12 → 2.0.14
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/CHANGELOG.md +20 -0
- package/accounts.ts +2087 -0
- package/agent.ts +2331 -0
- package/antigravity.ts +2139 -0
- package/apifreeze.ts +4146 -0
- package/auth.ts +642 -0
- package/background.ts +16837 -0
- package/bridge.ts +2080 -0
- package/build.ts +54 -0
- package/bun.ts +26 -0
- package/capture.ts +1614 -0
- package/checksecrets.ts +72 -0
- package/cjs.ts +9 -0
- package/cli.ts +3500 -0
- package/commands.ts +1101 -0
- package/companion.ts +450 -0
- package/compatibility.ts +37 -0
- package/config.ts +3137 -0
- package/constants.ts +943 -0
- package/core.ts +1473 -0
- package/crossbrowser.ts +1359 -0
- package/dashdone.ts +605 -0
- package/data.ts +943 -0
- package/database.ts +133 -0
- package/debug.ts +850 -0
- package/deno.ts +27 -0
- package/devthink.ts +707 -0
- package/dist/accounts.d.ts +409 -0
- package/dist/agent.d.ts +837 -0
- package/dist/antigravity.d.ts +380 -0
- package/dist/apifreeze.d.ts +113 -0
- package/dist/auth.d.ts +224 -0
- package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
- package/dist/capture.d.ts +595 -0
- package/dist/cjs.d.ts +6 -0
- package/{cli.js → dist/cli.js} +2 -2
- package/dist/commands.d.ts +310 -0
- package/dist/compatibility.d.ts +17 -0
- package/dist/config.d.ts +1853 -0
- package/dist/constants.d.ts +637 -0
- package/dist/core.d.ts +361 -0
- package/dist/data.d.ts +330 -0
- package/dist/database.d.ts +24 -0
- package/dist/debug.d.ts +270 -0
- package/dist/engine.d.ts +74 -0
- package/dist/environments.d.ts +244 -0
- package/dist/evidence.d.ts +277 -0
- package/dist/export.d.ts +311 -0
- package/dist/fingerprint.d.ts +594 -0
- package/dist/flow.d.ts +300 -0
- package/dist/gates.d.ts +103 -0
- package/dist/identity.d.ts +51 -0
- package/dist/ids.d.ts +7 -0
- package/dist/llm.d.ts +652 -0
- package/dist/models.d.ts +1807 -0
- package/dist/net.d.ts +325 -0
- package/dist/neutral.d.ts +41 -0
- package/dist/oauth.d.ts +703 -0
- package/dist/page.d.ts +1166 -0
- package/dist/perf.d.ts +481 -0
- package/dist/plan.d.ts +134 -0
- package/dist/project.d.ts +395 -0
- package/dist/providers.d.ts +37 -0
- package/dist/quota.d.ts +453 -0
- package/dist/recovery.d.ts +621 -0
- package/dist/request.d.ts +1492 -0
- package/dist/run.d.ts +779 -0
- package/dist/runtime.d.ts +212 -0
- package/dist/search.d.ts +375 -0
- package/dist/security.d.ts +534 -0
- package/dist/serve.d.ts +136 -0
- package/dist/session.d.ts +296 -0
- package/dist/storage.d.ts +53 -0
- package/dist/streaming.d.ts +1119 -0
- package/dist/swarm.d.ts +328 -0
- package/dist/tools.d.ts +170 -0
- package/dist/types.d.ts +8357 -0
- package/dist/utils.d.ts +82 -0
- package/dist/version.d.ts +7 -0
- package/dist/versionregistry.d.ts +406 -0
- package/dist/views.d.ts +579 -0
- package/dist/workbenchsession.d.ts +69 -0
- package/dist/workflow.d.ts +649 -0
- package/docs/readme.md +92 -0
- package/docs/schemas/antigravity.json +192 -0
- package/engine.ts +1524 -0
- package/environments.ts +1005 -0
- package/evidence.ts +643 -0
- package/export.ts +889 -0
- package/fingerprint.ts +1458 -0
- package/flow.ts +1190 -0
- package/gates.ts +307 -0
- package/gateway.ts +34 -0
- package/hardening.ts +191 -0
- package/headless.ts +369 -0
- package/http.ts +2110 -0
- package/identity.ts +246 -0
- package/ids.ts +26 -0
- package/index.ts +369 -0
- package/llm.ts +2682 -0
- package/mcp.ts +1835 -0
- package/{memory.js → memory.ts} +3834 -2310
- package/models.ts +3805 -0
- package/modes.ts +43 -0
- package/net.ts +1214 -0
- package/neutral.ts +362 -0
- package/node.ts +26 -0
- package/oauth.ts +2216 -0
- package/offscreen.ts +170 -0
- package/pack.ts +956 -0
- package/package.json +175 -82
- package/page.ts +7824 -0
- package/pagebridge.ts +1116 -0
- package/perf.ts +957 -0
- package/plan.ts +672 -0
- package/plugin.ts +76 -0
- package/{policy.js → policy.ts} +5073 -4679
- package/prisma.config.ts +27 -0
- package/progress.ts +1308 -0
- package/project.ts +1291 -0
- package/protocol.ts +4187 -0
- package/providers.ts +246 -0
- package/quota.ts +1887 -0
- package/recovery.ts +2433 -0
- package/request.ts +4482 -0
- package/run.ts +2135 -0
- package/runtime.ts +526 -0
- package/search.ts +1760 -0
- package/security.ts +1673 -0
- package/serve.ts +419 -0
- package/server.ts +5538 -0
- package/session.ts +1162 -0
- package/storage.ts +182 -0
- package/streaming.ts +4101 -0
- package/swarm.ts +884 -0
- package/sync.ts +56 -0
- package/tests/agentcert.test.ts +128 -0
- package/tests/agentfleet.test.ts +214 -0
- package/tests/agentreview.test.ts +241 -0
- package/tests/agentstream.test.ts +335 -0
- package/tests/agentwork.test.ts +356 -0
- package/tests/apifreeze.test.ts +290 -0
- package/tests/apimap.test.ts +112 -0
- package/tests/approvalgate.test.ts +175 -0
- package/tests/artifactmanifest.test.ts +204 -0
- package/tests/attentionfeed.test.ts +163 -0
- package/tests/backgroundruns.test.ts +100 -0
- package/tests/batchscheduling.test.ts +131 -0
- package/tests/blackboard.test.ts +238 -0
- package/tests/bridge.test.ts +1088 -0
- package/tests/browsercoverage.test.ts +134 -0
- package/tests/browserpolyfills.test.ts +124 -0
- package/tests/build.mjs +1260 -0
- package/tests/clientauth.test.ts +223 -0
- package/tests/clitools.test.ts +578 -0
- package/tests/confirmgates.test.ts +190 -0
- package/tests/containerpack.test.ts +143 -0
- package/tests/controlflow.test.ts +2120 -0
- package/tests/coordination.test.ts +345 -0
- package/tests/copyscreen.test.ts +139 -0
- package/tests/core.test.ts +417 -0
- package/tests/costcert.test.ts +95 -0
- package/tests/crossbrowser.test.ts +115 -0
- package/tests/cspaudit.test.ts +79 -0
- package/tests/dashdone.test.ts +615 -0
- package/tests/datagrid.test.ts +137 -0
- package/tests/doccheck.test.ts +107 -0
- package/tests/dombatch.test.ts +109 -0
- package/tests/emulation.test.ts +282 -0
- package/tests/environments.test.ts +259 -0
- package/tests/evidenceviews.test.ts +88 -0
- package/tests/exporttools.test.ts +185 -0
- package/tests/firefoxprep.test.ts +134 -0
- package/tests/flowlibrary.test.ts +242 -0
- package/tests/flowrun.test.ts +303 -0
- package/tests/forensics.test.ts +439 -0
- package/tests/gateway.test.ts +939 -0
- package/tests/headlesslib.test.ts +171 -0
- package/tests/httpstream.test.ts +277 -0
- package/tests/immutablelog.test.ts +157 -0
- package/tests/inboundguard.test.ts +177 -0
- package/tests/lazyload.test.ts +72 -0
- package/tests/librarymodes.test.ts +623 -0
- package/tests/llm.test.ts +696 -0
- package/tests/manifest.test.ts +226 -0
- package/tests/maskinputs.test.ts +150 -0
- package/tests/matrixverify.test.ts +111 -0
- package/tests/mavenpack.test.ts +75 -0
- package/tests/mcpserver.test.ts +704 -0
- package/tests/memory.test.ts +4155 -0
- package/tests/memorycare.test.ts +230 -0
- package/tests/migrateplan.test.ts +349 -0
- package/tests/minimization.test.ts +414 -0
- package/tests/modelroute.test.ts +112 -0
- package/tests/multiagent.test.ts +440 -0
- package/tests/nativebridge.test.ts +807 -0
- package/tests/navigation.test.ts +284 -0
- package/tests/nugetpack.test.ts +83 -0
- package/tests/orchestration.test.ts +449 -0
- package/tests/originpolicy.test.ts +405 -0
- package/tests/outputcompare.test.ts +97 -0
- package/tests/package.test.ts +91 -0
- package/tests/pagebridge.test.ts +5909 -0
- package/tests/pentest.test.ts +75 -0
- package/tests/perfrecords.test.ts +257 -0
- package/tests/permdiff.test.ts +85 -0
- package/tests/phishguard.test.ts +128 -0
- package/tests/pickerviews.test.ts +155 -0
- package/tests/pipelines.test.ts +381 -0
- package/tests/planlint.test.ts +240 -0
- package/tests/planreview.test.ts +383 -0
- package/tests/platformtargets.test.ts +56 -0
- package/tests/policy.test.ts +8284 -0
- package/tests/portability.test.ts +96 -0
- package/tests/progress.test.ts +1036 -0
- package/tests/promptlibrary.test.ts +116 -0
- package/tests/protocol.test.ts +4793 -0
- package/tests/publishingpipeline.test.ts +314 -0
- package/tests/quickactions.test.ts +164 -0
- package/tests/readiness.test.ts +78 -0
- package/tests/recipes.test.ts +163 -0
- package/tests/redactshots.test.ts +216 -0
- package/tests/registernvidiakeys.ts +51 -0
- package/tests/relayserve.test.ts +304 -0
- package/tests/releasenotes.test.ts +276 -0
- package/tests/resilience.test.ts +467 -0
- package/tests/resourceaware.test.ts +187 -0
- package/tests/runbudget.test.ts +125 -0
- package/tests/runhistory.test.ts +299 -0
- package/tests/runreplay.test.ts +113 -0
- package/tests/runresume.test.ts +184 -0
- package/tests/runstate.test.ts +288 -0
- package/tests/runtimeadapters.test.ts +251 -0
- package/tests/safariskeleton.test.ts +115 -0
- package/tests/sandboxframe.test.ts +196 -0
- package/tests/sbom.test.ts +105 -0
- package/tests/secretvault.test.ts +188 -0
- package/tests/selcache.test.ts +111 -0
- package/tests/sessioninterface.test.ts +791 -0
- package/tests/sessions.test.ts +591 -0
- package/tests/siteprefs.test.ts +228 -0
- package/tests/snapshotdelta.test.ts +165 -0
- package/tests/statusviews.test.ts +237 -0
- package/tests/stepmeter.test.ts +200 -0
- package/tests/streamparse.test.ts +163 -0
- package/tests/stylepruning.test.ts +90 -0
- package/tests/surfaces.test.ts +282 -0
- package/tests/sweep.test.ts +78 -0
- package/tests/syncbridge.test.ts +164 -0
- package/tests/taskqueue.test.ts +206 -0
- package/tests/toolcalls.test.ts +336 -0
- package/tests/toolcatalog.test.ts +201 -0
- package/tests/torture-agent.test.ts +706 -0
- package/tests/torture-auth.test.ts +461 -0
- package/tests/torture-commands.test.ts +895 -0
- package/tests/torture-data.test.ts +923 -0
- package/tests/torture-environments.test.ts +285 -0
- package/tests/torture-export.test.ts +263 -0
- package/tests/torture-http.test.ts +1379 -0
- package/tests/torture-llm.test.ts +808 -0
- package/tests/torture-memory.test.ts +1084 -0
- package/tests/torture-plan.test.ts +829 -0
- package/tests/torture-run.test.ts +626 -0
- package/tests/torture-session.test.ts +928 -0
- package/tests/torture-swarm.test.ts +1245 -0
- package/tests/torture-tools.test.ts +964 -0
- package/tests/torture-workflow.test.ts +2354 -0
- package/tests/tourviews.test.ts +52 -0
- package/tests/transparency.test.ts +147 -0
- package/tests/trigger.test.ts +941 -0
- package/tests/v1sunset.test.ts +428 -0
- package/tests/virtlist.test.ts +94 -0
- package/tests/vision.test.ts +540 -0
- package/tests/vsixpack.test.ts +145 -0
- package/tests/webapi.test.ts +523 -0
- package/tests/workflow.test.ts +1785 -0
- package/tests/workfloweditor.test.ts +759 -0
- package/tests/xpipack.test.ts +128 -0
- package/tools.ts +734 -0
- package/types.ts +9908 -0
- package/umd.ts +9 -0
- package/utils.ts +249 -0
- package/version.ts +8 -0
- package/versionregistry.ts +1310 -0
- package/views.ts +2153 -0
- package/vitest.config.ts +12 -0
- package/web/App.tsx +78 -0
- package/web/README.md +47 -0
- package/web/capacitor.config.ts +46 -0
- package/web/const.ts +19 -0
- package/web/control.shell.tsx +66 -0
- package/web/dashboardpage.ts +554 -0
- package/web/db.ts +493 -0
- package/web/design.html +541 -0
- package/web/gatewayview/Gateway.tsx +345 -0
- package/web/gatewayview/config.ts +1042 -0
- package/web/gatewayview/definition.ts +174 -0
- package/web/icons.ts +15 -0
- package/{manifest.json → web/manifest.json} +1 -1
- package/web/optionspage.ts +691 -0
- package/web/popup.ts +1651 -0
- package/web/schema.prisma +176 -0
- package/web/sidepanel.ts +7941 -0
- package/web/sitemanifest.json +13 -0
- package/web/terminal-ui.tsx +622 -0
- package/web/transparencypage.ts +541 -0
- package/web/vite.config.ts +39 -0
- package/web/workspace.ts +23 -0
- package/workbenchmemory.ts +104 -0
- package/workbenchsession.ts +304 -0
- package/workflow.ts +4495 -0
- package/bridge.d.ts.map +0 -1
- package/bridge.js +0 -1877
- package/bun.d.ts.map +0 -1
- package/bun.js +0 -52329
- package/caps/background.json +0 -712
- package/caps/cli.json +0 -360
- package/caps/library.json +0 -3611
- package/caps/mcp.json +0 -126
- package/caps/pagebridge.json +0 -377
- package/caps/popup.json +0 -51
- package/caps/sidepanel.json +0 -522
- package/checksums.txt +0 -142
- package/cli.d.ts.map +0 -1
- package/companion.d.ts.map +0 -1
- package/companion.js +0 -389
- package/crossbrowser.d.ts.map +0 -1
- package/crossbrowser.js +0 -1191
- package/dashdone.d.ts.map +0 -1
- package/dashdone.js +0 -368
- package/deno.d.ts.map +0 -1
- package/deno.js +0 -52325
- package/devthink.umd.js +0 -52346
- package/fixtures/bun.ts +0 -7
- package/fixtures/cjs.js +0 -6
- package/fixtures/deno.ts +0 -6
- package/fixtures/example-org-pagestate.json +0 -22
- package/fixtures/importers/automa-workflow.json +0 -18
- package/fixtures/importers/selenium-side.json +0 -20
- package/fixtures/importers/tabular-plan.csv +0 -7
- package/fixtures/importers/uivision-macro.json +0 -10
- package/fixtures/importers/v1-plan.json +0 -13
- package/fixtures/mcp-client.mjs +0 -14
- package/fixtures/pages/checkout.html +0 -192
- package/fixtures/pages/dashboard.html +0 -110
- package/fixtures/pages/feed.html +0 -113
- package/fixtures/pages/product-grid.html +0 -150
- package/fixtures/plans/form-inventory-plan.json +0 -11
- package/fixtures/plans/release-notes-plan.json +0 -11
- package/fixtures/recipes/agentscompete.json +0 -11
- package/fixtures/recipes/agentsforms.json +0 -11
- package/fixtures/recipes/agentsmonitor.json +0 -11
- package/fixtures/recipes/agentsreview.json +0 -29
- package/fixtures/recipes/agentswarm.json +0 -11
- package/fixtures/recipes/fillcalendar.json +0 -12
- package/fixtures/recipes/fillcheckout.json +0 -13
- package/fixtures/recipes/filldependentdropdowns.json +0 -12
- package/fixtures/recipes/fillfromcsv.json +0 -13
- package/fixtures/recipes/fillloginform.json +0 -12
- package/fixtures/recipes/fillmultistepwizard.json +0 -13
- package/fixtures/recipes/fillregistration.json +0 -12
- package/fixtures/recipes/fillsearchfilters.json +0 -12
- package/fixtures/recipes/filltypeahead.json +0 -11
- package/fixtures/recipes/monitoralerts.json +0 -12
- package/fixtures/recipes/monitoravailability.json +0 -11
- package/fixtures/recipes/monitornetworkquiet.json +0 -12
- package/fixtures/recipes/monitorpagechanges.json +0 -11
- package/fixtures/recipes/monitorpricechange.json +0 -11
- package/fixtures/recipes/monitorschedule.json +0 -12
- package/fixtures/recipes/scrapedynamicprices.json +0 -13
- package/fixtures/recipes/scrapeimagegallery.json +0 -12
- package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
- package/fixtures/recipes/scrapenestedlists.json +0 -12
- package/fixtures/recipes/scrapeproductgrid.json +0 -12
- package/fixtures/recipes/scrapereviews.json +0 -12
- package/fixtures/recipes/scrapesearchresults.json +0 -12
- package/fixtures/recipes/scrapetablepagination.json +0 -12
- package/fixtures/recipes/testaccessibility.json +0 -34
- package/fixtures/recipes/testconsoleerrors.json +0 -12
- package/fixtures/recipes/testdeeplinks.json +0 -12
- package/fixtures/recipes/testformvalidation.json +0 -13
- package/fixtures/recipes/testlayoutshift.json +0 -12
- package/fixtures/recipes/testlinkcheck.json +0 -12
- package/fixtures/recipes/testloadtiming.json +0 -12
- package/fixtures/recipes/testvisualdiff.json +0 -12
- package/fixtures/workflows/changelog-digest-workflow.json +0 -13
- package/fixtures/workflows/form-survey-workflow.json +0 -13
- package/gallery.json +0 -706
- package/hardening.d.ts.map +0 -1
- package/hardening.js +0 -188
- package/headless.d.ts.map +0 -1
- package/headless.js +0 -11522
- package/http.d.ts.map +0 -1
- package/http.js +0 -2165
- package/index.cjs +0 -54682
- package/index.d.ts.map +0 -1
- package/index.js +0 -52310
- package/index.neutral.js +0 -46343
- package/mcp.d.ts.map +0 -1
- package/mcp.js +0 -20515
- package/memory.d.ts.map +0 -1
- package/nativehost.template.json +0 -10
- package/node.cjs +0 -54686
- package/node.d.ts.map +0 -1
- package/pack.d.ts.map +0 -1
- package/pack.js +0 -805
- package/policy.d.ts.map +0 -1
- package/progress.d.ts.map +0 -1
- package/progress.js +0 -645
- package/protocol.d.ts.map +0 -1
- package/protocol.js +0 -8334
- package/schemas/audit.schema.json +0 -226
- package/schemas/capability.schema.json +0 -113
- package/schemas/envelope.schema.json +0 -162
- package/schemas/memory.schema.json +0 -51
- package/schemas/observation.schema.json +0 -113
- package/schemas/plan.schema.json +0 -208
- package/schemas/progress.schema.json +0 -126
- package/schemas/proposal.schema.json +0 -68
- package/schemas/session.schema.json +0 -82
- package/schemas/tool.schema.json +0 -94
- package/server.d.ts.map +0 -1
- package/server.js +0 -28877
- package/umd-example.html +0 -18
- package/umd.d.ts.map +0 -1
- /package/{bun.d.ts → dist/bun.d.ts} +0 -0
- /package/{cli.d.ts → dist/cli.d.ts} +0 -0
- /package/{companion.d.ts → dist/companion.d.ts} +0 -0
- /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
- /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
- /package/{deno.d.ts → dist/deno.d.ts} +0 -0
- /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
- /package/{headless.d.ts → dist/headless.d.ts} +0 -0
- /package/{http.d.ts → dist/http.d.ts} +0 -0
- /package/{index.d.ts → dist/index.d.ts} +0 -0
- /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
- /package/{memory.d.ts → dist/memory.d.ts} +0 -0
- /package/{node.d.ts → dist/node.d.ts} +0 -0
- /package/{pack.d.ts → dist/pack.d.ts} +0 -0
- /package/{policy.d.ts → dist/policy.d.ts} +0 -0
- /package/{progress.d.ts → dist/progress.d.ts} +0 -0
- /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
- /package/{server.d.ts → dist/server.d.ts} +0 -0
- /package/{umd.d.ts → dist/umd.d.ts} +0 -0
package/capture.ts
ADDED
|
@@ -0,0 +1,1614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capture module of the 1.1.90 consolidation: every correlated variation of the media capture, vision and redaction logic interned in this one file, so the module family carries one surface without duplicate variations.
|
|
3
|
+
* The correlation is the capture pipeline of a run: capture holds the base geometry (the capture options with their formats and export targets, the stitch tiling with seam blending and fixed header skip, the element crop with the viewport fallback, the region container steps, the contact sheet grid, the annotation plan and the capture naming rule with the before and after state pairing); media holds the media capture part two (the derived pdf composition with reviewed paper sizes and pagination break points, the recording records with their frame intervals and clean stops, the image filter matching with url deduplication, the lapse frame ordering, the conversion and thumbnail geometry and the raw media, asset and stream normalization); vision holds the teaching to see (the ocr recognition with its word boxes merged into lines and paragraphs, the region, pdf and frame ocr passes, the vision descriptions, the crop geometry that reuses the capture rectangle rules, the redaction masks, the grounding and the dom pairing beside every screenshot); and redactshots holds the capture masking (the sensitive regions derived from the field shapes the recognizer masks or drawn by the user, covering viewport, element and stitched captures alike so the stored bytes never carry them).
|
|
4
|
+
* No paper size, fps, region count, retention, model choice or cache window is ever hardcoded anywhere in the family: every geometry and bound stays the user's or the reviewed step's choice, recognition payloads stay opaque reviewed text, and no capture path ever bypasses the review.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
captureexport,
|
|
9
|
+
captureformat,
|
|
10
|
+
capturenaming,
|
|
11
|
+
captureoptions,
|
|
12
|
+
regionrect,
|
|
13
|
+
sheetlayout,
|
|
14
|
+
shotpair,
|
|
15
|
+
shotrecord,
|
|
16
|
+
stitchplan,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Media capture logics for the 1.1.40 family.
|
|
21
|
+
* Every correlated rule for the capture options defaults, the stitch tiling and seam blending, the fixed header skip, the pixel ratio scaling, the element crop and viewport fallback, the region container steps, the contact sheet grid, the annotation plan, the capture naming rule and the before and after state pairing lives in this file.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Formats every capture kind may produce; the policy grammar refuses anything outside this set. */
|
|
25
|
+
export const captureformats: captureformat[] = ["png", "jpeg", "webp"];
|
|
26
|
+
|
|
27
|
+
/** Export targets a reviewed capture may route to; disk writes stay inside the reviewed download flow. */
|
|
28
|
+
export const capturetargets: captureexport[] = ["memory", "download", "clipboard"];
|
|
29
|
+
|
|
30
|
+
/** The capture kinds of the 1.1.40 family, listed among the available capabilities of every proposal request. */
|
|
31
|
+
export const capturekinds: string[] = ["shotview", "shotfullpage", "shotelement", "shotregion", "contactsheet"];
|
|
32
|
+
|
|
33
|
+
/** Normalizes reviewed capture options with png, pixel ratio one and the memory export target as defaults. */
|
|
34
|
+
export function captureoptionsof(value: unknown): captureoptions {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
36
|
+
const options = value as Record<string, unknown>;
|
|
37
|
+
const normalized: captureoptions = {};
|
|
38
|
+
if (options.format === "png" || options.format === "jpeg" || options.format === "webp")
|
|
39
|
+
normalized.format = options.format;
|
|
40
|
+
if (typeof options.quality === "number" && Number.isFinite(options.quality)) normalized.quality = options.quality;
|
|
41
|
+
if (typeof options.pixelratio === "number" && Number.isFinite(options.pixelratio))
|
|
42
|
+
normalized.pixelratio = options.pixelratio;
|
|
43
|
+
if (typeof options.annotate === "boolean") normalized.annotate = options.annotate;
|
|
44
|
+
if (options.exporttarget === "memory" || options.exporttarget === "download" || options.exporttarget === "clipboard")
|
|
45
|
+
normalized.exporttarget = options.exporttarget;
|
|
46
|
+
return normalized;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Builds one shotrecord for a visible viewport capture with the reviewed geometry and byte size. */
|
|
50
|
+
export function capturevisible(input: {
|
|
51
|
+
runid: string;
|
|
52
|
+
stepid: string;
|
|
53
|
+
options: captureoptions;
|
|
54
|
+
viewport: { width: number; height: number };
|
|
55
|
+
dataurl: string;
|
|
56
|
+
at: number;
|
|
57
|
+
id: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
target?: string;
|
|
60
|
+
}): shotrecord {
|
|
61
|
+
const ratio = input.options.pixelratio ?? 1;
|
|
62
|
+
return {
|
|
63
|
+
id: input.id,
|
|
64
|
+
runid: input.runid,
|
|
65
|
+
stepid: input.stepid,
|
|
66
|
+
kind: "shotview",
|
|
67
|
+
format: input.options.format ?? "png",
|
|
68
|
+
width: Math.round(input.viewport.width * ratio),
|
|
69
|
+
height: Math.round(input.viewport.height * ratio),
|
|
70
|
+
capturedat: input.at,
|
|
71
|
+
bytes: input.dataurl,
|
|
72
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
73
|
+
...(input.options.annotate === true ? { annotated: true } : {}),
|
|
74
|
+
...(input.options.exporttarget !== undefined ? { exporttarget: input.options.exporttarget } : {}),
|
|
75
|
+
...(input.target !== undefined ? { target: input.target } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Builds one shotrecord for a stitched full page capture from the tile plan geometry. */
|
|
80
|
+
export function capturestitched(input: {
|
|
81
|
+
runid: string;
|
|
82
|
+
stepid: string;
|
|
83
|
+
options: captureoptions;
|
|
84
|
+
plan: stitchplan;
|
|
85
|
+
dataurl: string;
|
|
86
|
+
at: number;
|
|
87
|
+
id: string;
|
|
88
|
+
name?: string;
|
|
89
|
+
}): shotrecord {
|
|
90
|
+
const ratio = input.options.pixelratio ?? 1;
|
|
91
|
+
return {
|
|
92
|
+
id: input.id,
|
|
93
|
+
runid: input.runid,
|
|
94
|
+
stepid: input.stepid,
|
|
95
|
+
kind: "shotfullpage",
|
|
96
|
+
format: input.options.format ?? "png",
|
|
97
|
+
width: Math.round(input.plan.scrollwidth * ratio),
|
|
98
|
+
height: Math.round(input.plan.scrollheight * ratio),
|
|
99
|
+
capturedat: input.at,
|
|
100
|
+
bytes: input.dataurl,
|
|
101
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
102
|
+
...(input.options.annotate === true ? { annotated: true } : {}),
|
|
103
|
+
...(input.options.exporttarget !== undefined ? { exporttarget: input.options.exporttarget } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Builds one shotrecord for an element capture cropped to the pixel ratio scaled element bounds. */
|
|
108
|
+
export function captureelement(input: {
|
|
109
|
+
runid: string;
|
|
110
|
+
stepid: string;
|
|
111
|
+
options: captureoptions;
|
|
112
|
+
rect: regionrect;
|
|
113
|
+
dataurl: string;
|
|
114
|
+
at: number;
|
|
115
|
+
id: string;
|
|
116
|
+
name?: string;
|
|
117
|
+
target?: string;
|
|
118
|
+
}): shotrecord {
|
|
119
|
+
const ratio = input.options.pixelratio ?? 1;
|
|
120
|
+
const scaled = scaledrect(input.rect, ratio);
|
|
121
|
+
return {
|
|
122
|
+
id: input.id,
|
|
123
|
+
runid: input.runid,
|
|
124
|
+
stepid: input.stepid,
|
|
125
|
+
kind: "shotelement",
|
|
126
|
+
format: input.options.format ?? "png",
|
|
127
|
+
width: scaled.width,
|
|
128
|
+
height: scaled.height,
|
|
129
|
+
capturedat: input.at,
|
|
130
|
+
bytes: input.dataurl,
|
|
131
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
132
|
+
...(input.options.annotate === true ? { annotated: true } : {}),
|
|
133
|
+
...(input.options.exporttarget !== undefined ? { exporttarget: input.options.exporttarget } : {}),
|
|
134
|
+
...(input.target !== undefined ? { target: input.target } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Builds one shotrecord for a region capture of one reviewed rectangle or scrollable container. */
|
|
139
|
+
export function captureregion(input: {
|
|
140
|
+
runid: string;
|
|
141
|
+
stepid: string;
|
|
142
|
+
options: captureoptions;
|
|
143
|
+
rect: regionrect;
|
|
144
|
+
dataurl: string;
|
|
145
|
+
at: number;
|
|
146
|
+
id: string;
|
|
147
|
+
name?: string;
|
|
148
|
+
target?: string;
|
|
149
|
+
}): shotrecord {
|
|
150
|
+
const ratio = input.options.pixelratio ?? 1;
|
|
151
|
+
const scaled = scaledrect(input.rect, ratio);
|
|
152
|
+
return {
|
|
153
|
+
id: input.id,
|
|
154
|
+
runid: input.runid,
|
|
155
|
+
stepid: input.stepid,
|
|
156
|
+
kind: "shotregion",
|
|
157
|
+
format: input.options.format ?? "png",
|
|
158
|
+
width: scaled.width,
|
|
159
|
+
height: scaled.height,
|
|
160
|
+
capturedat: input.at,
|
|
161
|
+
bytes: input.dataurl,
|
|
162
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
163
|
+
...(input.options.annotate === true ? { annotated: true } : {}),
|
|
164
|
+
...(input.options.exporttarget !== undefined ? { exporttarget: input.options.exporttarget } : {}),
|
|
165
|
+
...(input.target !== undefined ? { target: input.target } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Links a before and an after shot into one shotpair with the action context and the dom snapshot id of the same moment; a missing after shot skips the pair. */
|
|
170
|
+
export function pairstates(
|
|
171
|
+
before: shotrecord | undefined,
|
|
172
|
+
after: shotrecord | undefined,
|
|
173
|
+
action: { kind: string; target?: string; domsnapshotid?: string },
|
|
174
|
+
at: number,
|
|
175
|
+
id: string,
|
|
176
|
+
): { pair?: shotpair; skipped?: "before" | "after"; reason: string } {
|
|
177
|
+
if (!before) return { skipped: "before", reason: "The before shot was not captured, so no state pair exists." };
|
|
178
|
+
if (!after)
|
|
179
|
+
return {
|
|
180
|
+
skipped: "after",
|
|
181
|
+
reason: `The action of kind ${action.kind} failed before the after shot, so the state pair is skipped.`,
|
|
182
|
+
};
|
|
183
|
+
return {
|
|
184
|
+
pair: {
|
|
185
|
+
id,
|
|
186
|
+
beforeid: before.id,
|
|
187
|
+
afterid: after.id,
|
|
188
|
+
actionkind: action.kind,
|
|
189
|
+
...(action.target !== undefined ? { target: action.target } : {}),
|
|
190
|
+
...(action.domsnapshotid !== undefined ? { domsnapshotid: action.domsnapshotid } : {}),
|
|
191
|
+
at,
|
|
192
|
+
},
|
|
193
|
+
reason: `Paired the before shot ${before.id} with the after shot ${after.id} around the ${action.kind} action.`,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Applies the capture policy around one action: beforeafter pairs wrap the action, every other mode leaves the pair to the manual capture kinds. */
|
|
198
|
+
export function capturestates(input: {
|
|
199
|
+
policy: string;
|
|
200
|
+
before?: shotrecord | undefined;
|
|
201
|
+
after?: shotrecord | undefined;
|
|
202
|
+
actionkind: string;
|
|
203
|
+
target?: string | undefined;
|
|
204
|
+
domsnapshotid?: string | undefined;
|
|
205
|
+
at: number;
|
|
206
|
+
id: string;
|
|
207
|
+
}): { pair?: shotpair; skipped?: "before" | "after"; reason: string } {
|
|
208
|
+
if (input.policy !== "beforeafter")
|
|
209
|
+
return { reason: `The ${input.policy} capture policy takes no state pair around the ${input.actionkind} action.` };
|
|
210
|
+
return pairstates(
|
|
211
|
+
input.before,
|
|
212
|
+
input.after,
|
|
213
|
+
{
|
|
214
|
+
kind: input.actionkind,
|
|
215
|
+
...(input.target !== undefined ? { target: input.target } : {}),
|
|
216
|
+
...(input.domsnapshotid !== undefined ? { domsnapshotid: input.domsnapshotid } : {}),
|
|
217
|
+
},
|
|
218
|
+
input.at,
|
|
219
|
+
input.id,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Builds the viewport tiling of one full page capture: a tile grid of scroll offsets with overlap rows; the last tile clamps to the page end. */
|
|
224
|
+
export function buildstitchplan(input: {
|
|
225
|
+
scrollwidth: number;
|
|
226
|
+
scrollheight: number;
|
|
227
|
+
viewportwidth: number;
|
|
228
|
+
viewportheight: number;
|
|
229
|
+
overlap?: number;
|
|
230
|
+
}): stitchplan {
|
|
231
|
+
const overlap = Math.max(0, Math.round(input.overlap ?? 0));
|
|
232
|
+
const stepy = Math.max(1, input.viewportheight - overlap);
|
|
233
|
+
const columns = Math.max(1, Math.ceil(input.scrollwidth / input.viewportwidth));
|
|
234
|
+
const rows =
|
|
235
|
+
input.scrollheight <= input.viewportheight ? 1 : Math.max(1, Math.ceil((input.scrollheight - overlap) / stepy));
|
|
236
|
+
const tiles: Array<{ x: number; y: number }> = [];
|
|
237
|
+
for (let column = 0; column < columns; column += 1) {
|
|
238
|
+
for (let row = 0; row < rows; row += 1) {
|
|
239
|
+
const x = Math.min(column * input.viewportwidth, Math.max(0, input.scrollwidth - input.viewportwidth));
|
|
240
|
+
const y = rows === 1 ? 0 : Math.min(row * stepy, Math.max(0, input.scrollheight - input.viewportheight));
|
|
241
|
+
tiles.push({ x: Math.round(x), y: Math.round(y) });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
columns,
|
|
246
|
+
rows,
|
|
247
|
+
tiles,
|
|
248
|
+
overlap,
|
|
249
|
+
scrollwidth: Math.round(input.scrollwidth),
|
|
250
|
+
scrollheight: Math.round(input.scrollheight),
|
|
251
|
+
viewportwidth: Math.round(input.viewportwidth),
|
|
252
|
+
viewportheight: Math.round(input.viewportheight),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Linear cross fade weights across one overlap band: the first row keeps the existing content, the last row adopts the new tile. */
|
|
257
|
+
export function seamweights(overlap: number): number[] {
|
|
258
|
+
if (overlap <= 0) return [];
|
|
259
|
+
const weights: number[] = [];
|
|
260
|
+
for (let index = 0; index < overlap; index += 1) weights.push((index + 1) / (overlap + 1));
|
|
261
|
+
return weights;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Blends one overlap band of equal length row arrays with the linear seam weights so tile seams never hard cut. */
|
|
265
|
+
export function blendrows(upper: number[], lower: number[]): number[] {
|
|
266
|
+
const weights = seamweights(upper.length);
|
|
267
|
+
return upper.map((value, index) => {
|
|
268
|
+
const weight = weights[index] ?? 1;
|
|
269
|
+
return value * (1 - weight) + (lower[index] ?? value) * weight;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** True when a tile band repeats the first tile band, so the stitcher skips it and fixed headers never repeat across tiles. */
|
|
274
|
+
export function fixedheadermatch(band: number[], firstband: number[]): boolean {
|
|
275
|
+
if (band.length === 0 || band.length !== firstband.length) return false;
|
|
276
|
+
return band.every((value, index) => value === firstband[index]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Scales one css pixel rectangle by the reviewed pixel ratio; ratios of one, two and three scale every edge. */
|
|
280
|
+
export function scaledrect(rect: regionrect, pixelratio: number): regionrect {
|
|
281
|
+
const ratio = pixelratio >= 1 ? pixelratio : 1;
|
|
282
|
+
return {
|
|
283
|
+
x: Math.round(rect.x * ratio),
|
|
284
|
+
y: Math.round(rect.y * ratio),
|
|
285
|
+
width: Math.round(rect.width * ratio),
|
|
286
|
+
height: Math.round(rect.height * ratio),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Clamps one rectangle to the visible part of the viewport, never returning negative geometry. */
|
|
291
|
+
export function croprect(rect: regionrect, viewport: { width: number; height: number }): regionrect {
|
|
292
|
+
const x = Math.max(0, rect.x);
|
|
293
|
+
const y = Math.max(0, rect.y);
|
|
294
|
+
return {
|
|
295
|
+
x: Math.round(x),
|
|
296
|
+
y: Math.round(y),
|
|
297
|
+
width: Math.round(Math.max(0, Math.min(rect.width, viewport.width - x))),
|
|
298
|
+
height: Math.round(Math.max(0, Math.min(rect.height, viewport.height - y))),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** True when an element rectangle crosses any viewport edge and the capture falls back to tiled capture. */
|
|
303
|
+
export function crossesviewport(rect: regionrect, viewport: { width: number; height: number }): boolean {
|
|
304
|
+
return rect.x < 0 || rect.y < 0 || rect.x + rect.width > viewport.width || rect.y + rect.height > viewport.height;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Scroll tops that walk a scrollable container in reviewed steps; every step clamps so the last window ends at the container end. */
|
|
308
|
+
export function regionsteps(containerheight: number, viewportstep: number): number[] {
|
|
309
|
+
if (containerheight <= 0 || viewportstep <= 0) return [0];
|
|
310
|
+
const steps: number[] = [];
|
|
311
|
+
for (let top = 0; top < containerheight; top += viewportstep) {
|
|
312
|
+
const clamped = Math.min(top, Math.max(0, containerheight - viewportstep));
|
|
313
|
+
if (!steps.includes(clamped)) steps.push(clamped);
|
|
314
|
+
}
|
|
315
|
+
return steps;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** One placed cell of a contact sheet grid with its caption stamped from the reviewed label style. */
|
|
319
|
+
export interface sheetcell {
|
|
320
|
+
index: number;
|
|
321
|
+
column: number;
|
|
322
|
+
row: number;
|
|
323
|
+
selector: string;
|
|
324
|
+
label: string;
|
|
325
|
+
caption: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Places the element captures of a contact sheet on a labeled grid; the cell count stays a user choice with no code ceiling. */
|
|
329
|
+
export function buildsheet(
|
|
330
|
+
cells: Array<{ selector: string; label?: string }>,
|
|
331
|
+
layout: sheetlayout,
|
|
332
|
+
): { columns: number; rows: number; cells: sheetcell[] } {
|
|
333
|
+
const columns = Math.max(1, Math.round(layout.columns));
|
|
334
|
+
const rows = Math.max(1, Math.ceil(cells.length / columns));
|
|
335
|
+
const placed: sheetcell[] = cells.map((cell, index) => {
|
|
336
|
+
const column = index % columns;
|
|
337
|
+
const row = Math.floor(index / columns);
|
|
338
|
+
const label = cell.label ?? "";
|
|
339
|
+
const caption =
|
|
340
|
+
layout.label === "none"
|
|
341
|
+
? ""
|
|
342
|
+
: layout.label === "index"
|
|
343
|
+
? `${index + 1}`
|
|
344
|
+
: layout.label === "selector"
|
|
345
|
+
? cell.selector
|
|
346
|
+
: label
|
|
347
|
+
? `${index + 1} · ${cell.selector} · ${label}`
|
|
348
|
+
: `${index + 1} · ${cell.selector}`;
|
|
349
|
+
return { index, column, row, selector: cell.selector, label, caption };
|
|
350
|
+
});
|
|
351
|
+
return { columns, rows, cells: placed };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Slugifies one capture name part into a filename safe lowercase form through a plain character walk: a run of characters outside the reviewed set collapses into one dash, the leading and trailing dashes trim away, and the walk stays linear on adversarial names where a run regex would backtrack. */
|
|
355
|
+
function capturepart(value: string): string {
|
|
356
|
+
let out = "";
|
|
357
|
+
let pendingdash = false;
|
|
358
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
359
|
+
const code = value.charCodeAt(index);
|
|
360
|
+
const kept = (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || (code >= 48 && code <= 57) || code === 45;
|
|
361
|
+
if (kept) {
|
|
362
|
+
if (pendingdash && out !== "") out += "-";
|
|
363
|
+
pendingdash = false;
|
|
364
|
+
out += value[index] ?? "";
|
|
365
|
+
} else pendingdash = true;
|
|
366
|
+
}
|
|
367
|
+
let start = 0;
|
|
368
|
+
let end = out.length;
|
|
369
|
+
while (start < end && out[start] === "-") start += 1;
|
|
370
|
+
while (end > start && out[end - 1] === "-") end -= 1;
|
|
371
|
+
const slug = out.slice(start, end).toLowerCase();
|
|
372
|
+
return slug !== "" ? slug : "capture";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Builds one capture filename from the reviewed naming rule segments; the sequence counter keeps every name unique inside a run. */
|
|
376
|
+
export function buildname(
|
|
377
|
+
rule: capturenaming,
|
|
378
|
+
parts: { run: string; step: string; sequence: number; kind: string },
|
|
379
|
+
extension: string,
|
|
380
|
+
): string {
|
|
381
|
+
const segments: string[] = [];
|
|
382
|
+
if (rule.run) segments.push(capturepart(parts.run));
|
|
383
|
+
if (rule.step) segments.push(capturepart(parts.step));
|
|
384
|
+
if (rule.sequence) segments.push(String(Math.max(0, Math.round(parts.sequence))));
|
|
385
|
+
if (rule.kind) segments.push(capturepart(parts.kind));
|
|
386
|
+
const safeextension = extension.replace(/^\.+/, "").toLowerCase() || "png";
|
|
387
|
+
return `${(segments.length > 0 ? segments : ["capture"]).join("-")}.${safeextension}`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** One annotation plan for a captured image: the step number marker, the optional target outline and the footer with capture time and page url. */
|
|
391
|
+
export interface annotationplan {
|
|
392
|
+
marker: { x: number; y: number; number: number };
|
|
393
|
+
outline?: regionrect;
|
|
394
|
+
footer: string;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Plans the annotations of one capture: the step number marker position, the expanded target rect outline and the footer text with capture time and url. */
|
|
398
|
+
export function annotationplanof(input: {
|
|
399
|
+
step: number;
|
|
400
|
+
width: number;
|
|
401
|
+
height: number;
|
|
402
|
+
rect?: regionrect;
|
|
403
|
+
url: string;
|
|
404
|
+
at: number;
|
|
405
|
+
}): annotationplan {
|
|
406
|
+
const inset = Math.min(24, Math.max(8, Math.round(Math.min(input.width, input.height) / 12)));
|
|
407
|
+
const plan: annotationplan = {
|
|
408
|
+
marker: { x: inset, y: inset, number: Math.max(1, Math.round(input.step)) },
|
|
409
|
+
footer: `${new Date(input.at).toISOString()} · ${input.url}`,
|
|
410
|
+
};
|
|
411
|
+
if (input.rect !== undefined) {
|
|
412
|
+
const expansion = 2;
|
|
413
|
+
plan.outline = {
|
|
414
|
+
x: Math.round(input.rect.x - expansion),
|
|
415
|
+
y: Math.round(input.rect.y - expansion),
|
|
416
|
+
width: Math.round(input.rect.width + expansion * 2),
|
|
417
|
+
height: Math.round(input.rect.height + expansion * 2),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
return plan;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* ── Merged from media.ts ── */
|
|
424
|
+
|
|
425
|
+
import type {
|
|
426
|
+
assetrecord,
|
|
427
|
+
convertdirective,
|
|
428
|
+
imagefilter,
|
|
429
|
+
imagedescriptor,
|
|
430
|
+
mediadatum,
|
|
431
|
+
pdfoptions,
|
|
432
|
+
recordingoptions,
|
|
433
|
+
recordingrecord,
|
|
434
|
+
streamrecord,
|
|
435
|
+
thumbdirective,
|
|
436
|
+
} from "./types.js";
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Media capture part two logics for the 1.1.41 family.
|
|
440
|
+
* Every correlated rule for the derived pdf composition with reviewed paper sizes, margins and pagination break points, the recording records with their frame intervals and clean stops, the image filter matching with url deduplication and counter names, the lapse frame ordering, the conversion and thumbnail geometry and the raw media, asset and stream normalization lives in this file.
|
|
441
|
+
*/
|
|
442
|
+
|
|
443
|
+
/** The media capture part two kinds, listed among the available capabilities of every proposal request. */
|
|
444
|
+
export const mediakinds: string[] = [
|
|
445
|
+
"capturepdf",
|
|
446
|
+
"recordscreen",
|
|
447
|
+
"captureaudio",
|
|
448
|
+
"captureframe",
|
|
449
|
+
"downloadimages",
|
|
450
|
+
"shotcanvas",
|
|
451
|
+
"probestream",
|
|
452
|
+
"readmedia",
|
|
453
|
+
"readassets",
|
|
454
|
+
"timelapse",
|
|
455
|
+
"convertimage",
|
|
456
|
+
"makethumbs",
|
|
457
|
+
];
|
|
458
|
+
|
|
459
|
+
/** Default paper size in inches when the reviewed options leave it open: us letter portrait. */
|
|
460
|
+
const defaultpaperwidth = 8.5;
|
|
461
|
+
const defaultpaperheight = 11;
|
|
462
|
+
|
|
463
|
+
/** Default page margins in inches when the reviewed options leave them open. */
|
|
464
|
+
const defaultmargins = { top: 0.4, right: 0.4, bottom: 0.4, left: 0.4 };
|
|
465
|
+
|
|
466
|
+
/** Points per paper inch of the pdf coordinate space. */
|
|
467
|
+
const pdfpointsperinch = 72;
|
|
468
|
+
|
|
469
|
+
/** Base body font size in points that the reviewed scale multiplies. */
|
|
470
|
+
const basefontsize = 11;
|
|
471
|
+
|
|
472
|
+
/** Normalizes reviewed pdf options with paper defaults; unknown fields stay ignored. */
|
|
473
|
+
export function pdfoptionsof(value: unknown): pdfoptions {
|
|
474
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
475
|
+
const options = value as Record<string, unknown>;
|
|
476
|
+
const normalized: pdfoptions = {};
|
|
477
|
+
if (typeof options.paperwidth === "number" && Number.isFinite(options.paperwidth))
|
|
478
|
+
normalized.paperwidth = options.paperwidth;
|
|
479
|
+
if (typeof options.paperheight === "number" && Number.isFinite(options.paperheight))
|
|
480
|
+
normalized.paperheight = options.paperheight;
|
|
481
|
+
if (options.margins && typeof options.margins === "object" && !Array.isArray(options.margins)) {
|
|
482
|
+
const margins = options.margins as Record<string, unknown>;
|
|
483
|
+
const top = typeof margins.top === "number" ? margins.top : defaultmargins.top;
|
|
484
|
+
const right = typeof margins.right === "number" ? margins.right : defaultmargins.right;
|
|
485
|
+
const bottom = typeof margins.bottom === "number" ? margins.bottom : defaultmargins.bottom;
|
|
486
|
+
const left = typeof margins.left === "number" ? margins.left : defaultmargins.left;
|
|
487
|
+
normalized.margins = { top, right, bottom, left };
|
|
488
|
+
}
|
|
489
|
+
if (typeof options.scale === "number" && Number.isFinite(options.scale)) normalized.scale = options.scale;
|
|
490
|
+
if (typeof options.landscape === "boolean") normalized.landscape = options.landscape;
|
|
491
|
+
if (typeof options.paginate === "boolean") normalized.paginate = options.paginate;
|
|
492
|
+
return normalized;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Resolves the pdf page size in points after the landscape swap. */
|
|
496
|
+
export function pdfpagesize(options: pdfoptions): { width: number; height: number } {
|
|
497
|
+
const width = (options.paperwidth ?? defaultpaperwidth) * pdfpointsperinch;
|
|
498
|
+
const height = (options.paperheight ?? defaultpaperheight) * pdfpointsperinch;
|
|
499
|
+
return options.landscape === true ? { width: height, height: width } : { width, height };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Resolves the reviewed margins in points. */
|
|
503
|
+
function pdfmargins(options: pdfoptions): { top: number; right: number; bottom: number; left: number } {
|
|
504
|
+
const margins = options.margins ?? defaultmargins;
|
|
505
|
+
return {
|
|
506
|
+
top: margins.top * pdfpointsperinch,
|
|
507
|
+
right: margins.right * pdfpointsperinch,
|
|
508
|
+
bottom: margins.bottom * pdfpointsperinch,
|
|
509
|
+
left: margins.left * pdfpointsperinch,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Resolves the body font size in points from the reviewed scale with no code ceiling on the scale itself. */
|
|
514
|
+
function pdffontsize(options: pdfoptions): number {
|
|
515
|
+
return basefontsize * (options.scale ?? 1);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Wraps one text block into page lines for the reviewed geometry: word wrap at the average glyph width of the Helvetica body font. */
|
|
519
|
+
export function pdftextlayout(text: string, options: pdfoptions): string[] {
|
|
520
|
+
const size = pdfpagesize(options);
|
|
521
|
+
const margins = pdfmargins(options);
|
|
522
|
+
const fontsize = pdffontsize(options);
|
|
523
|
+
const leading = fontsize * 1.35;
|
|
524
|
+
const linesperpage = Math.max(1, Math.floor((size.height - margins.top - margins.bottom) / leading));
|
|
525
|
+
const columns = Math.max(1, Math.floor((size.width - margins.left - margins.right) / (fontsize * 0.5)));
|
|
526
|
+
const wrapped: string[] = [];
|
|
527
|
+
for (const paragraph of text.split(/\r?\n/)) {
|
|
528
|
+
let line = "";
|
|
529
|
+
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
530
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
531
|
+
if (candidate.length <= columns) {
|
|
532
|
+
line = candidate;
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (line) wrapped.push(line);
|
|
536
|
+
if (word.length <= columns) {
|
|
537
|
+
line = word;
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
for (let index = 0; index < word.length; index += columns) wrapped.push(word.slice(index, index + columns));
|
|
541
|
+
line = "";
|
|
542
|
+
}
|
|
543
|
+
wrapped.push(line);
|
|
544
|
+
if (wrapped.length >= linesperpage) break;
|
|
545
|
+
}
|
|
546
|
+
return wrapped.slice(0, linesperpage);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Splits the scroll height into report page segments at reviewed break point offsets; absent break points walk the height in viewport steps. */
|
|
550
|
+
export function pdfsegments(
|
|
551
|
+
scrollheight: number,
|
|
552
|
+
viewportheight: number,
|
|
553
|
+
breaks: number[],
|
|
554
|
+
): Array<{ top: number; height: number }> {
|
|
555
|
+
if (scrollheight <= 0) return [];
|
|
556
|
+
const step = viewportheight > 0 ? viewportheight : scrollheight;
|
|
557
|
+
const cuts = [
|
|
558
|
+
0,
|
|
559
|
+
...breaks.filter((top) => Number.isFinite(top) && top > 0 && top < scrollheight).map((top) => Math.round(top)),
|
|
560
|
+
]
|
|
561
|
+
.filter((top, index, list) => list.indexOf(top) === index)
|
|
562
|
+
.sort((left, right) => left - right);
|
|
563
|
+
const segments: Array<{ top: number; height: number }> = [];
|
|
564
|
+
let index = 0;
|
|
565
|
+
let cursor = 0;
|
|
566
|
+
while (cursor < scrollheight) {
|
|
567
|
+
while (index < cuts.length && (cuts[index] ?? 0) <= cursor) index += 1;
|
|
568
|
+
const nextcut = index < cuts.length ? cuts[index] : undefined;
|
|
569
|
+
const next = nextcut !== undefined ? Math.min(nextcut, scrollheight) : Math.min(cursor + step, scrollheight);
|
|
570
|
+
if (next <= cursor) break;
|
|
571
|
+
segments.push({ top: cursor, height: next - cursor });
|
|
572
|
+
cursor = next;
|
|
573
|
+
}
|
|
574
|
+
return segments.length > 0 ? segments : [{ top: 0, height: scrollheight }];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Escapes one pdf text string: parentheses and backslashes escape and characters outside latin one become honest question marks. */
|
|
578
|
+
function pdfescape(text: string): string {
|
|
579
|
+
let escaped = "";
|
|
580
|
+
for (const character of text) {
|
|
581
|
+
const code = character.charCodeAt(0);
|
|
582
|
+
if (character === "(" || character === ")" || character === "\\") escaped += `\\${character}`;
|
|
583
|
+
else if (code >= 32 && code <= 255) escaped += character;
|
|
584
|
+
else escaped += "?";
|
|
585
|
+
}
|
|
586
|
+
return escaped;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Composes one derived pdf document from the page text blocks with the reviewed paper size, margins, scale and landscape orientation; the byte size is the latin one document length. */
|
|
590
|
+
export function buildpdf(
|
|
591
|
+
pages: string[],
|
|
592
|
+
options: pdfoptions,
|
|
593
|
+
): { document: string; bytes: number; pages: number; pagewidth: number; pageheight: number } {
|
|
594
|
+
const size = pdfpagesize(options);
|
|
595
|
+
const margins = pdfmargins(options);
|
|
596
|
+
const fontsize = pdffontsize(options);
|
|
597
|
+
const leading = fontsize * 1.35;
|
|
598
|
+
const laidout = (pages.length > 0 ? pages : [""]).map((text) => pdftextlayout(text, options));
|
|
599
|
+
const objects: string[] = [];
|
|
600
|
+
const kids = laidout.map((_, index) => `${4 + index * 2} 0 R`).join(" ");
|
|
601
|
+
objects.push(`<< /Type /Catalog /Pages 2 0 R >>`);
|
|
602
|
+
objects.push(`<< /Type /Pages /Kids [${kids}] /Count ${laidout.length} >>`);
|
|
603
|
+
objects.push(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>`);
|
|
604
|
+
for (let pageindex = 0; pageindex < laidout.length; pageindex += 1) {
|
|
605
|
+
const lines = laidout[pageindex] ?? [];
|
|
606
|
+
const operators: string[] = [
|
|
607
|
+
"BT",
|
|
608
|
+
`/F1 ${fontsize} Tf`,
|
|
609
|
+
`${leading.toFixed(2)} TL`,
|
|
610
|
+
`${margins.left.toFixed(2)} ${(size.height - margins.top - fontsize).toFixed(2)} Td`,
|
|
611
|
+
];
|
|
612
|
+
for (let lineindex = 0; lineindex < lines.length; lineindex += 1) {
|
|
613
|
+
if (lineindex > 0) operators.push("T*");
|
|
614
|
+
operators.push(`(${pdfescape(lines[lineindex] ?? "")}) Tj`);
|
|
615
|
+
}
|
|
616
|
+
operators.push("ET");
|
|
617
|
+
const content = operators.join("\n");
|
|
618
|
+
objects.push(
|
|
619
|
+
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${size.width.toFixed(2)} ${size.height.toFixed(2)}] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + pageindex * 2} 0 R >>`,
|
|
620
|
+
);
|
|
621
|
+
objects.push(`<< /Length ${content.length} >>\nstream\n${content}\nendstream`);
|
|
622
|
+
}
|
|
623
|
+
let document = "%PDF-1.4\n";
|
|
624
|
+
const offsets: number[] = [];
|
|
625
|
+
for (let index = 0; index < objects.length; index += 1) {
|
|
626
|
+
offsets.push(document.length);
|
|
627
|
+
document += `${index + 1} 0 obj\n${objects[index]}\nendobj\n`;
|
|
628
|
+
}
|
|
629
|
+
const xrefstart = document.length;
|
|
630
|
+
document += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
631
|
+
for (const offset of offsets) document += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
|
632
|
+
document += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefstart}\n%%EOF\n`;
|
|
633
|
+
return {
|
|
634
|
+
document,
|
|
635
|
+
bytes: document.length,
|
|
636
|
+
pages: laidout.length,
|
|
637
|
+
pagewidth: Math.round(size.width),
|
|
638
|
+
pageheight: Math.round(size.height),
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Normalizes reviewed recording options with the tab scope as the default. */
|
|
643
|
+
export function recordingoptionsof(value: unknown): recordingoptions {
|
|
644
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
645
|
+
const options = value as Record<string, unknown>;
|
|
646
|
+
const normalized: recordingoptions = {};
|
|
647
|
+
if (options.scope === "tab" || options.scope === "run") normalized.scope = options.scope;
|
|
648
|
+
if (typeof options.fps === "number" && Number.isFinite(options.fps)) normalized.fps = options.fps;
|
|
649
|
+
if (typeof options.bitrate === "number" && Number.isFinite(options.bitrate)) normalized.bitrate = options.bitrate;
|
|
650
|
+
if (typeof options.audio === "boolean") normalized.audio = options.audio;
|
|
651
|
+
return normalized;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Opens one recording record of user activity with the reviewed options applied. */
|
|
655
|
+
export function newrecording(input: {
|
|
656
|
+
id: string;
|
|
657
|
+
runid: string;
|
|
658
|
+
stepid: string;
|
|
659
|
+
tabid: number;
|
|
660
|
+
kind: "screen" | "audio";
|
|
661
|
+
options: recordingoptions;
|
|
662
|
+
at: number;
|
|
663
|
+
}): recordingrecord {
|
|
664
|
+
return {
|
|
665
|
+
id: input.id,
|
|
666
|
+
runid: input.runid,
|
|
667
|
+
stepid: input.stepid,
|
|
668
|
+
tabid: input.tabid,
|
|
669
|
+
kind: input.kind,
|
|
670
|
+
scope: input.options.scope ?? "tab",
|
|
671
|
+
format: input.kind === "audio" ? "evidence" : "frames",
|
|
672
|
+
startedat: input.at,
|
|
673
|
+
at: input.at,
|
|
674
|
+
...(input.options.fps !== undefined ? { fps: input.options.fps } : {}),
|
|
675
|
+
...(input.options.bitrate !== undefined ? { bitrate: input.options.bitrate } : {}),
|
|
676
|
+
...(input.options.audio !== undefined ? { audio: input.options.audio } : {}),
|
|
677
|
+
frames: [],
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Closes one recording cleanly at the given time and stamps the duration. */
|
|
682
|
+
export function finishrecording(record: recordingrecord, endat: number): recordingrecord {
|
|
683
|
+
return { ...record, endedat: endat, duration: Math.max(0, endat - record.startedat) };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Frame shoot interval in milliseconds of the reviewed fps; the fps itself carries no code ceiling. */
|
|
687
|
+
export function frameinterval(fps: number): number {
|
|
688
|
+
if (!Number.isFinite(fps) || fps <= 0) return 1000;
|
|
689
|
+
return Math.max(1, Math.round(1000 / fps));
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Normalizes a reviewed image filter; unknown fields stay ignored. */
|
|
693
|
+
export function imagefilterof(value: unknown): imagefilter {
|
|
694
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
695
|
+
const options = value as Record<string, unknown>;
|
|
696
|
+
const normalized: imagefilter = {};
|
|
697
|
+
if (typeof options.selector === "string" && options.selector.trim()) normalized.selector = options.selector.trim();
|
|
698
|
+
if (typeof options.minwidth === "number" && Number.isFinite(options.minwidth)) normalized.minwidth = options.minwidth;
|
|
699
|
+
if (typeof options.minheight === "number" && Number.isFinite(options.minheight))
|
|
700
|
+
normalized.minheight = options.minheight;
|
|
701
|
+
if (Array.isArray(options.formats) && options.formats.every((item) => typeof item === "string" && item.trim()))
|
|
702
|
+
normalized.formats = options.formats as string[];
|
|
703
|
+
return normalized;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** True when one observed image passes the reviewed filter: minimum dimensions and the format list; absent bounds never refuse. */
|
|
707
|
+
export function imagematches(image: imagedescriptor, filter: imagefilter): boolean {
|
|
708
|
+
if (filter.minwidth !== undefined && image.width < filter.minwidth) return false;
|
|
709
|
+
if (filter.minheight !== undefined && image.height < filter.minheight) return false;
|
|
710
|
+
if (filter.formats !== undefined && filter.formats.length > 0) {
|
|
711
|
+
const mime = image.mime.toLowerCase();
|
|
712
|
+
const matches = filter.formats.some((format) => {
|
|
713
|
+
const wanted = format.toLowerCase().trim();
|
|
714
|
+
return mime === wanted || mime === `image/${wanted}` || mime.endsWith(`/${wanted}`);
|
|
715
|
+
});
|
|
716
|
+
if (!matches) return false;
|
|
717
|
+
}
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Deduplicates observed images by url before any download starts; the first observation of a url wins. */
|
|
722
|
+
export function dedupeimages(images: imagedescriptor[]): imagedescriptor[] {
|
|
723
|
+
const seen = new Set<string>();
|
|
724
|
+
const unique: imagedescriptor[] = [];
|
|
725
|
+
for (const image of images) {
|
|
726
|
+
if (seen.has(image.url)) continue;
|
|
727
|
+
seen.add(image.url);
|
|
728
|
+
unique.push(image);
|
|
729
|
+
}
|
|
730
|
+
return unique;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Stamps consistent image batch filenames from the reviewed naming rule with a per batch image counter. */
|
|
734
|
+
export function imagenames(rule: capturenaming, run: string, step: string, count: number, extension: string): string[] {
|
|
735
|
+
const names: string[] = [];
|
|
736
|
+
for (let index = 1; index <= Math.max(0, Math.round(count)); index += 1)
|
|
737
|
+
names.push(buildname(rule, { run, step, sequence: index, kind: "image" }, extension));
|
|
738
|
+
return names;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Normalizes a reviewed lapse plan; an absent format keeps png. */
|
|
742
|
+
export function lapseplanof(value: unknown): { interval: number; duration: number; format: captureformat } | undefined {
|
|
743
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
744
|
+
const options = value as Record<string, unknown>;
|
|
745
|
+
if (typeof options.interval !== "number" || !Number.isFinite(options.interval)) return undefined;
|
|
746
|
+
if (typeof options.duration !== "number" || !Number.isFinite(options.duration)) return undefined;
|
|
747
|
+
const format = options.format === "jpeg" || options.format === "webp" ? options.format : "png";
|
|
748
|
+
return { interval: options.interval, duration: options.duration, format };
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Ordered lapse frame timestamps starting at zero and stepping the reviewed interval inside the reviewed duration. */
|
|
752
|
+
export function lapseframes(plan: { interval: number; duration: number }): number[] {
|
|
753
|
+
if (!(plan.interval > 0) || !(plan.duration > 0)) return [];
|
|
754
|
+
const frames: number[] = [];
|
|
755
|
+
for (let time = 0; time < plan.duration; time += plan.interval) frames.push(Math.round(time));
|
|
756
|
+
return frames;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Normalizes a reviewed conversion directive; an absent payload leaves the step without a directive. */
|
|
760
|
+
export function convertdirectiveof(value: unknown): convertdirective | undefined {
|
|
761
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
762
|
+
const options = value as Record<string, unknown>;
|
|
763
|
+
if (options.target !== "png" && options.target !== "jpeg" && options.target !== "webp") return undefined;
|
|
764
|
+
const normalized: convertdirective = { target: options.target };
|
|
765
|
+
if (options.source === "png" || options.source === "jpeg" || options.source === "webp")
|
|
766
|
+
normalized.source = options.source;
|
|
767
|
+
if (typeof options.quality === "number" && Number.isFinite(options.quality)) normalized.quality = options.quality;
|
|
768
|
+
return normalized;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Normalizes a reviewed thumbnail directive; an absent payload leaves the step without a directive. */
|
|
772
|
+
export function thumbdirectiveof(value: unknown): thumbdirective | undefined {
|
|
773
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
774
|
+
const options = value as Record<string, unknown>;
|
|
775
|
+
if (typeof options.size !== "number" || !Number.isFinite(options.size) || options.size <= 0) return undefined;
|
|
776
|
+
if (options.fit !== "cover" && options.fit !== "contain") return undefined;
|
|
777
|
+
if (typeof options.suffix !== "string" || !options.suffix.trim()) return undefined;
|
|
778
|
+
return { size: options.size, fit: options.fit, suffix: options.suffix.trim() };
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Nine argument draw geometry of one thumbnail: cover crops to the square and fits inside, contain letterboxes the whole source. */
|
|
782
|
+
export function thumbgeometry(
|
|
783
|
+
source: { width: number; height: number },
|
|
784
|
+
directive: thumbdirective,
|
|
785
|
+
): {
|
|
786
|
+
sx: number;
|
|
787
|
+
sy: number;
|
|
788
|
+
sw: number;
|
|
789
|
+
sh: number;
|
|
790
|
+
dx: number;
|
|
791
|
+
dy: number;
|
|
792
|
+
dw: number;
|
|
793
|
+
dh: number;
|
|
794
|
+
width: number;
|
|
795
|
+
height: number;
|
|
796
|
+
} {
|
|
797
|
+
const size = Math.max(1, Math.round(directive.size));
|
|
798
|
+
if (directive.fit === "contain") {
|
|
799
|
+
const scale = Math.min(size / Math.max(1, source.width), size / Math.max(1, source.height));
|
|
800
|
+
const dw = Math.max(1, Math.round(source.width * scale));
|
|
801
|
+
const dh = Math.max(1, Math.round(source.height * scale));
|
|
802
|
+
return {
|
|
803
|
+
sx: 0,
|
|
804
|
+
sy: 0,
|
|
805
|
+
sw: source.width,
|
|
806
|
+
sh: source.height,
|
|
807
|
+
dx: Math.floor((size - dw) / 2),
|
|
808
|
+
dy: Math.floor((size - dh) / 2),
|
|
809
|
+
dw,
|
|
810
|
+
dh,
|
|
811
|
+
width: size,
|
|
812
|
+
height: size,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
const scale = Math.max(size / Math.max(1, source.width), size / Math.max(1, source.height));
|
|
816
|
+
const sw = Math.min(source.width, Math.round(size / scale));
|
|
817
|
+
const sh = Math.min(source.height, Math.round(size / scale));
|
|
818
|
+
return {
|
|
819
|
+
sx: Math.floor((source.width - sw) / 2),
|
|
820
|
+
sy: Math.floor((source.height - sh) / 2),
|
|
821
|
+
sw,
|
|
822
|
+
sh,
|
|
823
|
+
dx: 0,
|
|
824
|
+
dy: 0,
|
|
825
|
+
dw: size,
|
|
826
|
+
dh: size,
|
|
827
|
+
width: size,
|
|
828
|
+
height: size,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Normalizes raw embedded media element entries into mediadatum records with duration, dimensions, codecs and track lists. */
|
|
833
|
+
export function mediaentries(raw: Array<Record<string, unknown>>): mediadatum[] {
|
|
834
|
+
return raw.map((entry) => ({
|
|
835
|
+
url: typeof entry.url === "string" ? entry.url : "",
|
|
836
|
+
mime: typeof entry.mime === "string" ? entry.mime : "",
|
|
837
|
+
duration: typeof entry.duration === "number" && Number.isFinite(entry.duration) ? entry.duration : 0,
|
|
838
|
+
width: typeof entry.width === "number" && Number.isFinite(entry.width) ? Math.round(entry.width) : 0,
|
|
839
|
+
height: typeof entry.height === "number" && Number.isFinite(entry.height) ? Math.round(entry.height) : 0,
|
|
840
|
+
codecs: typeof entry.codecs === "string" ? entry.codecs : "",
|
|
841
|
+
tracks: Array.isArray(entry.tracks) ? entry.tracks.filter((item) => typeof item === "string") : [],
|
|
842
|
+
}));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Normalizes raw page asset entries into favicon or logo asset shapes with their byte sizes and declared sizes. */
|
|
846
|
+
export function assetentries(
|
|
847
|
+
raw: Array<Record<string, unknown>>,
|
|
848
|
+
): Array<{ kind: assetrecord["kind"]; url: string; bytes: number; sizes?: string }> {
|
|
849
|
+
return raw.map((entry) => ({
|
|
850
|
+
kind: entry.kind === "logo" ? "logo" : "favicon",
|
|
851
|
+
url: typeof entry.url === "string" ? entry.url : "",
|
|
852
|
+
bytes: typeof entry.bytes === "number" && Number.isFinite(entry.bytes) ? entry.bytes : 0,
|
|
853
|
+
...(typeof entry.sizes === "string" && entry.sizes.trim() ? { sizes: entry.sizes.trim() } : {}),
|
|
854
|
+
}));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Normalizes raw stream probe entries into stream summaries with track counts, labels, live states and track details. */
|
|
858
|
+
export function streamsummaries(
|
|
859
|
+
raw: Array<Record<string, unknown>>,
|
|
860
|
+
): Array<Omit<streamrecord, "id" | "runid" | "stepid" | "at">> {
|
|
861
|
+
return raw.map((entry) => {
|
|
862
|
+
const tracks = Array.isArray(entry.tracks) ? entry.tracks : [];
|
|
863
|
+
return {
|
|
864
|
+
kind: typeof entry.kind === "string" ? entry.kind : "stream",
|
|
865
|
+
tracks: tracks.length,
|
|
866
|
+
label: typeof entry.label === "string" ? entry.label : "",
|
|
867
|
+
live: entry.live === true,
|
|
868
|
+
detail: tracks.map((track) => {
|
|
869
|
+
const item = track as Record<string, unknown>;
|
|
870
|
+
return {
|
|
871
|
+
kind: typeof item.kind === "string" ? item.kind : "",
|
|
872
|
+
label: typeof item.label === "string" ? item.label : "",
|
|
873
|
+
...(typeof item.width === "number" && Number.isFinite(item.width) ? { width: Math.round(item.width) } : {}),
|
|
874
|
+
...(typeof item.height === "number" && Number.isFinite(item.height)
|
|
875
|
+
? { height: Math.round(item.height) }
|
|
876
|
+
: {}),
|
|
877
|
+
...(typeof item.framerate === "number" && Number.isFinite(item.framerate)
|
|
878
|
+
? { framerate: item.framerate }
|
|
879
|
+
: {}),
|
|
880
|
+
state: typeof item.state === "string" ? item.state : "",
|
|
881
|
+
};
|
|
882
|
+
}),
|
|
883
|
+
};
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/* ── Merged from vision.ts ── */
|
|
888
|
+
|
|
889
|
+
import type {
|
|
890
|
+
framereference,
|
|
891
|
+
groundingmatch,
|
|
892
|
+
groundingresult,
|
|
893
|
+
ocrline,
|
|
894
|
+
ocrregion,
|
|
895
|
+
ocrresult,
|
|
896
|
+
ocrword,
|
|
897
|
+
redactionmask,
|
|
898
|
+
screenshotpair,
|
|
899
|
+
streamcursor,
|
|
900
|
+
visioncacheentry,
|
|
901
|
+
visiondescription,
|
|
902
|
+
} from "./types.js";
|
|
903
|
+
import { maskingfield } from "./security.js";
|
|
904
|
+
import { randomid } from "./memory.js";
|
|
905
|
+
import { bodyhashof } from "./http.js";
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Vision and ocr logic of the 1.1.77 family.
|
|
909
|
+
* Every correlated rule for teaching the agent to see lives in this one module: the imageocr recognition that reads the words with their boxes and confidences off a captured image and merges the word boxes into lines and paragraphs; the regionocr recognition of one reviewed ocrregion of a screenshot with its bounds clamped into the viewport and its word boxes offset to absolute coordinates; the pdfocr pass that rasterizes one scanned pdf page at a time through a rasterize seam with the streamcursor checkpointing after every page; the frameocr read that refuses a playing video, seeks a paused video to its reviewed position and captures the frame; the visionshot description that carries the reviewed prompt with the image to the configured vision model and returns a visiondescription with labeled regions; the cropshot geometry that crops a screenshot to element bounds scaled with device pixel ratio awareness; the redactshot masking that applies a redactionmask before any sharing, fills the masked regions and proposes masks for the common sensitive regions by field type; the groundshot grounding that scores candidate elements by text and geometry match and returns ranked selectors; the pairshot pairing that captures a dom snapshot beside every screenshot, aligns the pair by capture time and viewport size and answers queries by searching text in both image and dom; the ocrtext normalization into searchable text; the visioncost counting of the model calls per run for the costshare ledger; and the visioncache that stores recognition results by image hash so a repeated read serves without a model call.
|
|
910
|
+
* The module extends the reviewed families instead of duplicating them: the crop and scale geometry composes the capture rectangle rules of the 1.1.40 family, the sensitive field shapes come from the maskinputs recognizer the redactshots family already uses, the streamcursor checkpoint grammar comes from the pipeline family of the 1.1.75 release, and the image hash rides the same bodyhashof shape identity the web api cache of the 1.1.76 family keys its entries with.
|
|
911
|
+
* Every impure move stays behind an injected seam — the ocr read, the vision model send, the pdf rasterize, the video state read, the frame grab and the mask fill resolve through functions the executor wires — while every recognition result, description text and labeled region stays opaque reviewed text; no recognition payload enters any audit summary built from these results, and every retention, budget and model choice stays the user's with no code default — an absent value never hides a hardcoded ceiling, and no vision path ever bypasses a review.
|
|
912
|
+
*/
|
|
913
|
+
|
|
914
|
+
/** Ocr read seam: one captured image with its optional region resolves to the recognized words with their boxes and confidences; the extension executor wires the configured vision model provider, tests wire plain fixtures. */
|
|
915
|
+
export type ocrread = (image: string, region?: ocrregion) => Promise<ocrword[]>;
|
|
916
|
+
|
|
917
|
+
/** The vision kinds of the 1.1.77 family, listed among the available capabilities of every proposal request. */
|
|
918
|
+
export const visionkinds: string[] = [
|
|
919
|
+
"imageocr",
|
|
920
|
+
"regionocr",
|
|
921
|
+
"pdfocr",
|
|
922
|
+
"frameocr",
|
|
923
|
+
"visionshot",
|
|
924
|
+
"cropshot",
|
|
925
|
+
"redactshot",
|
|
926
|
+
"groundshot",
|
|
927
|
+
"pairshot",
|
|
928
|
+
];
|
|
929
|
+
|
|
930
|
+
/** Vision model seam: one reviewed vision request with its image and prompt resolves to the description text and its labeled regions; the extension executor wires the configured model endpoint, tests wire plain fixtures. */
|
|
931
|
+
export type visionsend = (request: {
|
|
932
|
+
imageid: string;
|
|
933
|
+
image: string;
|
|
934
|
+
prompt: string;
|
|
935
|
+
}) => Promise<{ text: string; regions: Array<{ label: string; box: ocrregion }> }>;
|
|
936
|
+
|
|
937
|
+
/** Pdf rasterize seam: one stored pdf document with its one based page number resolves to the rasterized page image; the extension executor wires the renderer backend, tests wire plain fixtures. */
|
|
938
|
+
export type pdfrasterize = (documentid: string, page: number) => Promise<string>;
|
|
939
|
+
|
|
940
|
+
/** Video state seam: one video selector resolves to its paused flag with the playback position and duration in milliseconds; the executor wires the page bridge, tests wire plain fixtures. */
|
|
941
|
+
export type videostateread = (selector: string) => Promise<{ paused: boolean; positionms: number; durationms: number }>;
|
|
942
|
+
|
|
943
|
+
/** Frame grab seam: one video selector at one reviewed position resolves to the captured frame image with its size; the executor wires the page bridge capture, tests wire plain fixtures. */
|
|
944
|
+
export type framegrab = (
|
|
945
|
+
selector: string,
|
|
946
|
+
positionms: number,
|
|
947
|
+
) => Promise<{ image: string; width: number; height: number }>;
|
|
948
|
+
|
|
949
|
+
/** Mask fill seam: one image with the regions to cover resolves to the image with the opaque rectangles drawn; the executor wires the capture canvas, tests wire plain fixtures. */
|
|
950
|
+
export type maskfill = (image: string, regions: ocrregion[]) => Promise<string>;
|
|
951
|
+
|
|
952
|
+
/** Reads the shared vertical height of two boxes relative to the shorter box, so the line merge groups words that visually sit on the same text row. */
|
|
953
|
+
function verticaloverlap(one: ocrregion, two: ocrregion): number {
|
|
954
|
+
const top = Math.max(one.y, two.y);
|
|
955
|
+
const bottom = Math.min(one.y + one.height, two.y + two.height);
|
|
956
|
+
const shared = Math.max(0, bottom - top);
|
|
957
|
+
const shorter = Math.min(one.height, two.height);
|
|
958
|
+
return shorter > 0 ? shared / shorter : 0;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** Builds the covering box of a set of boxes: the union rectangle of every region named. */
|
|
962
|
+
function boxof(regions: ocrregion[]): ocrregion {
|
|
963
|
+
const x = Math.min(...regions.map((region) => region.x));
|
|
964
|
+
const y = Math.min(...regions.map((region) => region.y));
|
|
965
|
+
const right = Math.max(...regions.map((region) => region.x + region.width));
|
|
966
|
+
const bottom = Math.max(...regions.map((region) => region.y + region.height));
|
|
967
|
+
return { x: Math.round(x), y: Math.round(y), width: Math.round(right - x), height: Math.round(bottom - y) };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Reads the intersection over union of two boxes, the geometry half of the grounding score. */
|
|
971
|
+
function iou(one: ocrregion, two: ocrregion): number {
|
|
972
|
+
const width = Math.max(0, Math.min(one.x + one.width, two.x + two.width) - Math.max(one.x, two.x));
|
|
973
|
+
const height = Math.max(0, Math.min(one.y + one.height, two.y + two.height) - Math.max(one.y, two.y));
|
|
974
|
+
const shared = width * height;
|
|
975
|
+
const union = one.width * one.height + two.width * two.height - shared;
|
|
976
|
+
return union > 0 ? shared / union : 0;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Splits a plain language value into its significant lowercase words of more than two characters. */
|
|
980
|
+
function words(value: string): string[] {
|
|
981
|
+
return value
|
|
982
|
+
.toLowerCase()
|
|
983
|
+
.split(/[^a-z0-9]+/)
|
|
984
|
+
.filter((word) => word.length > 2);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Normalizes recognition output into searchable text: whitespace runs fold into single spaces, the ends trim, and the folded text feeds the pairshot queries and the observation vision block. */
|
|
988
|
+
export function ocrtext(value: string): string {
|
|
989
|
+
return value.replace(/\s+/g, " ").trim();
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Merges the recognized word boxes into lines: two words join one line when their boxes share more than half of the shorter box's height, the line text joins the words left to right, the line box covers its words and the line confidence reports the lowest word confidence so a shaky word drags its line down instead of hiding inside an average — pure geometry with no configurable bound.
|
|
994
|
+
*/
|
|
995
|
+
export function mergelines(words: ocrword[]): ocrline[] {
|
|
996
|
+
const ordered = [...words].sort((one, two) => one.box.y - two.box.y || one.box.x - two.box.x);
|
|
997
|
+
const grouped: ocrword[][] = [];
|
|
998
|
+
for (const word of ordered) {
|
|
999
|
+
const line = grouped.find((candidate) => candidate.some((member) => verticaloverlap(member.box, word.box) > 0.5));
|
|
1000
|
+
if (line !== undefined) line.push(word);
|
|
1001
|
+
else grouped.push([word]);
|
|
1002
|
+
}
|
|
1003
|
+
return grouped.map((group) => {
|
|
1004
|
+
const sorted = [...group].sort((one, two) => one.box.x - two.box.x);
|
|
1005
|
+
return {
|
|
1006
|
+
text: sorted.map((word) => word.text).join(" "),
|
|
1007
|
+
box: boxof(sorted.map((word) => word.box)),
|
|
1008
|
+
words: sorted.length,
|
|
1009
|
+
confidence: Math.min(...sorted.map((word) => word.confidence)),
|
|
1010
|
+
};
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Merges the recognized lines into paragraphs: two consecutive lines join one paragraph while the vertical gap between them stays inside the shorter line's height, and the paragraph text joins its line texts with single spaces — pure geometry again, so the paragraph breaks answer the page's own spacing.
|
|
1016
|
+
*/
|
|
1017
|
+
export function mergeparagraphs(lines: ocrline[]): string[] {
|
|
1018
|
+
const ordered = [...lines].sort((one, two) => one.box.y - two.box.y || one.box.x - two.box.x);
|
|
1019
|
+
const paragraphs: ocrline[][] = [];
|
|
1020
|
+
for (const line of ordered) {
|
|
1021
|
+
const last = paragraphs[paragraphs.length - 1];
|
|
1022
|
+
if (last !== undefined) {
|
|
1023
|
+
const previous = last[last.length - 1]!;
|
|
1024
|
+
const gap = line.box.y - (previous.box.y + previous.box.height);
|
|
1025
|
+
if (gap <= Math.min(previous.box.height, line.box.height)) {
|
|
1026
|
+
last.push(line);
|
|
1027
|
+
continue;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
paragraphs.push([line]);
|
|
1031
|
+
}
|
|
1032
|
+
return paragraphs.map((paragraph) => ocrtext(paragraph.map((line) => line.text).join(" ")));
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Runs the recognition of one captured image through the ocr read seam: the seam returns the words with their boxes and confidences, the words merge into lines and paragraphs by their own geometry, and the result carries the full normalized text beside the word level evidence.
|
|
1037
|
+
*/
|
|
1038
|
+
export async function imageocr(input: {
|
|
1039
|
+
record: { runid: string; stepid: string; imageid: string };
|
|
1040
|
+
image: string;
|
|
1041
|
+
read: ocrread;
|
|
1042
|
+
id?: string;
|
|
1043
|
+
now: number;
|
|
1044
|
+
}): Promise<ocrresult> {
|
|
1045
|
+
if (input.image.trim() === "")
|
|
1046
|
+
throw new Error("The imageocr pass needs its captured image; an empty payload reads no pixels.");
|
|
1047
|
+
const words = await input.read(input.image);
|
|
1048
|
+
const lines = mergelines(words);
|
|
1049
|
+
const paragraphs = mergeparagraphs(lines);
|
|
1050
|
+
return {
|
|
1051
|
+
id: input.id ?? randomid(),
|
|
1052
|
+
runid: input.record.runid,
|
|
1053
|
+
stepid: input.record.stepid,
|
|
1054
|
+
imageid: input.record.imageid,
|
|
1055
|
+
text: ocrtext(lines.map((line) => line.text).join(" ")),
|
|
1056
|
+
words,
|
|
1057
|
+
lines,
|
|
1058
|
+
paragraphs,
|
|
1059
|
+
at: input.now,
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Runs the recognition of one reviewed ocrregion of a screenshot: the region clamps into the viewport so a region drawn past the edges reads only the visible part, a region fully outside the surface refuses loudly instead of reading wrong pixels, the read rides the region through the ocr seam, and the recognized boxes offset by the region origin so every word box lands in absolute screenshot coordinates.
|
|
1065
|
+
*/
|
|
1066
|
+
export async function regionocr(input: {
|
|
1067
|
+
record: { runid: string; stepid: string; imageid: string };
|
|
1068
|
+
image: string;
|
|
1069
|
+
region: ocrregion;
|
|
1070
|
+
viewport: { width: number; height: number };
|
|
1071
|
+
read: ocrread;
|
|
1072
|
+
id?: string;
|
|
1073
|
+
now: number;
|
|
1074
|
+
}): Promise<ocrresult> {
|
|
1075
|
+
const clamped = croprect(input.region, input.viewport);
|
|
1076
|
+
if (clamped.width <= 0 || clamped.height <= 0)
|
|
1077
|
+
throw new Error(
|
|
1078
|
+
`The ocrregion of x ${input.region.x}, y ${input.region.y}, ${input.region.width} by ${input.region.height} falls entirely outside the ${input.viewport.width} by ${input.viewport.height} viewport; the region read refuses wrong pixels.`,
|
|
1079
|
+
);
|
|
1080
|
+
const recognized = await input.read(input.image, clamped);
|
|
1081
|
+
const words: ocrword[] = recognized.map((word) => ({
|
|
1082
|
+
text: word.text,
|
|
1083
|
+
box: { x: word.box.x + clamped.x, y: word.box.y + clamped.y, width: word.box.width, height: word.box.height },
|
|
1084
|
+
confidence: word.confidence,
|
|
1085
|
+
}));
|
|
1086
|
+
const lines = mergelines(words);
|
|
1087
|
+
const paragraphs = mergeparagraphs(lines);
|
|
1088
|
+
return {
|
|
1089
|
+
id: input.id ?? randomid(),
|
|
1090
|
+
runid: input.record.runid,
|
|
1091
|
+
stepid: input.record.stepid,
|
|
1092
|
+
imageid: input.record.imageid,
|
|
1093
|
+
text: ocrtext(lines.map((line) => line.text).join(" ")),
|
|
1094
|
+
words,
|
|
1095
|
+
lines,
|
|
1096
|
+
paragraphs,
|
|
1097
|
+
at: input.now,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Reads one scanned pdf one page at a time through the rasterize and ocr seams: every page rasterizes, reads and lands its own ocrresult while the streamcursor checkpoints after each page, a cursor of another document refuses loudly because the pass continues exactly the document it came from, and the already read pages skip on a resume so nothing reads twice.
|
|
1103
|
+
*/
|
|
1104
|
+
export async function pdfocr(input: {
|
|
1105
|
+
record: { runid: string; stepid: string; documentid: string };
|
|
1106
|
+
pages: number;
|
|
1107
|
+
rasterize: pdfrasterize;
|
|
1108
|
+
read: ocrread;
|
|
1109
|
+
cursor?: streamcursor;
|
|
1110
|
+
checkpoint?: (cursor: streamcursor) => void;
|
|
1111
|
+
now: number;
|
|
1112
|
+
clock?: () => number;
|
|
1113
|
+
}): Promise<{ results: ocrresult[]; cursor: streamcursor; pagesread: number; skipped: number }> {
|
|
1114
|
+
const documentid = input.record.documentid;
|
|
1115
|
+
if (documentid.trim() === "")
|
|
1116
|
+
throw new Error("The pdfocr pass names its document; a scanned pdf carries its identity.");
|
|
1117
|
+
if (!(input.pages >= 1))
|
|
1118
|
+
throw new Error("The pdfocr pass reads at least one page; a pageless document reads nothing.");
|
|
1119
|
+
const clock = input.clock ?? (() => input.now);
|
|
1120
|
+
const start = input.cursor !== undefined && input.cursor.pipelineid === documentid ? input.cursor.offset : 0;
|
|
1121
|
+
const skipped = input.cursor !== undefined && input.cursor.pipelineid === documentid ? input.cursor.offset : 0;
|
|
1122
|
+
if (input.cursor !== undefined && input.cursor.pipelineid !== documentid)
|
|
1123
|
+
throw new Error(
|
|
1124
|
+
`The streamcursor of ${input.cursor.pipelineid} belongs to another document; the pdfocr pass continues exactly the document it came from.`,
|
|
1125
|
+
);
|
|
1126
|
+
const results: ocrresult[] = [];
|
|
1127
|
+
let cursor: streamcursor = {
|
|
1128
|
+
pipelineid: documentid,
|
|
1129
|
+
offset: start,
|
|
1130
|
+
chunk: input.cursor?.chunk ?? 0,
|
|
1131
|
+
updatedat: input.now,
|
|
1132
|
+
};
|
|
1133
|
+
for (let page = start + 1; page <= input.pages; page += 1) {
|
|
1134
|
+
const image = await input.rasterize(documentid, page);
|
|
1135
|
+
results.push(
|
|
1136
|
+
await imageocr({
|
|
1137
|
+
record: { runid: input.record.runid, stepid: input.record.stepid, imageid: `${documentid}:p${page}` },
|
|
1138
|
+
image,
|
|
1139
|
+
read: input.read,
|
|
1140
|
+
id: `${documentid}:p${page}`,
|
|
1141
|
+
now: clock(),
|
|
1142
|
+
}),
|
|
1143
|
+
);
|
|
1144
|
+
cursor = { pipelineid: documentid, offset: page, chunk: cursor.chunk + 1, updatedat: clock() };
|
|
1145
|
+
input.checkpoint?.(cursor);
|
|
1146
|
+
}
|
|
1147
|
+
return { results, cursor, pagesread: results.length, skipped };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Reads one video frame through the video state and frame grab seams: a playing video refuses loudly because the frame of a moving picture answers no question, a paused video seeks to its reviewed position in milliseconds and captures the frame, and the read rides the captured image through the ocr seam while the framereference records the position the run read.
|
|
1152
|
+
*/
|
|
1153
|
+
export async function frameocr(input: {
|
|
1154
|
+
record: { runid: string; stepid: string };
|
|
1155
|
+
frame: framereference;
|
|
1156
|
+
state: videostateread;
|
|
1157
|
+
grab: framegrab;
|
|
1158
|
+
read: ocrread;
|
|
1159
|
+
now: number;
|
|
1160
|
+
}): Promise<{ frame: framereference; result: ocrresult; image: string }> {
|
|
1161
|
+
const state = await input.state(input.frame.selector);
|
|
1162
|
+
if (!state.paused)
|
|
1163
|
+
throw new Error(
|
|
1164
|
+
`The video ${input.frame.selector} is playing at ${state.positionms} milliseconds; the frame read refuses a moving picture — pause the video first so the frame answers a real position.`,
|
|
1165
|
+
);
|
|
1166
|
+
if (input.frame.positionms < 0 || (state.durationms > 0 && input.frame.positionms > state.durationms))
|
|
1167
|
+
throw new Error(
|
|
1168
|
+
`The reviewed position of ${input.frame.positionms} milliseconds falls outside the video ${input.frame.selector} of ${state.durationms} milliseconds; the seek stays inside the video.`,
|
|
1169
|
+
);
|
|
1170
|
+
const grabbed = await input.grab(input.frame.selector, input.frame.positionms);
|
|
1171
|
+
const result = await imageocr({
|
|
1172
|
+
record: {
|
|
1173
|
+
runid: input.record.runid,
|
|
1174
|
+
stepid: input.record.stepid,
|
|
1175
|
+
imageid: `${input.frame.selector}@${input.frame.positionms}`,
|
|
1176
|
+
},
|
|
1177
|
+
image: grabbed.image,
|
|
1178
|
+
read: input.read,
|
|
1179
|
+
now: input.now,
|
|
1180
|
+
});
|
|
1181
|
+
return { frame: { ...input.frame, positionms: input.frame.positionms, at: input.now }, result, image: grabbed.image };
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Sends one screenshot with its reviewed prompt to the configured vision model through the vision send seam: an empty prompt refuses loudly because the model answers exactly what the review asked, the seam returns the description text with its labeled regions, and the description carries the prompt it answered so the audit reads both sides.
|
|
1186
|
+
*/
|
|
1187
|
+
export async function visionshot(input: {
|
|
1188
|
+
record: { runid: string; stepid: string; imageid: string };
|
|
1189
|
+
image: string;
|
|
1190
|
+
prompt: string;
|
|
1191
|
+
send: visionsend;
|
|
1192
|
+
id?: string;
|
|
1193
|
+
now: number;
|
|
1194
|
+
}): Promise<visiondescription> {
|
|
1195
|
+
if (input.prompt.trim() === "")
|
|
1196
|
+
throw new Error(
|
|
1197
|
+
"The visionshot prompt stays a non-empty reviewed string; the vision model answers exactly what the review asked.",
|
|
1198
|
+
);
|
|
1199
|
+
if (input.image.trim() === "")
|
|
1200
|
+
throw new Error("The visionshot pass needs its captured image; an empty payload describes no pixels.");
|
|
1201
|
+
const answer = await input.send({ imageid: input.record.imageid, image: input.image, prompt: input.prompt.trim() });
|
|
1202
|
+
return {
|
|
1203
|
+
id: input.id ?? randomid(),
|
|
1204
|
+
runid: input.record.runid,
|
|
1205
|
+
stepid: input.record.stepid,
|
|
1206
|
+
imageid: input.record.imageid,
|
|
1207
|
+
prompt: input.prompt.trim(),
|
|
1208
|
+
text: answer.text,
|
|
1209
|
+
regions: answer.regions,
|
|
1210
|
+
at: input.now,
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Computes the crop geometry of one element shot: the element bounds in css pixels scale by the device pixel ratio so the crop lands in image pixels, the scaled crop clamps into the captured image because an element past the edges crops only the visible part, and a bounds rectangle fully outside the image refuses loudly instead of cropping wrong pixels.
|
|
1216
|
+
*/
|
|
1217
|
+
export function cropshot(input: { image: { width: number; height: number }; bounds: ocrregion; pixelratio: number }): {
|
|
1218
|
+
cssbounds: ocrregion;
|
|
1219
|
+
crop: ocrregion;
|
|
1220
|
+
width: number;
|
|
1221
|
+
height: number;
|
|
1222
|
+
ratio: number;
|
|
1223
|
+
} {
|
|
1224
|
+
const device = scaledrect(input.bounds, input.pixelratio);
|
|
1225
|
+
const crop = croprect(device, input.image);
|
|
1226
|
+
if (crop.width <= 0 || crop.height <= 0)
|
|
1227
|
+
throw new Error(
|
|
1228
|
+
`The element bounds of x ${input.bounds.x}, y ${input.bounds.y}, ${input.bounds.width} by ${input.bounds.height} css pixels fall entirely outside the ${input.image.width} by ${input.image.height} pixel capture; the crop refuses wrong pixels.`,
|
|
1229
|
+
);
|
|
1230
|
+
return { cssbounds: input.bounds, crop, width: crop.width, height: crop.height, ratio: input.pixelratio };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Applies one redaction mask to a capture before any sharing through the mask fill seam: every region clamps into the capture so a mask past the edges covers only the real part, a mask whose every region falls outside the capture refuses loudly because a share that claims redaction while covering nothing leaks, the fill seam draws the opaque rectangles, and the returned summary records the mask in the audit trail with its reason.
|
|
1235
|
+
*/
|
|
1236
|
+
export async function redactshot(input: {
|
|
1237
|
+
image: string;
|
|
1238
|
+
width: number;
|
|
1239
|
+
height: number;
|
|
1240
|
+
mask: redactionmask;
|
|
1241
|
+
fill: maskfill;
|
|
1242
|
+
}): Promise<{ image: string; regions: number; reason: string; summary: string }> {
|
|
1243
|
+
if (input.mask.regions.length === 0)
|
|
1244
|
+
throw new Error("The redaction mask carries at least one region; an empty mask masks nothing.");
|
|
1245
|
+
const regions = input.mask.regions
|
|
1246
|
+
.map((region) => croprect(region, { width: input.width, height: input.height }))
|
|
1247
|
+
.filter((region) => region.width > 0 && region.height > 0);
|
|
1248
|
+
if (regions.length === 0)
|
|
1249
|
+
throw new Error(
|
|
1250
|
+
"Every region of the redaction mask falls outside the capture; the share refuses instead of leaking what the mask promised to cover.",
|
|
1251
|
+
);
|
|
1252
|
+
const image = await input.fill(input.image, regions);
|
|
1253
|
+
return {
|
|
1254
|
+
image,
|
|
1255
|
+
regions: regions.length,
|
|
1256
|
+
reason: input.mask.reason,
|
|
1257
|
+
summary: `The redaction mask covered ${regions.length} region${regions.length === 1 ? "" : "s"} of the capture before the share: ${input.mask.reason}`,
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Proposes redaction masks for the common sensitive regions by field type: every field whose name carries a sensitive field shape the maskinputs recognizer masks contributes one mask with its own reason, so the sidepanel offers exactly the masks the field types imply and the user edits them before any share.
|
|
1263
|
+
*/
|
|
1264
|
+
export function proposeredactionmasks(input: {
|
|
1265
|
+
runid: string;
|
|
1266
|
+
captureid: string;
|
|
1267
|
+
fields: Array<{ name: string; rect: ocrregion }>;
|
|
1268
|
+
now: number;
|
|
1269
|
+
}): redactionmask[] {
|
|
1270
|
+
const masks: redactionmask[] = [];
|
|
1271
|
+
for (const field of input.fields) {
|
|
1272
|
+
if (!maskingfield(field.name, [])) continue;
|
|
1273
|
+
masks.push({
|
|
1274
|
+
id: randomid(),
|
|
1275
|
+
runid: input.runid,
|
|
1276
|
+
captureid: input.captureid,
|
|
1277
|
+
regions: [field.rect],
|
|
1278
|
+
reason: `The ${field.name} field carries a sensitive field shape; the proposed mask covers it before any share.`,
|
|
1279
|
+
source: "fieldshape",
|
|
1280
|
+
at: input.now,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
return masks;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Grounds the labeled regions of one vision description into ranked page selectors: every candidate element scores by its text match with the label (the shared significant words over the label's words) and its geometry match with the region box (the intersection over union after the image pixel ratio converts the box into css pixels), the two halves average, and the matches rank by score so the top selector grounds the label; a candidate that shares neither words nor geometry never joins the ranking.
|
|
1288
|
+
*/
|
|
1289
|
+
export function groundshot(input: {
|
|
1290
|
+
description: visiondescription;
|
|
1291
|
+
elements: Array<{ selector: string; text: string; rect: ocrregion }>;
|
|
1292
|
+
pixelratio: number;
|
|
1293
|
+
record: { runid: string; stepid: string };
|
|
1294
|
+
id?: string;
|
|
1295
|
+
now: number;
|
|
1296
|
+
}): groundingresult {
|
|
1297
|
+
if (input.description.regions.length === 0)
|
|
1298
|
+
throw new Error("The grounding pass needs its labeled regions; a description without regions grounds nothing.");
|
|
1299
|
+
const matches: groundingmatch[] = [];
|
|
1300
|
+
const ratio = input.pixelratio >= 1 ? input.pixelratio : 1;
|
|
1301
|
+
for (const region of input.description.regions) {
|
|
1302
|
+
const cssbox = {
|
|
1303
|
+
x: region.box.x / ratio,
|
|
1304
|
+
y: region.box.y / ratio,
|
|
1305
|
+
width: region.box.width / ratio,
|
|
1306
|
+
height: region.box.height / ratio,
|
|
1307
|
+
};
|
|
1308
|
+
const labelwords = words(region.label);
|
|
1309
|
+
for (const element of input.elements) {
|
|
1310
|
+
const textscore =
|
|
1311
|
+
labelwords.length > 0
|
|
1312
|
+
? words(element.text).filter((word) => labelwords.includes(word)).length / labelwords.length
|
|
1313
|
+
: 0;
|
|
1314
|
+
const geometryscore = iou(cssbox, element.rect);
|
|
1315
|
+
const score = (textscore + geometryscore) / 2;
|
|
1316
|
+
if (score <= 0) continue;
|
|
1317
|
+
matches.push({ label: region.label, text: element.text, selector: element.selector, score });
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
matches.sort((one, two) => two.score - one.score || one.selector.localeCompare(two.selector));
|
|
1321
|
+
return {
|
|
1322
|
+
id: input.id ?? randomid(),
|
|
1323
|
+
runid: input.record.runid,
|
|
1324
|
+
stepid: input.record.stepid,
|
|
1325
|
+
descriptionid: input.description.id,
|
|
1326
|
+
matches,
|
|
1327
|
+
at: input.now,
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Pairs one screenshot with the dom snapshot captured beside it: only snapshots of the same viewport size align, the nearest capture time wins because the pair answers one moment of the page, and an image with no same viewport snapshot stays unpaired and names its refusal instead of pairing wrong pixels with wrong dom.
|
|
1333
|
+
*/
|
|
1334
|
+
export function pairshot(input: {
|
|
1335
|
+
runid: string;
|
|
1336
|
+
stepid: string;
|
|
1337
|
+
image: { id: string; capturetime: number; viewport: { width: number; height: number } };
|
|
1338
|
+
snapshots: Array<{ id: string; capturetime: number; viewport: { width: number; height: number } }>;
|
|
1339
|
+
id?: string;
|
|
1340
|
+
now: number;
|
|
1341
|
+
}): { pair?: screenshotpair; skipped?: "snapshot"; reason: string } {
|
|
1342
|
+
const candidates = input.snapshots.filter(
|
|
1343
|
+
(snapshot) =>
|
|
1344
|
+
snapshot.viewport.width === input.image.viewport.width &&
|
|
1345
|
+
snapshot.viewport.height === input.image.viewport.height,
|
|
1346
|
+
);
|
|
1347
|
+
if (candidates.length === 0)
|
|
1348
|
+
return {
|
|
1349
|
+
skipped: "snapshot",
|
|
1350
|
+
reason: `No dom snapshot of the ${input.image.viewport.width} by ${input.image.viewport.height} viewport exists; the pair of ${input.image.id} waits for a snapshot of the same viewport.`,
|
|
1351
|
+
};
|
|
1352
|
+
const nearest = candidates.reduce((best, candidate) =>
|
|
1353
|
+
Math.abs(candidate.capturetime - input.image.capturetime) < Math.abs(best.capturetime - input.image.capturetime)
|
|
1354
|
+
? candidate
|
|
1355
|
+
: best,
|
|
1356
|
+
);
|
|
1357
|
+
const delta = Math.abs(nearest.capturetime - input.image.capturetime);
|
|
1358
|
+
return {
|
|
1359
|
+
pair: {
|
|
1360
|
+
id: input.id ?? randomid(),
|
|
1361
|
+
runid: input.runid,
|
|
1362
|
+
stepid: input.stepid,
|
|
1363
|
+
imageid: input.image.id,
|
|
1364
|
+
domsnapshotid: nearest.id,
|
|
1365
|
+
capturetime: input.image.capturetime,
|
|
1366
|
+
viewport: input.image.viewport,
|
|
1367
|
+
at: input.now,
|
|
1368
|
+
},
|
|
1369
|
+
reason: `Paired the image ${input.image.id} with the dom snapshot ${nearest.id} captured ${delta} millisecond${delta === 1 ? "" : "s"} apart at the same ${input.image.viewport.width} by ${input.image.viewport.height} viewport.`,
|
|
1370
|
+
};
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Answers one query by searching the text of both sides of a pair: the normalized ocr text of the image and the dom text both fold their whitespace and lowercase before the needle search, every matching side reports an excerpt around its hit, and the summary names exactly which sides matched so a visual claim answers its page side.
|
|
1375
|
+
*/
|
|
1376
|
+
export function pairquery(input: { query: string; ocrtextvalue: string; domtext: string }): {
|
|
1377
|
+
matches: Array<{ source: "image" | "dom"; excerpt: string }>;
|
|
1378
|
+
summary: string;
|
|
1379
|
+
} {
|
|
1380
|
+
const needle = input.query.trim().toLowerCase();
|
|
1381
|
+
if (needle === "")
|
|
1382
|
+
throw new Error("The pair query stays a non-empty string; an empty query matches every side of the pair.");
|
|
1383
|
+
const image = ocrtext(input.ocrtextvalue).toLowerCase();
|
|
1384
|
+
const dom = ocrtext(input.domtext).toLowerCase();
|
|
1385
|
+
const matches: Array<{ source: "image" | "dom"; excerpt: string }> = [];
|
|
1386
|
+
if (image.includes(needle)) matches.push({ source: "image", excerpt: excerptof(image, needle) });
|
|
1387
|
+
if (dom.includes(needle)) matches.push({ source: "dom", excerpt: excerptof(dom, needle) });
|
|
1388
|
+
return {
|
|
1389
|
+
matches,
|
|
1390
|
+
summary: `The query ${input.query.trim()} matched ${matches.length} side${matches.length === 1 ? "" : "s"} of the pair${matches.length > 0 ? `: ${matches.map((match) => match.source).join(" and ")}` : "; neither the image text nor the dom text carries it"}.`,
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** Reads the excerpt around one hit: up to forty characters of context on both sides of the needle so the match reads in place. */
|
|
1395
|
+
function excerptof(value: string, needle: string): string {
|
|
1396
|
+
const index = value.indexOf(needle);
|
|
1397
|
+
if (index < 0) return "";
|
|
1398
|
+
const start = Math.max(0, index - 40);
|
|
1399
|
+
const end = Math.min(value.length, index + needle.length + 40);
|
|
1400
|
+
return `${start > 0 ? "…" : ""}${value.slice(start, end)}${end < value.length ? "…" : ""}`;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Counts the vision calls of one run for the costshare ledger: every call records whether it rode the configured model endpoint, only the model calls count units because the local passes spend nothing, and the description names the split so the ledger entry reads exactly what the vision family spent.
|
|
1405
|
+
*/
|
|
1406
|
+
export function visioncost(input: { runid: string; calls: Array<{ kind: string; model: boolean; at: number }> }): {
|
|
1407
|
+
calls: number;
|
|
1408
|
+
modelcalls: number;
|
|
1409
|
+
units: number;
|
|
1410
|
+
description: string;
|
|
1411
|
+
} {
|
|
1412
|
+
const modelcalls = input.calls.filter((call) => call.model).length;
|
|
1413
|
+
return {
|
|
1414
|
+
calls: input.calls.length,
|
|
1415
|
+
modelcalls,
|
|
1416
|
+
units: modelcalls,
|
|
1417
|
+
description: `The vision family made ${input.calls.length} call${input.calls.length === 1 ? "" : "s"} of the run ${input.runid} with ${modelcalls} riding the configured model endpoint; every unit stays local to the costshare ledger.`,
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Reads the hash of one image payload for the visioncache keys: the same bodyhashof shape identity the web api cache keys its entries with, never the pixel values. */
|
|
1422
|
+
export function imagehashof(image: string): string {
|
|
1423
|
+
return `img:${bodyhashof(image)}`;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Stores one recognition or description in the visioncache by its image hash: a newer entry of the same hash replaces the older one, and the cache never grows a duplicate because one image hash answers one recognition.
|
|
1428
|
+
*/
|
|
1429
|
+
export function visioncacheput(input: { entries: visioncacheentry[]; entry: visioncacheentry }): visioncacheentry[] {
|
|
1430
|
+
return [input.entry, ...input.entries.filter((candidate) => candidate.hash !== input.entry.hash)];
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* Serves one visioncache hit by the image hash and kind: entries older than the user retention window drop first because the expiry ties to the user choice with no code default, a live entry of the same hash and kind serves with its hit counter bumped so the repeated read never calls the model again, and the pass reports exactly what it expired.
|
|
1435
|
+
*/
|
|
1436
|
+
export function visioncacheserve(input: {
|
|
1437
|
+
entries: visioncacheentry[];
|
|
1438
|
+
hash: string;
|
|
1439
|
+
kind: "ocr" | "vision";
|
|
1440
|
+
now: number;
|
|
1441
|
+
retention?: number;
|
|
1442
|
+
}): { entry?: visioncacheentry; entries: visioncacheentry[]; expired: number } {
|
|
1443
|
+
const live = input.entries.filter((entry) => input.retention === undefined || input.now - entry.at < input.retention);
|
|
1444
|
+
const expired = input.entries.length - live.length;
|
|
1445
|
+
const found = live.find((entry) => entry.hash === input.hash && entry.kind === input.kind);
|
|
1446
|
+
if (found === undefined) return { entries: live, expired };
|
|
1447
|
+
const bumped = { ...found, hits: found.hits + 1 };
|
|
1448
|
+
return {
|
|
1449
|
+
entry: bumped,
|
|
1450
|
+
entries: live.map((entry) => (entry.hash === found.hash && entry.kind === found.kind ? bumped : entry)),
|
|
1451
|
+
expired,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/* ── Merged from redactshots.ts ── */
|
|
1456
|
+
|
|
1457
|
+
import type { capturesurface, redactregion, toolstep } from "./types.js";
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Redactshots logic of the 1.1.62 family.
|
|
1461
|
+
* The capture masking rules live here: sensitive regions of the capture surface — derived from the field shapes the recognizer masks or drawn by the user — cover viewport, element and stitched captures alike, so the stored bytes never carry the sensitive regions while the record keeps the redaction evidence and the reason.
|
|
1462
|
+
* The regions scope to one origin and one page template, and no geometry, region count or shape list is ever hardcoded: every region stays the user's knowledge, mirroring the maskinputs posture.
|
|
1463
|
+
*/
|
|
1464
|
+
|
|
1465
|
+
/** Builds one redact region with its geometry in css pixels, its plain language reason and its source; the geometry stays finite and positive so the capture seam draws a real rectangle. */
|
|
1466
|
+
export function regionof(input: {
|
|
1467
|
+
origin: string;
|
|
1468
|
+
template: string;
|
|
1469
|
+
x: number;
|
|
1470
|
+
y: number;
|
|
1471
|
+
width: number;
|
|
1472
|
+
height: number;
|
|
1473
|
+
reason: string;
|
|
1474
|
+
source: "fieldshape" | "userdrawn";
|
|
1475
|
+
now: number;
|
|
1476
|
+
id?: string;
|
|
1477
|
+
}): redactregion {
|
|
1478
|
+
if (input.origin.trim() === "" || input.template.trim() === "")
|
|
1479
|
+
throw new Error("The redact region needs its origin and its page template.");
|
|
1480
|
+
for (const value of [input.x, input.y, input.width, input.height]) {
|
|
1481
|
+
if (!Number.isFinite(value) || value < 0)
|
|
1482
|
+
throw new Error("The redact region needs finite, non-negative geometry in css pixels.");
|
|
1483
|
+
}
|
|
1484
|
+
if (input.width <= 0 || input.height <= 0)
|
|
1485
|
+
throw new Error("The redact region needs a positive width and height so the mask covers a real area.");
|
|
1486
|
+
if (input.reason.trim() === "") throw new Error("The redact region names its reason in plain language.");
|
|
1487
|
+
return {
|
|
1488
|
+
id: input.id ?? randomid(),
|
|
1489
|
+
origin: input.origin.trim(),
|
|
1490
|
+
template: input.template.trim(),
|
|
1491
|
+
x: input.x,
|
|
1492
|
+
y: input.y,
|
|
1493
|
+
width: input.width,
|
|
1494
|
+
height: input.height,
|
|
1495
|
+
reason: input.reason.trim(),
|
|
1496
|
+
source: input.source,
|
|
1497
|
+
createdat: input.now,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/** Reads whether one region keeps finite, positive geometry the capture seam can draw. */
|
|
1502
|
+
export function regionvalid(region: redactregion): boolean {
|
|
1503
|
+
return (
|
|
1504
|
+
Number.isFinite(region.x) &&
|
|
1505
|
+
Number.isFinite(region.y) &&
|
|
1506
|
+
Number.isFinite(region.width) &&
|
|
1507
|
+
Number.isFinite(region.height) &&
|
|
1508
|
+
region.width > 0 &&
|
|
1509
|
+
region.height > 0
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Reads the regions of one origin and page template: the regions bind to the page they were drawn on and never leak onto another origin or template. */
|
|
1514
|
+
export function regionsfor(regions: redactregion[], origin: string, template: string): redactregion[] {
|
|
1515
|
+
return regions.filter((region) => region.origin === origin && region.template === template);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/** Derives redact regions from the sensitive field shapes of one form layout: every field whose name matches a masked shape contributes its rectangle, so the capture masks exactly what the recognizer would mask. */
|
|
1519
|
+
export function fieldshaperegions(input: {
|
|
1520
|
+
origin: string;
|
|
1521
|
+
template: string;
|
|
1522
|
+
fields: Array<{ name: string; rect: { x: number; y: number; width: number; height: number } }>;
|
|
1523
|
+
now: number;
|
|
1524
|
+
}): redactregion[] {
|
|
1525
|
+
const regions: redactregion[] = [];
|
|
1526
|
+
for (const field of input.fields) {
|
|
1527
|
+
if (!maskingfield(field.name, [])) continue;
|
|
1528
|
+
regions.push(
|
|
1529
|
+
regionof({
|
|
1530
|
+
origin: input.origin,
|
|
1531
|
+
template: input.template,
|
|
1532
|
+
x: field.rect.x,
|
|
1533
|
+
y: field.rect.y,
|
|
1534
|
+
width: field.rect.width,
|
|
1535
|
+
height: field.rect.height,
|
|
1536
|
+
reason: `The ${field.name} field carries a sensitive field shape the recognizer masks.`,
|
|
1537
|
+
source: "fieldshape",
|
|
1538
|
+
now: input.now,
|
|
1539
|
+
}),
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
return regions;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** Merges drawn regions into the stored set: a region of the same origin, template and geometry keeps its first record while a new geometry joins the list. */
|
|
1546
|
+
export function mergeregions(existing: redactregion[], added: redactregion[]): redactregion[] {
|
|
1547
|
+
const merged = [...existing];
|
|
1548
|
+
for (const region of added) {
|
|
1549
|
+
if (
|
|
1550
|
+
merged.some(
|
|
1551
|
+
(candidate) =>
|
|
1552
|
+
candidate.origin === region.origin &&
|
|
1553
|
+
candidate.template === region.template &&
|
|
1554
|
+
candidate.x === region.x &&
|
|
1555
|
+
candidate.y === region.y &&
|
|
1556
|
+
candidate.width === region.width &&
|
|
1557
|
+
candidate.height === region.height,
|
|
1558
|
+
)
|
|
1559
|
+
)
|
|
1560
|
+
continue;
|
|
1561
|
+
merged.push(region);
|
|
1562
|
+
}
|
|
1563
|
+
return merged;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/** Maps one capture kind to its redactshots surface: viewport captures, element captures and stitched captures all carry the same region grammar. */
|
|
1567
|
+
export function capturesurfaceof(kind: string): capturesurface {
|
|
1568
|
+
if (kind === "element" || kind === "elementshot") return "element";
|
|
1569
|
+
if (
|
|
1570
|
+
kind === "stitched" ||
|
|
1571
|
+
kind === "fullpage" ||
|
|
1572
|
+
kind === "shotfullpage" ||
|
|
1573
|
+
kind === "stitch" ||
|
|
1574
|
+
kind === "contactsheet" ||
|
|
1575
|
+
kind === "timelapse" ||
|
|
1576
|
+
kind === "recordscreen"
|
|
1577
|
+
)
|
|
1578
|
+
return "stitched";
|
|
1579
|
+
return "viewport";
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** Reads the template name of one capture step: the reviewed option template the user named, or the step kind itself as the page template baseline. */
|
|
1583
|
+
export function templateof(step: Pick<toolstep, "kind" | "options">): string {
|
|
1584
|
+
if (step.options) {
|
|
1585
|
+
try {
|
|
1586
|
+
const parsed = JSON.parse(step.options) as unknown;
|
|
1587
|
+
if (Boolean(parsed) && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1588
|
+
const template = (parsed as Record<string, unknown>).template;
|
|
1589
|
+
if (typeof template === "string" && template.trim() !== "") return template.trim();
|
|
1590
|
+
}
|
|
1591
|
+
} catch {
|
|
1592
|
+
/* an options payload outside the json grammar falls back to the step kind */
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return step.kind;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** Applies the redact regions to one stored capture record: the record keeps the redaction evidence — the flag and the region count — while the capture seam draws the opaque rectangles before the bytes reach storage, across every capture kind. */
|
|
1599
|
+
export function redactedshot<T extends { kind: string }>(
|
|
1600
|
+
record: T,
|
|
1601
|
+
regions: redactregion[],
|
|
1602
|
+
): T & { redacted?: boolean; redactedregions?: number } {
|
|
1603
|
+
if (regions.length === 0) return record;
|
|
1604
|
+
return { ...record, redacted: true, redactedregions: regions.length };
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/** Builds the redaction evidence summary the audit trail records: the surface, the region count and the reason of every region. */
|
|
1608
|
+
export function redactionsummary(regions: redactregion[]): string {
|
|
1609
|
+
if (regions.length === 0)
|
|
1610
|
+
return "No redact region covered the capture; the stored bytes carry everything the surface saw.";
|
|
1611
|
+
const sources = { fieldshape: 0, userdrawn: 0 };
|
|
1612
|
+
for (const region of regions) sources[region.source] += 1;
|
|
1613
|
+
return `${regions.length} redact region${regions.length === 1 ? "" : "s"} covered the capture before storage: ${sources.fieldshape} derived from sensitive field shapes and ${sources.userdrawn} drawn by the user (${regions.map((region) => region.reason).join("; ")}).`;
|
|
1614
|
+
}
|