lyra-browser 0.1.2__py3-none-any.whl
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.
- lyra_browser/__init__.py +10 -0
- lyra_browser/__main__.py +85 -0
- lyra_browser/approval.py +82 -0
- lyra_browser/audit.py +80 -0
- lyra_browser/capture.py +129 -0
- lyra_browser/cdp_guard.py +752 -0
- lyra_browser/cdp_socket.py +285 -0
- lyra_browser/challenge.py +144 -0
- lyra_browser/config.py +624 -0
- lyra_browser/consent.py +416 -0
- lyra_browser/context.py +219 -0
- lyra_browser/dialogs.py +276 -0
- lyra_browser/downloads.py +716 -0
- lyra_browser/enforcement.py +707 -0
- lyra_browser/origin.py +137 -0
- lyra_browser/permission.py +297 -0
- lyra_browser/server.py +169 -0
- lyra_browser/session.py +1009 -0
- lyra_browser/tools/__init__.py +34 -0
- lyra_browser/tools/actionable.py +453 -0
- lyra_browser/tools/aftermath.py +136 -0
- lyra_browser/tools/collaboration.py +180 -0
- lyra_browser/tools/dialogs.py +98 -0
- lyra_browser/tools/downloads.py +37 -0
- lyra_browser/tools/forms.py +990 -0
- lyra_browser/tools/interaction.py +655 -0
- lyra_browser/tools/navigation.py +412 -0
- lyra_browser/tools/reading.py +608 -0
- lyra_browser/tools/scope.py +100 -0
- lyra_browser/tools/tabs.py +165 -0
- lyra_browser/tools/uat.py +146 -0
- lyra_browser/tools/waiting.py +225 -0
- lyra_browser/uat/__init__.py +24 -0
- lyra_browser/uat/__main__.py +11 -0
- lyra_browser/uat/batch.py +397 -0
- lyra_browser/uat/brains/__init__.py +42 -0
- lyra_browser/uat/brains/anthropic_api.py +184 -0
- lyra_browser/uat/brains/base.py +129 -0
- lyra_browser/uat/brains/claude_code.py +156 -0
- lyra_browser/uat/brains/codex.py +175 -0
- lyra_browser/uat/brains/harness.py +165 -0
- lyra_browser/uat/brains/openai_compat.py +222 -0
- lyra_browser/uat/brains/scripted.py +43 -0
- lyra_browser/uat/cli.py +251 -0
- lyra_browser/uat/demo_site.py +154 -0
- lyra_browser/uat/observe.py +196 -0
- lyra_browser/uat/procs.py +132 -0
- lyra_browser/uat/prompt.py +148 -0
- lyra_browser/uat/recorder.py +460 -0
- lyra_browser/uat/report.py +641 -0
- lyra_browser/uat/runner.py +223 -0
- lyra_browser/uat/server.py +236 -0
- lyra_browser/uat/spec.py +477 -0
- lyra_browser/vision.py +58 -0
- lyra_browser-0.1.2.dist-info/METADATA +975 -0
- lyra_browser-0.1.2.dist-info/RECORD +59 -0
- lyra_browser-0.1.2.dist-info/WHEEL +4 -0
- lyra_browser-0.1.2.dist-info/entry_points.txt +3 -0
- lyra_browser-0.1.2.dist-info/licenses/LICENSE +21 -0
lyra_browser/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""lyra-browser — a headful, collaborative browser MCP server for VEGA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
|
|
7
|
+
from .config import Config
|
|
8
|
+
from .server import build_server
|
|
9
|
+
|
|
10
|
+
__all__ = ["Config", "build_server", "__version__"]
|
lyra_browser/__main__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""CLI entry point: ``lyra-browser`` launches the MCP server over stdio.
|
|
2
|
+
|
|
3
|
+
VEGA (mcp.json) and Hermes (config.yaml ``mcp_servers``) spawn this as a stdio
|
|
4
|
+
MCP server. Pass ``--http`` to instead serve over Streamable HTTP for
|
|
5
|
+
remote/dev use.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .config import CLIENTS, Config
|
|
14
|
+
from .server import build_server
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="lyra-browser", description=__doc__)
|
|
19
|
+
parser.add_argument("--http", action="store_true", help="Serve over HTTP instead of stdio.")
|
|
20
|
+
parser.add_argument("--host", default="127.0.0.1", help="HTTP host (with --http).")
|
|
21
|
+
parser.add_argument("--port", type=int, default=8765, help="HTTP port (with --http).")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--headless",
|
|
24
|
+
action=argparse.BooleanOptionalAction,
|
|
25
|
+
default=None,
|
|
26
|
+
help=(
|
|
27
|
+
"Run without a visible window. Nobody can watch or take over, so the "
|
|
28
|
+
"collaboration tools refuse with 'unattended'. Omit to follow "
|
|
29
|
+
"LYRA_BROWSER_HEADLESS, else the client's default (headless for "
|
|
30
|
+
"hermes, and on Linux with no display)."
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--client",
|
|
35
|
+
choices=sorted(CLIENTS),
|
|
36
|
+
default=None,
|
|
37
|
+
help=(
|
|
38
|
+
"The agent harness spawning this server. Picks where the profile and "
|
|
39
|
+
"captures live and whether a window is expected. Omit to follow "
|
|
40
|
+
"LYRA_BROWSER_CLIENT, else detect (VEGA_DATA_DIR -> vega, "
|
|
41
|
+
"HERMES_HOME -> hermes)."
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--uat-run",
|
|
46
|
+
type=Path,
|
|
47
|
+
default=None,
|
|
48
|
+
metavar="RUN_JSON",
|
|
49
|
+
help=(
|
|
50
|
+
"Serve one UAT persona run over stdio: the run.json that `lyra-uat run` "
|
|
51
|
+
"wrote. Configuration comes from that file, not from the environment or "
|
|
52
|
+
"the other flags. Used by harness brains (Claude Code, Codex)."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
return parser
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def config_from_args(args: argparse.Namespace) -> Config:
|
|
59
|
+
"""Build the runtime config: environment first, explicit flags on top.
|
|
60
|
+
|
|
61
|
+
A flag beats the environment so a developer can force either mode without
|
|
62
|
+
editing the VEGA install's mcp.json env block.
|
|
63
|
+
"""
|
|
64
|
+
cfg = Config.from_env(client=args.client)
|
|
65
|
+
if args.headless is not None:
|
|
66
|
+
cfg.headless = args.headless
|
|
67
|
+
return cfg
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> None:
|
|
71
|
+
args = build_parser().parse_args()
|
|
72
|
+
if args.uat_run is not None:
|
|
73
|
+
from .uat.server import serve_stdio
|
|
74
|
+
|
|
75
|
+
serve_stdio(args.uat_run)
|
|
76
|
+
return
|
|
77
|
+
mcp = build_server(config_from_args(args))
|
|
78
|
+
if args.http:
|
|
79
|
+
mcp.run(transport="http", host=args.host, port=args.port)
|
|
80
|
+
else:
|
|
81
|
+
mcp.run()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
lyra_browser/approval.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Risk gating and collaboration state for human-in-the-loop control.
|
|
2
|
+
|
|
3
|
+
Two mechanisms live here:
|
|
4
|
+
|
|
5
|
+
1. **Risk gate** — high-impact actions (navigating to a new origin, submitting a
|
|
6
|
+
form, clicking a destructive control) require an explicit ``confirm=True`` when
|
|
7
|
+
``Config.require_approval`` is on. A gated call returns a structured
|
|
8
|
+
``needs_approval`` envelope instead of acting, so VEGA can ask the user first.
|
|
9
|
+
|
|
10
|
+
2. **Takeover state** — the user can grab the wheel. While a takeover is active,
|
|
11
|
+
agent-driven mutation tools refuse to act and tell the model to wait.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApprovalRequired(Exception):
|
|
20
|
+
"""Raised by gated tools when the user has not yet confirmed the action."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, action: str, reason: str, asked: bool = False) -> None:
|
|
23
|
+
super().__init__(reason)
|
|
24
|
+
self.action = action
|
|
25
|
+
self.reason = reason
|
|
26
|
+
self.asked = asked
|
|
27
|
+
|
|
28
|
+
def envelope(self) -> dict[str, str]:
|
|
29
|
+
if self.asked:
|
|
30
|
+
# The client put the question to a person and this is their answer.
|
|
31
|
+
# "Re-call with confirm=true" would only ask them again — on Hermes
|
|
32
|
+
# that is another approval prompt in their chat — so say so.
|
|
33
|
+
hint = (
|
|
34
|
+
"The user was asked and did not approve. Do not re-call to get "
|
|
35
|
+
"past this; tell them it was refused and retry only if they ask."
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
hint = "Re-call with confirm=true to proceed once the user agrees."
|
|
39
|
+
return {
|
|
40
|
+
"status": "needs_approval",
|
|
41
|
+
"action": self.action,
|
|
42
|
+
"reason": self.reason,
|
|
43
|
+
"hint": hint,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TakeoverActive(Exception):
|
|
48
|
+
"""Raised when the user holds the session and the agent tries to mutate it."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, reason: str) -> None:
|
|
51
|
+
super().__init__(reason)
|
|
52
|
+
self.reason = reason
|
|
53
|
+
|
|
54
|
+
def envelope(self) -> dict[str, str]:
|
|
55
|
+
return {
|
|
56
|
+
"status": "takeover_active",
|
|
57
|
+
"reason": self.reason,
|
|
58
|
+
"hint": "The user is driving. Call resume_after_takeover when they hand back.",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(slots=True)
|
|
63
|
+
class CollaborationState:
|
|
64
|
+
"""Shared, in-process control state between the agent and the user."""
|
|
65
|
+
|
|
66
|
+
require_approval: bool = True
|
|
67
|
+
takeover: bool = False
|
|
68
|
+
takeover_reason: str = ""
|
|
69
|
+
|
|
70
|
+
def gate(self, action: str, reason: str, confirm: bool) -> None:
|
|
71
|
+
"""Enforce the risk gate for a high-impact action.
|
|
72
|
+
|
|
73
|
+
Raises ApprovalRequired unless approval is disabled or confirm is True.
|
|
74
|
+
"""
|
|
75
|
+
if not self.require_approval or confirm:
|
|
76
|
+
return
|
|
77
|
+
raise ApprovalRequired(action, reason)
|
|
78
|
+
|
|
79
|
+
def assert_agent_may_act(self) -> None:
|
|
80
|
+
"""Block agent-driven mutations while the user holds a takeover."""
|
|
81
|
+
if self.takeover:
|
|
82
|
+
raise TakeoverActive(self.takeover_reason or "User has taken over the session.")
|
lyra_browser/audit.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Append-only audit trail.
|
|
2
|
+
|
|
3
|
+
Every tool invocation is recorded as one JSON line so a user can review exactly
|
|
4
|
+
what the agent did in their browser. This is a hard requirement for a tool that
|
|
5
|
+
acts inside a session sharing the user's logins.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuditLog:
|
|
18
|
+
def __init__(self, path: Path, *, mirror_stderr: bool = False) -> None:
|
|
19
|
+
self._path = path
|
|
20
|
+
# Each record is also written, unprefixed, as one JSON line on stderr —
|
|
21
|
+
# for hosts whose disk is ephemeral but whose stderr is collected.
|
|
22
|
+
self._mirror_stderr = mirror_stderr
|
|
23
|
+
|
|
24
|
+
def record(
|
|
25
|
+
self,
|
|
26
|
+
tool: str,
|
|
27
|
+
args: dict[str, Any] | None = None,
|
|
28
|
+
status: str = "ok",
|
|
29
|
+
detail: str | None = None,
|
|
30
|
+
origin: str | None = None,
|
|
31
|
+
initiator: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Append a single audit entry. Best-effort: never raises into a tool call.
|
|
34
|
+
|
|
35
|
+
``origin`` is where the action points and ``initiator`` is where it came
|
|
36
|
+
from. Reconstructing an incident needs both: a request to a bank is
|
|
37
|
+
ordinary from the bank's own page and alarming from anywhere else.
|
|
38
|
+
"""
|
|
39
|
+
entry: dict[str, Any] = {
|
|
40
|
+
"ts": datetime.now(UTC).isoformat(),
|
|
41
|
+
"tool": tool,
|
|
42
|
+
"args": _redact(args or {}),
|
|
43
|
+
"status": status,
|
|
44
|
+
"detail": detail,
|
|
45
|
+
}
|
|
46
|
+
# Only present when known, so existing entries keep their shape.
|
|
47
|
+
if origin is not None:
|
|
48
|
+
entry["origin"] = origin
|
|
49
|
+
if initiator is not None:
|
|
50
|
+
entry["initiator"] = initiator
|
|
51
|
+
line = json.dumps(entry, ensure_ascii=False)
|
|
52
|
+
try:
|
|
53
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with self._path.open("a", encoding="utf-8") as fh:
|
|
55
|
+
fh.write(line + "\n")
|
|
56
|
+
except OSError:
|
|
57
|
+
# Auditing must not break the agent loop; surfacing happens elsewhere.
|
|
58
|
+
pass
|
|
59
|
+
if self._mirror_stderr:
|
|
60
|
+
try:
|
|
61
|
+
print(line, file=sys.stderr, flush=True)
|
|
62
|
+
except (OSError, ValueError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Argument keys whose values should never hit the on-disk log verbatim.
|
|
67
|
+
# ``post_data`` is here because the enforcement layer sees raw form bodies, and
|
|
68
|
+
# that is exactly where a submitted password lives.
|
|
69
|
+
_SENSITIVE_KEYS = {"password", "secret", "token", "value", "post_data"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _redact(args: dict[str, Any]) -> dict[str, Any]:
|
|
73
|
+
"""Redact values that may carry credentials typed into form fields."""
|
|
74
|
+
out: dict[str, Any] = {}
|
|
75
|
+
for key, val in args.items():
|
|
76
|
+
if key.lower() in _SENSITIVE_KEYS and isinstance(val, str):
|
|
77
|
+
out[key] = f"<redacted:{len(val)} chars>"
|
|
78
|
+
else:
|
|
79
|
+
out[key] = val
|
|
80
|
+
return out
|
lyra_browser/capture.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Captures: screenshots and element images written to disk for the model.
|
|
2
|
+
|
|
3
|
+
A capture is handed over as a *file*, not as base64 inside the tool result.
|
|
4
|
+
Measured on VEGA: the client concatenates the text blocks of a tool result and
|
|
5
|
+
shows the model that string, so a base64 PNG arrives as tens of thousands of
|
|
6
|
+
tokens of prose and no picture. VEGA does have an image path — a local file
|
|
7
|
+
under its uploads root is attached to the next model turn as an image block —
|
|
8
|
+
and the in-app browser runs on the same machine, so a file on disk is the one
|
|
9
|
+
handoff both surfaces can use without a protocol in between.
|
|
10
|
+
|
|
11
|
+
Files are named so they sort by time, written atomically so a reader never
|
|
12
|
+
sees a partial PNG, and pruned oldest-first so a long session cannot fill the
|
|
13
|
+
disk one screenshot per turn.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import os
|
|
20
|
+
import struct
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .config import Config
|
|
27
|
+
|
|
28
|
+
MIME_PNG = "image/png"
|
|
29
|
+
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
30
|
+
|
|
31
|
+
# Chrome answers Page.captureScreenshot with this while the renderer has not
|
|
32
|
+
# presented a frame yet. Measured with Chrome 152 headless on a fresh context:
|
|
33
|
+
# the first capture after a navigation to example.com fails, and the same call
|
|
34
|
+
# ~1s later succeeds. It is a transient, not a broken page, so it is retried.
|
|
35
|
+
_NO_FRAME_YET = "Unable to capture screenshot"
|
|
36
|
+
_RETRY_DELAY_S = 0.15
|
|
37
|
+
_RETRY_BUDGET_S = 3.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def take(shoot: Callable[[], Awaitable[bytes]]) -> bytes:
|
|
41
|
+
"""Run ``shoot`` and retry the no-frame-yet transient within a small budget.
|
|
42
|
+
|
|
43
|
+
Any other failure — element detached, page closed — is raised as it is;
|
|
44
|
+
only the one message Chrome uses for "nothing painted yet" is retried, and
|
|
45
|
+
even that gives up after ``_RETRY_BUDGET_S`` so a page that never paints
|
|
46
|
+
still errors instead of hanging the tool.
|
|
47
|
+
"""
|
|
48
|
+
deadline = asyncio.get_running_loop().time() + _RETRY_BUDGET_S
|
|
49
|
+
while True:
|
|
50
|
+
try:
|
|
51
|
+
return await shoot()
|
|
52
|
+
except Exception as exc: # noqa: BLE001 — filtered on the message below
|
|
53
|
+
if _NO_FRAME_YET not in str(exc) or asyncio.get_running_loop().time() >= deadline:
|
|
54
|
+
raise
|
|
55
|
+
await asyncio.sleep(_RETRY_DELAY_S)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def png_size(png: bytes) -> tuple[int, int] | None:
|
|
59
|
+
"""Width and height from the IHDR chunk, or None when this is not a PNG.
|
|
60
|
+
|
|
61
|
+
The IHDR chunk is mandatory and always first, so the dimensions sit at a
|
|
62
|
+
fixed offset: signature (8) + length (4) + type (4) = 16.
|
|
63
|
+
"""
|
|
64
|
+
if len(png) < 24 or not png.startswith(_PNG_SIGNATURE) or png[12:16] != b"IHDR":
|
|
65
|
+
return None
|
|
66
|
+
width, height = struct.unpack(">II", png[16:24])
|
|
67
|
+
return width, height
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_capture(config: Config, png: bytes, kind: str) -> Path:
|
|
71
|
+
"""Write ``png`` under the capture dir and return its path.
|
|
72
|
+
|
|
73
|
+
``kind`` names what was captured (``page``, ``element``) and leads the file
|
|
74
|
+
name so a directory listing reads as a log. The write goes through a temp
|
|
75
|
+
file and ``os.replace`` so a concurrent reader — VEGA attaching the image
|
|
76
|
+
while we are still writing — cannot observe a truncated file.
|
|
77
|
+
"""
|
|
78
|
+
directory = config.capture_dir
|
|
79
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
|
81
|
+
final = directory / f"{kind}-{stamp}-{uuid.uuid4().hex[:8]}.png"
|
|
82
|
+
tmp = final.with_suffix(".png.part")
|
|
83
|
+
with tmp.open("wb") as handle:
|
|
84
|
+
handle.write(png)
|
|
85
|
+
os.replace(tmp, final)
|
|
86
|
+
_prune(directory, keep=config.capture_keep)
|
|
87
|
+
return final
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _prune(directory: Path, keep: int) -> None:
|
|
91
|
+
"""Delete the oldest captures beyond ``keep``. ``keep <= 0`` keeps everything.
|
|
92
|
+
|
|
93
|
+
Ordered by name, not mtime: names carry a UTC timestamp, and mtime is what
|
|
94
|
+
a copy or a backup restore rewrites.
|
|
95
|
+
"""
|
|
96
|
+
if keep <= 0:
|
|
97
|
+
return
|
|
98
|
+
files = sorted(p for p in directory.glob("*.png") if p.is_file())
|
|
99
|
+
for stale in files[: max(0, len(files) - keep)]:
|
|
100
|
+
try:
|
|
101
|
+
stale.unlink()
|
|
102
|
+
except OSError:
|
|
103
|
+
# Someone else's problem — a capture we cannot delete is not a
|
|
104
|
+
# reason to fail the one we just took.
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def envelope(path: Path, png: bytes, *, url: str, inline: bool) -> dict:
|
|
109
|
+
"""The tool result for a capture.
|
|
110
|
+
|
|
111
|
+
The path is the handoff. ``inline`` adds the base64 for a client with no
|
|
112
|
+
access to this filesystem — it is opt-in because on the client that does
|
|
113
|
+
(VEGA) the string would be shown to the model as text.
|
|
114
|
+
"""
|
|
115
|
+
result: dict = {
|
|
116
|
+
"status": "ok",
|
|
117
|
+
"url": url,
|
|
118
|
+
"image_path": str(path),
|
|
119
|
+
"mime_type": MIME_PNG,
|
|
120
|
+
"bytes": len(png),
|
|
121
|
+
}
|
|
122
|
+
size = png_size(png)
|
|
123
|
+
if size is not None:
|
|
124
|
+
result["width"], result["height"] = size
|
|
125
|
+
if inline:
|
|
126
|
+
import base64
|
|
127
|
+
|
|
128
|
+
result["base64"] = base64.b64encode(png).decode("ascii")
|
|
129
|
+
return result
|