textrun 0.1.0__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.
- textrun/__init__.py +3 -0
- textrun/__main__.py +194 -0
- textrun/adapters.py +176 -0
- textrun/backends/__init__.py +1 -0
- textrun/backends/audio.py +34 -0
- textrun/backends/ocr.py +34 -0
- textrun/backends/screenshot.py +44 -0
- textrun/backends/scroll.py +34 -0
- textrun/backends/tts.py +48 -0
- textrun/config/default.toml +335 -0
- textrun/config/example-profiles/firefox.toml +19 -0
- textrun/config/example-profiles/game-terminal.toml +19 -0
- textrun/config/example-profiles/wayland.toml +38 -0
- textrun/config.py +280 -0
- textrun/desktop.py +81 -0
- textrun/diff.py +52 -0
- textrun/gui/__init__.py +1 -0
- textrun/gui/main_window.py +643 -0
- textrun/gui/region_selector.py +321 -0
- textrun/gui/shortcuts.py +212 -0
- textrun/monitors.py +431 -0
- textrun/pipeline.py +340 -0
- textrun/runner.py +394 -0
- textrun/speaker.py +211 -0
- textrun/stitcher.py +312 -0
- textrun-0.1.0.dist-info/METADATA +469 -0
- textrun-0.1.0.dist-info/RECORD +31 -0
- textrun-0.1.0.dist-info/WHEEL +5 -0
- textrun-0.1.0.dist-info/entry_points.txt +2 -0
- textrun-0.1.0.dist-info/licenses/LICENSE +661 -0
- textrun-0.1.0.dist-info/top_level.txt +1 -0
textrun/__init__.py
ADDED
textrun/__main__.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""CLI entry point for textrun."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from textrun import __version__
|
|
11
|
+
from textrun.config import detect_profile, load_config
|
|
12
|
+
from textrun.pipeline import Pipeline
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def setup_logging(verbose: bool = False) -> None:
|
|
16
|
+
level = logging.DEBUG if verbose else logging.INFO
|
|
17
|
+
logging.basicConfig(
|
|
18
|
+
level=level,
|
|
19
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
20
|
+
datefmt="%H:%M:%S",
|
|
21
|
+
)
|
|
22
|
+
# Third-party libs probe multiple backends internally and log every
|
|
23
|
+
# failed probe (with tracebacks) at DEBUG. That is noise for us.
|
|
24
|
+
for name in ("pyscreenshot", "easyprocess", "PIL", "pyautogui", "pyscreeze"):
|
|
25
|
+
logging.getLogger(name).setLevel(logging.WARNING)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_region(value: str) -> tuple[int, int, int, int]:
|
|
29
|
+
"""Parse 'x,y,w,h' into a tuple of ints."""
|
|
30
|
+
parts = value.split(",")
|
|
31
|
+
if len(parts) != 4:
|
|
32
|
+
raise argparse.ArgumentTypeError(f"Region must be x,y,w,h (got {value!r})")
|
|
33
|
+
try:
|
|
34
|
+
return int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])
|
|
35
|
+
except ValueError:
|
|
36
|
+
raise argparse.ArgumentTypeError(
|
|
37
|
+
f"Region values must be integers (got {value!r})"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
42
|
+
parser = argparse.ArgumentParser(
|
|
43
|
+
prog="textrun",
|
|
44
|
+
description="Generalized text reader with OCR and TTS",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"-v",
|
|
51
|
+
"--verbose",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Enable debug logging",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"-c",
|
|
57
|
+
"--config",
|
|
58
|
+
type=Path,
|
|
59
|
+
default=None,
|
|
60
|
+
help="Path to config file (default: ~/.config/textrun/config.toml)",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"-p",
|
|
64
|
+
"--profile",
|
|
65
|
+
default=None,
|
|
66
|
+
help="Profile to use (default: auto-detect session type)",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--list-profiles",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="List available profiles and exit",
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--gui",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Launch the configuration GUI",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--shortcuts",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help=("Daemon-like: register the global shortcuts and wait, no window shown"),
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--install-desktop",
|
|
85
|
+
action="store_true",
|
|
86
|
+
help=(
|
|
87
|
+
"Install .desktop entry and icon so global shortcuts register "
|
|
88
|
+
"under 'textrun' instead of the launching terminal"
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--region",
|
|
93
|
+
type=parse_region,
|
|
94
|
+
default=None,
|
|
95
|
+
metavar="X,Y,W,H",
|
|
96
|
+
help=(
|
|
97
|
+
"Screen region to capture in real screen pixels "
|
|
98
|
+
"(e.g. 100,200,800,600). Note: the GUI saves logical coords plus "
|
|
99
|
+
"screen sizes and scales automatically; CLI coords are used as-is."
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--no-tts",
|
|
104
|
+
action="store_true",
|
|
105
|
+
help="Disable text-to-speech output",
|
|
106
|
+
)
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
"--scroll-amount",
|
|
109
|
+
type=int,
|
|
110
|
+
default=None,
|
|
111
|
+
help="Override scroll amount (wheel clicks)",
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--max-scrolls",
|
|
115
|
+
type=int,
|
|
116
|
+
default=None,
|
|
117
|
+
help="Override max scroll attempts",
|
|
118
|
+
)
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
"-o",
|
|
121
|
+
"--output",
|
|
122
|
+
type=Path,
|
|
123
|
+
default=None,
|
|
124
|
+
help="Save extracted text to file instead of stdout",
|
|
125
|
+
)
|
|
126
|
+
return parser
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main(argv: list[str] | None = None) -> None:
|
|
130
|
+
parser = build_parser()
|
|
131
|
+
args = parser.parse_args(argv)
|
|
132
|
+
setup_logging(args.verbose)
|
|
133
|
+
profile = args.profile or detect_profile()
|
|
134
|
+
|
|
135
|
+
if args.gui or args.shortcuts:
|
|
136
|
+
try:
|
|
137
|
+
from textrun.gui.main_window import run_gui, run_shortcuts_daemon
|
|
138
|
+
except ImportError as exc: # pragma: no cover - broken install only
|
|
139
|
+
print(f"GUI unavailable (broken install?): {exc}", file=sys.stderr)
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
config = load_config(args.config, profile=profile)
|
|
142
|
+
if args.shortcuts:
|
|
143
|
+
run_shortcuts_daemon(config)
|
|
144
|
+
else:
|
|
145
|
+
run_gui(config)
|
|
146
|
+
sys.exit(0)
|
|
147
|
+
|
|
148
|
+
if args.list_profiles:
|
|
149
|
+
cfg = load_config(args.config, profile="global")
|
|
150
|
+
for p in cfg.list_profiles():
|
|
151
|
+
print(p)
|
|
152
|
+
sys.exit(0)
|
|
153
|
+
|
|
154
|
+
if args.install_desktop:
|
|
155
|
+
from textrun.desktop import install_desktop_files
|
|
156
|
+
|
|
157
|
+
for p in install_desktop_files():
|
|
158
|
+
print(f"Installed: {p}")
|
|
159
|
+
print(
|
|
160
|
+
"Launch textrun from your app menu so global shortcuts "
|
|
161
|
+
"register under 'textrun' instead of your terminal."
|
|
162
|
+
)
|
|
163
|
+
sys.exit(0)
|
|
164
|
+
|
|
165
|
+
overrides: dict = {}
|
|
166
|
+
if args.no_tts:
|
|
167
|
+
overrides["tts_enabled"] = False
|
|
168
|
+
if args.scroll_amount is not None:
|
|
169
|
+
overrides["scroll_amount"] = args.scroll_amount
|
|
170
|
+
if args.max_scrolls is not None:
|
|
171
|
+
overrides["max_scroll_attempts"] = args.max_scrolls
|
|
172
|
+
if args.region is not None:
|
|
173
|
+
x, y, w, h = args.region
|
|
174
|
+
overrides["region"] = {"x": x, "y": y, "width": w, "height": h}
|
|
175
|
+
|
|
176
|
+
config = load_config(args.config, profile=profile)
|
|
177
|
+
if overrides:
|
|
178
|
+
config._profile_data.update(overrides)
|
|
179
|
+
|
|
180
|
+
logger = logging.getLogger("textrun")
|
|
181
|
+
logger.info("Using profile: %s", config.profile)
|
|
182
|
+
|
|
183
|
+
pipeline = Pipeline(config)
|
|
184
|
+
text = pipeline.run()
|
|
185
|
+
|
|
186
|
+
if args.output:
|
|
187
|
+
args.output.write_text(text, encoding="utf-8")
|
|
188
|
+
logger.info("Text saved to %s", args.output)
|
|
189
|
+
else:
|
|
190
|
+
print(text)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
textrun/adapters.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Adapter functions for stateful libraries that need initialization.
|
|
2
|
+
|
|
3
|
+
These are referenced from config as type="adapter" backends.
|
|
4
|
+
Each adapter is a simple function that takes a context dict
|
|
5
|
+
and returns a result string (or raises on failure).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Cached library instances, keyed by thread (COM objects on Windows are
|
|
17
|
+
# apartment-threaded and must not be shared across threads). Reusing the
|
|
18
|
+
# instance avoids per-chunk init cost during live chunked playback.
|
|
19
|
+
_speechd_clients: dict[int, Any] = {}
|
|
20
|
+
_pyttsx3_engines: dict[int, Any] = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _drop_cached(cache: dict, key: int) -> None:
|
|
24
|
+
cache.pop(key, None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def speechd_say(context: dict[str, Any]) -> str:
|
|
28
|
+
"""Speak text using speech-dispatcher."""
|
|
29
|
+
text = context.get("text", "")
|
|
30
|
+
if not text:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
import speechd
|
|
34
|
+
|
|
35
|
+
key = threading.get_ident()
|
|
36
|
+
client = _speechd_clients.get(key)
|
|
37
|
+
if client is None:
|
|
38
|
+
client = speechd.Client()
|
|
39
|
+
_speechd_clients[key] = client
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
client.set_output_module(context.get("speechd_module", "pico"))
|
|
43
|
+
client.set_language(context.get("language", "eng"))
|
|
44
|
+
client.speak(text)
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
_drop_cached(_speechd_clients, key)
|
|
47
|
+
try:
|
|
48
|
+
client.close()
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
raise RuntimeError(f"speech-dispatcher failed: {exc}") from exc
|
|
52
|
+
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def pyttsx3_say(context: dict[str, Any]) -> str:
|
|
57
|
+
"""Speak text using pyttsx3 (wraps system TTS engine)."""
|
|
58
|
+
text = context.get("text", "")
|
|
59
|
+
if not text:
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
import pyttsx3
|
|
63
|
+
|
|
64
|
+
key = threading.get_ident()
|
|
65
|
+
engine = _pyttsx3_engines.get(key)
|
|
66
|
+
if engine is None:
|
|
67
|
+
engine = pyttsx3.init()
|
|
68
|
+
_pyttsx3_engines[key] = engine
|
|
69
|
+
rate = context.get("pyttsx3_rate")
|
|
70
|
+
if rate is not None:
|
|
71
|
+
engine.setProperty("rate", rate)
|
|
72
|
+
volume = context.get("pyttsx3_volume")
|
|
73
|
+
if volume is not None:
|
|
74
|
+
engine.setProperty("volume", volume)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
engine.say(text)
|
|
78
|
+
engine.runAndWait()
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
_drop_cached(_pyttsx3_engines, key)
|
|
81
|
+
raise RuntimeError(f"pyttsx3 failed: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
return text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def playsound3_play(context: dict[str, Any]) -> str:
|
|
87
|
+
"""Play an audio file using playsound3."""
|
|
88
|
+
path = context.get("audio_path", "")
|
|
89
|
+
if not path:
|
|
90
|
+
raise RuntimeError("No audio_path provided")
|
|
91
|
+
|
|
92
|
+
from playsound3 import playsound
|
|
93
|
+
|
|
94
|
+
playsound(path)
|
|
95
|
+
return path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def crop_to_region(
|
|
99
|
+
path: str,
|
|
100
|
+
region: dict[str, Any],
|
|
101
|
+
screen_size: dict[str, Any] | None = None,
|
|
102
|
+
config_monitors: list | None = None,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Crop the image at *path* to *region* in place.
|
|
105
|
+
|
|
106
|
+
Region coords are logical and global (GUI). Monitor layout comes from
|
|
107
|
+
live providers (wlpdisplays/screeninfo/xrandr), then config-saved
|
|
108
|
+
monitors, then the legacy single-scale screen_size dict; with no info
|
|
109
|
+
at all coords are taken as-is. Skips cropping when the image already
|
|
110
|
+
matches the target size (backend cropped natively).
|
|
111
|
+
"""
|
|
112
|
+
from PIL import Image
|
|
113
|
+
|
|
114
|
+
from textrun.monitors import get_monitors, region_crop_rect, single_from_screen_size
|
|
115
|
+
|
|
116
|
+
img = Image.open(path)
|
|
117
|
+
mons = get_monitors(config_monitors=config_monitors)
|
|
118
|
+
if mons is None and screen_size:
|
|
119
|
+
# Legacy aggregate info: missing real dims come from the capture
|
|
120
|
+
info = dict(screen_size)
|
|
121
|
+
if not info.get("real_width"):
|
|
122
|
+
info["real_width"] = img.width
|
|
123
|
+
if not info.get("real_height"):
|
|
124
|
+
info["real_height"] = img.height
|
|
125
|
+
mons = single_from_screen_size(info)
|
|
126
|
+
|
|
127
|
+
if mons:
|
|
128
|
+
x, y, w, h = region_crop_rect(region, mons)
|
|
129
|
+
else:
|
|
130
|
+
x = int(region["x"])
|
|
131
|
+
y = int(region["y"])
|
|
132
|
+
w = int(region["width"])
|
|
133
|
+
h = int(region["height"])
|
|
134
|
+
|
|
135
|
+
if abs(img.width - w) <= 2 and abs(img.height - h) <= 2:
|
|
136
|
+
return path # already region-sized
|
|
137
|
+
|
|
138
|
+
img.crop((x, y, x + w, y + h)).save(path)
|
|
139
|
+
return path
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def pyscreenshot_capture(context: dict[str, Any]) -> str:
|
|
143
|
+
"""Capture a screenshot using pyscreenshot. Supports region if set in context."""
|
|
144
|
+
output_path = context.get("output_path", "/tmp/textrun/screenshot.png")
|
|
145
|
+
region = context.get("region")
|
|
146
|
+
|
|
147
|
+
import pyscreenshot as ImageGrab
|
|
148
|
+
|
|
149
|
+
# Grab the full screen, then crop with PIL. Using bbox directly can be
|
|
150
|
+
# unreliable on Wayland (positioning ignored, cutout centered).
|
|
151
|
+
img = ImageGrab.grab()
|
|
152
|
+
img.save(output_path)
|
|
153
|
+
if region:
|
|
154
|
+
crop_to_region(output_path, region, context.get("screen_size"))
|
|
155
|
+
return output_path
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def pyautogui_scroll(context: dict[str, Any]) -> str:
|
|
159
|
+
"""Scroll using pyautogui."""
|
|
160
|
+
amount = int(context.get("scroll_amount", 3))
|
|
161
|
+
|
|
162
|
+
import pyautogui
|
|
163
|
+
|
|
164
|
+
pyautogui.scroll(amount)
|
|
165
|
+
return f"scrolled {amount}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def pyautogui_screenshot(context: dict[str, Any]) -> str:
|
|
169
|
+
"""Take a screenshot using pyautogui."""
|
|
170
|
+
output_path = context.get("output_path", "/tmp/textrun/screenshot.png")
|
|
171
|
+
|
|
172
|
+
import pyautogui
|
|
173
|
+
|
|
174
|
+
img = pyautogui.screenshot()
|
|
175
|
+
img.save(output_path)
|
|
176
|
+
return output_path
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Backend orchestrators for textrun."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Audio playback backend orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from textrun.runner import BackendResult, run_chain
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def play(
|
|
14
|
+
config: dict[str, Any],
|
|
15
|
+
context: dict[str, Any],
|
|
16
|
+
skip: set[str] | None = None,
|
|
17
|
+
) -> BackendResult:
|
|
18
|
+
"""Play an audio file using the configured backend chain.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
config: The 'audio' section from the profile config.
|
|
22
|
+
context: Runtime context with audio_path, etc.
|
|
23
|
+
skip: Optional set of backend names to skip (failed earlier in this run).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
BackendResult indicating success/failure.
|
|
27
|
+
"""
|
|
28
|
+
chain = config.get("chain", [])
|
|
29
|
+
backends = config.get("backends", {})
|
|
30
|
+
|
|
31
|
+
if not chain:
|
|
32
|
+
return BackendResult(success=False, error="No audio backends configured")
|
|
33
|
+
|
|
34
|
+
return run_chain(chain, backends, context, skip)
|
textrun/backends/ocr.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""OCR backend orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from textrun.runner import BackendResult, run_chain
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_text(
|
|
14
|
+
config: dict[str, Any],
|
|
15
|
+
context: dict[str, Any],
|
|
16
|
+
skip: set[str] | None = None,
|
|
17
|
+
) -> BackendResult:
|
|
18
|
+
"""Extract text from an image using the configured backend chain.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
config: The 'ocr' section from the profile config.
|
|
22
|
+
context: Runtime context with image_path, language, etc.
|
|
23
|
+
skip: Optional set of backend names to skip (failed earlier in this run).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
BackendResult with output containing extracted text.
|
|
27
|
+
"""
|
|
28
|
+
chain = config.get("chain", [])
|
|
29
|
+
backends = config.get("backends", {})
|
|
30
|
+
|
|
31
|
+
if not chain:
|
|
32
|
+
return BackendResult(success=False, error="No OCR backends configured")
|
|
33
|
+
|
|
34
|
+
return run_chain(chain, backends, context, skip)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Screenshot backend orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from textrun.runner import BackendResult, run_chain
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def capture(
|
|
15
|
+
config: dict[str, Any],
|
|
16
|
+
context: dict[str, Any],
|
|
17
|
+
skip: set[str] | None = None,
|
|
18
|
+
) -> BackendResult:
|
|
19
|
+
"""Capture a screenshot using the configured backend chain.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: The 'screenshot' section from the profile config.
|
|
23
|
+
context: Runtime context with output_path, etc.
|
|
24
|
+
skip: Optional set of backend names to skip (failed earlier in this run).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
BackendResult with output containing the screenshot path.
|
|
28
|
+
"""
|
|
29
|
+
chain = config.get("chain", [])
|
|
30
|
+
backends = config.get("backends", {})
|
|
31
|
+
|
|
32
|
+
if not chain:
|
|
33
|
+
return BackendResult(success=False, error="No screenshot backends configured")
|
|
34
|
+
|
|
35
|
+
output_path = context.get("output_path", "/tmp/textrun/screenshot.png")
|
|
36
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
ctx = {**context, "output_path": output_path}
|
|
39
|
+
result = run_chain(chain, backends, ctx, skip)
|
|
40
|
+
|
|
41
|
+
if result.success and not result.output:
|
|
42
|
+
result.output = output_path
|
|
43
|
+
|
|
44
|
+
return result
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Scroll backend orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from textrun.runner import BackendResult, run_chain
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scroll(
|
|
14
|
+
config: dict[str, Any],
|
|
15
|
+
context: dict[str, Any],
|
|
16
|
+
skip: set[str] | None = None,
|
|
17
|
+
) -> BackendResult:
|
|
18
|
+
"""Scroll down using the configured backend chain.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
config: The 'scroll' section from the profile config.
|
|
22
|
+
context: Runtime context with scroll_amount, etc.
|
|
23
|
+
skip: Optional set of backend names to skip (failed earlier in this run).
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
BackendResult indicating success/failure.
|
|
27
|
+
"""
|
|
28
|
+
chain = config.get("chain", [])
|
|
29
|
+
backends = config.get("backends", {})
|
|
30
|
+
|
|
31
|
+
if not chain:
|
|
32
|
+
return BackendResult(success=False, error="No scroll backends configured")
|
|
33
|
+
|
|
34
|
+
return run_chain(chain, backends, context, skip)
|
textrun/backends/tts.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""TTS backend orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from textrun.runner import BackendResult, run_chain
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def synthesize(
|
|
15
|
+
config: dict[str, Any],
|
|
16
|
+
context: dict[str, Any],
|
|
17
|
+
skip: set[str] | None = None,
|
|
18
|
+
) -> BackendResult:
|
|
19
|
+
"""Convert text to speech using the configured backend chain.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: The 'tts' section from the profile config.
|
|
23
|
+
context: Runtime context with text, output_path, etc.
|
|
24
|
+
skip: Optional set of backend names to skip (failed earlier in this run).
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
BackendResult with output containing the audio file path.
|
|
28
|
+
"""
|
|
29
|
+
chain = config.get("chain", [])
|
|
30
|
+
backends = config.get("backends", {})
|
|
31
|
+
|
|
32
|
+
if not chain:
|
|
33
|
+
return BackendResult(success=False, error="No TTS backends configured")
|
|
34
|
+
|
|
35
|
+
output_path = context.get("tts_output_path", "/tmp/textrun/tts_output.wav")
|
|
36
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
ctx = {
|
|
39
|
+
**context,
|
|
40
|
+
"output_path": output_path,
|
|
41
|
+
"stdin_text": context.get("text", ""),
|
|
42
|
+
}
|
|
43
|
+
result = run_chain(chain, backends, ctx, skip)
|
|
44
|
+
|
|
45
|
+
if result.success and not result.output:
|
|
46
|
+
result.output = output_path
|
|
47
|
+
|
|
48
|
+
return result
|