watchby 0.0.1__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.
- watchby/__init__.py +11 -0
- watchby/__main__.py +7 -0
- watchby/cli/__init__.py +103 -0
- watchby/cli/doctor.py +71 -0
- watchby/cli/init.py +68 -0
- watchby/cli/process.py +320 -0
- watchby/cli/term.py +106 -0
- watchby/config/__init__.py +2 -0
- watchby/config/settings.py +315 -0
- watchby/config/writer.py +47 -0
- watchby/errors.py +48 -0
- watchby/i18n.py +111 -0
- watchby/locale/watchby.pot +224 -0
- watchby/py.typed +0 -0
- watchby/runtime/__init__.py +2 -0
- watchby/runtime/diagnose.py +150 -0
- watchby/runtime/host.py +99 -0
- watchby/runtime/procs.py +53 -0
- watchby-0.0.1.dist-info/METADATA +119 -0
- watchby-0.0.1.dist-info/RECORD +24 -0
- watchby-0.0.1.dist-info/WHEEL +4 -0
- watchby-0.0.1.dist-info/entry_points.txt +3 -0
- watchby-0.0.1.dist-info/licenses/LICENSE +202 -0
- watchby-0.0.1.dist-info/licenses/NOTICE +7 -0
watchby/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 the watchby authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
__version__ = version("watchby")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "0.0.0+unknown"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
watchby/__main__.py
ADDED
watchby/cli/__init__.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 the watchby authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from watchby import __version__
|
|
11
|
+
from watchby.cli.doctor import doctor
|
|
12
|
+
from watchby.cli.init import init
|
|
13
|
+
from watchby.cli.process import EPILOG as PROCESS_EPILOG
|
|
14
|
+
from watchby.cli.process import HELP as PROCESS_HELP
|
|
15
|
+
from watchby.cli.process import process
|
|
16
|
+
from watchby.cli.term import prepare_streams
|
|
17
|
+
from watchby.errors import WatchbyError
|
|
18
|
+
from watchby.i18n import _, set_language
|
|
19
|
+
from watchby.runtime import host
|
|
20
|
+
|
|
21
|
+
UNSUPPORTED_ENV = "WATCHBY_UNSUPPORTED_OS"
|
|
22
|
+
WATCH = "Watch"
|
|
23
|
+
SETUP = "Set up"
|
|
24
|
+
|
|
25
|
+
EPILOG = """\b
|
|
26
|
+
Start here:
|
|
27
|
+
watchby doctor check this machine
|
|
28
|
+
watchby init write watchby.toml so runs need no flags
|
|
29
|
+
watchby process screen.mp4 read a screen recording
|
|
30
|
+
|
|
31
|
+
Every command has its own help: watchby process --help
|
|
32
|
+
Documentation: https://github.com/khvostpola/watchby/tree/main/docs"""
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="watchby",
|
|
36
|
+
help=(
|
|
37
|
+
"What a screen recording showed, next to what was said: stable screens, their text, "
|
|
38
|
+
"and a transcript that points at them. Runs on your machine."
|
|
39
|
+
),
|
|
40
|
+
epilog=EPILOG,
|
|
41
|
+
no_args_is_help=True,
|
|
42
|
+
pretty_exceptions_enable=False,
|
|
43
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
44
|
+
)
|
|
45
|
+
app.command("process", rich_help_panel=WATCH, epilog=PROCESS_EPILOG, help=PROCESS_HELP)(process)
|
|
46
|
+
app.command("init", rich_help_panel=SETUP)(init)
|
|
47
|
+
app.command("doctor", rich_help_panel=SETUP)(doctor)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _version(value: bool) -> None:
|
|
51
|
+
if value:
|
|
52
|
+
typer.echo(__version__)
|
|
53
|
+
raise typer.Exit()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _ui_language(value: str | None) -> str | None:
|
|
57
|
+
if value is not None:
|
|
58
|
+
set_language(value)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.callback()
|
|
63
|
+
def root(
|
|
64
|
+
version: Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--version", callback=_version, is_eager=True, help="Show the version and exit."
|
|
68
|
+
),
|
|
69
|
+
] = False,
|
|
70
|
+
ui_lang: Annotated[
|
|
71
|
+
str | None,
|
|
72
|
+
typer.Option(
|
|
73
|
+
"--ui-lang",
|
|
74
|
+
callback=_ui_language,
|
|
75
|
+
is_eager=True,
|
|
76
|
+
help="Language of messages, such as es (default: your system language).",
|
|
77
|
+
),
|
|
78
|
+
] = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def warn_unsupported_system() -> None:
|
|
84
|
+
if host.is_supported() or os.environ.get(UNSUPPORTED_ENV):
|
|
85
|
+
return
|
|
86
|
+
message = _(
|
|
87
|
+
"watchby supports Linux and Windows; {system} is untested (set {env}=1 to hide this)"
|
|
88
|
+
)
|
|
89
|
+
print(message.format(system=host.platform_label(), env=UNSUPPORTED_ENV), file=sys.stderr)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> None:
|
|
93
|
+
prepare_streams()
|
|
94
|
+
set_language()
|
|
95
|
+
warn_unsupported_system()
|
|
96
|
+
try:
|
|
97
|
+
app()
|
|
98
|
+
except KeyboardInterrupt:
|
|
99
|
+
sys.exit(130)
|
|
100
|
+
except WatchbyError as exc:
|
|
101
|
+
message = _("error: {message}").format(message=exc)
|
|
102
|
+
print(f"{message} [{exc.error_id} · exit {exc.exit_code}]", file=sys.stderr)
|
|
103
|
+
sys.exit(exc.exit_code)
|
watchby/cli/doctor.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 the watchby authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from watchby import __version__
|
|
12
|
+
from watchby.i18n import _, ngettext, set_language
|
|
13
|
+
from watchby.runtime.diagnose import Check, run_checks
|
|
14
|
+
|
|
15
|
+
SYMBOLS = {"ok": "ok ", "warn": "warn", "fail": "FAIL"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _as_dict(check: Check) -> dict[str, str | None]:
|
|
19
|
+
return {
|
|
20
|
+
"name": check.name,
|
|
21
|
+
"status": check.status,
|
|
22
|
+
"detail": check.detail,
|
|
23
|
+
"hint": check.hint,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _print(checks: list[Check]) -> None:
|
|
28
|
+
width = max(len(c.name) for c in checks)
|
|
29
|
+
for check in checks:
|
|
30
|
+
typer.echo(f"{SYMBOLS[check.status]} {check.name.ljust(width)} {check.detail}")
|
|
31
|
+
if check.hint and check.status != "ok":
|
|
32
|
+
typer.echo(f" {' ' * width} -> {check.hint}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def doctor(
|
|
36
|
+
as_json: Annotated[bool, typer.Option("--json", help="Machine-readable output")] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Check that this machine can run watchby and say how to fix what is missing."""
|
|
39
|
+
if as_json:
|
|
40
|
+
set_language("en")
|
|
41
|
+
checks = run_checks()
|
|
42
|
+
failures = [c for c in checks if c.status == "fail"]
|
|
43
|
+
if as_json:
|
|
44
|
+
typer.echo(
|
|
45
|
+
json.dumps(
|
|
46
|
+
{
|
|
47
|
+
"watchby": __version__,
|
|
48
|
+
"python": platform.python_version(),
|
|
49
|
+
"platform": sys.platform,
|
|
50
|
+
"ffmpeg": next((c.detail for c in checks if c.name == "ffmpeg"), None),
|
|
51
|
+
"saidby": next((c.detail for c in checks if c.name == "saidby"), None),
|
|
52
|
+
"checks": [_as_dict(c) for c in checks],
|
|
53
|
+
"problems": [f"{c.name}: {c.detail}" for c in failures],
|
|
54
|
+
},
|
|
55
|
+
indent=2,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
typer.echo(_("watchby {version}").format(version=__version__))
|
|
60
|
+
_print(checks)
|
|
61
|
+
typer.echo("")
|
|
62
|
+
if failures:
|
|
63
|
+
problems = ngettext(
|
|
64
|
+
"{n} problem to fix before processing",
|
|
65
|
+
"{n} problems to fix before processing",
|
|
66
|
+
len(failures),
|
|
67
|
+
)
|
|
68
|
+
typer.echo(problems.format(n=len(failures)))
|
|
69
|
+
else:
|
|
70
|
+
typer.echo(_("ready"))
|
|
71
|
+
raise typer.Exit(1 if failures else 0)
|
watchby/cli/init.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 the watchby authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from watchby.config.settings import CONFIG_NAME, global_config_path
|
|
11
|
+
from watchby.config.writer import check_language, default_language, render_config
|
|
12
|
+
from watchby.errors import InputError
|
|
13
|
+
from watchby.i18n import _
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _interactive() -> bool:
|
|
17
|
+
return sys.stdin.isatty() and sys.stderr.isatty()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _write_new(path: Path, text: str, note: str) -> None:
|
|
21
|
+
if path.exists():
|
|
22
|
+
typer.echo(_(" kept {name:<16} already there, not touched").format(name=path.name))
|
|
23
|
+
return
|
|
24
|
+
try:
|
|
25
|
+
path.write_text(text, encoding="utf-8", newline="\n")
|
|
26
|
+
except OSError as exc:
|
|
27
|
+
raise InputError(_("cannot write {path}: {error}").format(path=path, error=exc)) from exc
|
|
28
|
+
typer.echo(_(" wrote {name:<16} {note}").format(name=path.name, note=note))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def init(
|
|
32
|
+
language: Annotated[
|
|
33
|
+
str | None,
|
|
34
|
+
typer.Option(help="Language of your recordings, such as es or en (default: asks)."),
|
|
35
|
+
] = None,
|
|
36
|
+
yes: Annotated[
|
|
37
|
+
bool, typer.Option("--yes", help="Do not ask: take the language option or the default.")
|
|
38
|
+
] = False,
|
|
39
|
+
global_: Annotated[
|
|
40
|
+
bool,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--global",
|
|
43
|
+
help="Set up the user config folder for every folder, not this one "
|
|
44
|
+
"(~/.config/watchby on Linux, %APPDATA%\\watchby on Windows).",
|
|
45
|
+
),
|
|
46
|
+
] = False,
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Create watchby.toml so the next run needs no flags."""
|
|
49
|
+
ask = _interactive() and not yes
|
|
50
|
+
chosen = language or default_language()
|
|
51
|
+
if ask and language is None:
|
|
52
|
+
chosen = typer.prompt(_("Language of your recordings"), default=chosen)
|
|
53
|
+
chosen = check_language(chosen)
|
|
54
|
+
folder = global_config_path().parent if global_ else Path.cwd()
|
|
55
|
+
try:
|
|
56
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise InputError(_("cannot create {path}: {error}").format(path=folder, error=exc)) from exc
|
|
59
|
+
config = folder / ("config.toml" if global_ else CONFIG_NAME)
|
|
60
|
+
typer.echo(_("Setting up {folder}").format(folder=folder))
|
|
61
|
+
note = _("language {language}").format(language=chosen)
|
|
62
|
+
if not global_:
|
|
63
|
+
note += " · " + _("output ./watchby/")
|
|
64
|
+
_write_new(config, render_config(language=chosen, out=None if global_ else "watchby"), note)
|
|
65
|
+
where = _("every folder") if global_ else _("this folder")
|
|
66
|
+
typer.echo(
|
|
67
|
+
_("Done. In {where}, `watchby process screen.mp4` needs no flags now.").format(where=where)
|
|
68
|
+
)
|
watchby/cli/process.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 the watchby authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from watchby import __version__
|
|
10
|
+
from watchby.config.settings import Settings
|
|
11
|
+
from watchby.errors import WatchbyError
|
|
12
|
+
from watchby.i18n import _
|
|
13
|
+
|
|
14
|
+
IO = "Input and output"
|
|
15
|
+
SCREENS = "Screens"
|
|
16
|
+
TRANSCRIPT = "Transcript"
|
|
17
|
+
TEXT = "Text"
|
|
18
|
+
MODELS = "Models"
|
|
19
|
+
PRIVACY = "Privacy"
|
|
20
|
+
BEHAVIOR = "Run behavior"
|
|
21
|
+
|
|
22
|
+
HELP = (
|
|
23
|
+
"Turn screen recordings into stable screens, their text, and a transcript that points at "
|
|
24
|
+
"them. Not available yet: this version only checks the options."
|
|
25
|
+
)
|
|
26
|
+
EPILOG = """\b
|
|
27
|
+
Examples:
|
|
28
|
+
watchby process screen.mp4 --language es
|
|
29
|
+
watchby process call.mp4 --source call --out results/
|
|
30
|
+
watchby process app.mp4 --source screen --ocr none
|
|
31
|
+
watchby process call.mp4 --transcript saidby/call
|
|
32
|
+
watchby process call.mp4 --describe --llm-backend anthropic --allow-remote-llm
|
|
33
|
+
|
|
34
|
+
Each input gets its own folder under the output folder (./watchby unless you pass --out or set
|
|
35
|
+
`out` in watchby.toml). A failed run keeps its work and the next run resumes from the last
|
|
36
|
+
finished stage."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def process(
|
|
40
|
+
inputs: Annotated[list[Path], typer.Argument(help="Videos (anything ffmpeg reads).")],
|
|
41
|
+
out: Annotated[
|
|
42
|
+
Path | None,
|
|
43
|
+
typer.Option(
|
|
44
|
+
"--out",
|
|
45
|
+
help="Folder for the results, one subfolder per input (default: ./watchby).",
|
|
46
|
+
rich_help_panel=IO,
|
|
47
|
+
),
|
|
48
|
+
] = None,
|
|
49
|
+
config: Annotated[
|
|
50
|
+
Path | None,
|
|
51
|
+
typer.Option(
|
|
52
|
+
help="A watchby.toml with defaults for the options below.", rich_help_panel=IO
|
|
53
|
+
),
|
|
54
|
+
] = None,
|
|
55
|
+
source: Annotated[
|
|
56
|
+
str | None,
|
|
57
|
+
typer.Option(
|
|
58
|
+
help=(
|
|
59
|
+
"auto (default): look for a shared screen inside a video call and fall back to "
|
|
60
|
+
"the whole frame. call: only the shared screen. screen: always the whole frame."
|
|
61
|
+
),
|
|
62
|
+
rich_help_panel=SCREENS,
|
|
63
|
+
),
|
|
64
|
+
] = None,
|
|
65
|
+
crop: Annotated[
|
|
66
|
+
str | None,
|
|
67
|
+
typer.Option(
|
|
68
|
+
help="A fixed screen rectangle WxH+X+Y in pixels, such as 894x504+94+906; "
|
|
69
|
+
"skips the detection.",
|
|
70
|
+
rich_help_panel=SCREENS,
|
|
71
|
+
),
|
|
72
|
+
] = None,
|
|
73
|
+
fps: Annotated[
|
|
74
|
+
float | None,
|
|
75
|
+
typer.Option(
|
|
76
|
+
help="Frames per second looked at to find changes (default 1).",
|
|
77
|
+
rich_help_panel=SCREENS,
|
|
78
|
+
),
|
|
79
|
+
] = None,
|
|
80
|
+
min_screen: Annotated[
|
|
81
|
+
float | None,
|
|
82
|
+
typer.Option(
|
|
83
|
+
help="Seconds a screen must stay to count as stable (default 2).",
|
|
84
|
+
rich_help_panel=SCREENS,
|
|
85
|
+
),
|
|
86
|
+
] = None,
|
|
87
|
+
change_threshold: Annotated[
|
|
88
|
+
float | None,
|
|
89
|
+
typer.Option(
|
|
90
|
+
help="Share of a grid cell's pixels that must change to count it (default 0.02).",
|
|
91
|
+
rich_help_panel=SCREENS,
|
|
92
|
+
),
|
|
93
|
+
] = None,
|
|
94
|
+
mask: Annotated[
|
|
95
|
+
list[str] | None,
|
|
96
|
+
typer.Option(
|
|
97
|
+
"--mask",
|
|
98
|
+
help="A rectangle WxH+X+Y of the screen to ignore when comparing frames "
|
|
99
|
+
"(% works too); repeat it.",
|
|
100
|
+
rich_help_panel=SCREENS,
|
|
101
|
+
),
|
|
102
|
+
] = None,
|
|
103
|
+
transcript: Annotated[
|
|
104
|
+
Path | None,
|
|
105
|
+
typer.Option(
|
|
106
|
+
help="Use this finished saidby run of the same video instead of running saidby "
|
|
107
|
+
"(one input only).",
|
|
108
|
+
rich_help_panel=TRANSCRIPT,
|
|
109
|
+
),
|
|
110
|
+
] = None,
|
|
111
|
+
no_transcript: Annotated[
|
|
112
|
+
bool,
|
|
113
|
+
typer.Option(
|
|
114
|
+
"--no-transcript",
|
|
115
|
+
help="Do not run saidby: screens only. Automatic when the video has no audio.",
|
|
116
|
+
rich_help_panel=TRANSCRIPT,
|
|
117
|
+
),
|
|
118
|
+
] = False,
|
|
119
|
+
language: Annotated[
|
|
120
|
+
str | None,
|
|
121
|
+
typer.Option(
|
|
122
|
+
help="Language code of the speech and the on-screen text (es, en, pt...). "
|
|
123
|
+
"Passed to saidby.",
|
|
124
|
+
rich_help_panel=TRANSCRIPT,
|
|
125
|
+
),
|
|
126
|
+
] = None,
|
|
127
|
+
saidby_arg: Annotated[
|
|
128
|
+
list[str] | None,
|
|
129
|
+
typer.Option(
|
|
130
|
+
"--saidby-arg",
|
|
131
|
+
help="One extra argument for saidby; repeat it. Options watchby sets itself are "
|
|
132
|
+
"refused.",
|
|
133
|
+
rich_help_panel=TRANSCRIPT,
|
|
134
|
+
),
|
|
135
|
+
] = None,
|
|
136
|
+
ocr: Annotated[
|
|
137
|
+
str | None,
|
|
138
|
+
typer.Option(
|
|
139
|
+
help="Text reader: rapidocr (default), tesseract (extra) or none.",
|
|
140
|
+
rich_help_panel=TEXT,
|
|
141
|
+
),
|
|
142
|
+
] = None,
|
|
143
|
+
ocr_lang: Annotated[
|
|
144
|
+
list[str] | None,
|
|
145
|
+
typer.Option(
|
|
146
|
+
"--ocr-lang",
|
|
147
|
+
help="Language of the on-screen text; repeat it (default: the transcript language "
|
|
148
|
+
"plus en).",
|
|
149
|
+
rich_help_panel=TEXT,
|
|
150
|
+
),
|
|
151
|
+
] = None,
|
|
152
|
+
describe: Annotated[
|
|
153
|
+
bool,
|
|
154
|
+
typer.Option(
|
|
155
|
+
"--describe",
|
|
156
|
+
help="Have a model describe each stable screen.",
|
|
157
|
+
rich_help_panel=MODELS,
|
|
158
|
+
),
|
|
159
|
+
] = False,
|
|
160
|
+
summarize: Annotated[
|
|
161
|
+
bool,
|
|
162
|
+
typer.Option(
|
|
163
|
+
"--summarize",
|
|
164
|
+
help="Have a model sum up each stretch of talk next to what was on screen. "
|
|
165
|
+
"Implies --describe.",
|
|
166
|
+
rich_help_panel=MODELS,
|
|
167
|
+
),
|
|
168
|
+
] = False,
|
|
169
|
+
llm_backend: Annotated[
|
|
170
|
+
str | None,
|
|
171
|
+
typer.Option(
|
|
172
|
+
help="openai (default): a llama-server or any OpenAI-compatible server. "
|
|
173
|
+
"anthropic: Claude through the official SDK (extra).",
|
|
174
|
+
rich_help_panel=MODELS,
|
|
175
|
+
),
|
|
176
|
+
] = None,
|
|
177
|
+
llm_url: Annotated[
|
|
178
|
+
str | None,
|
|
179
|
+
typer.Option(
|
|
180
|
+
help="Use an OpenAI-compatible server you already run, not llama-server.",
|
|
181
|
+
rich_help_panel=MODELS,
|
|
182
|
+
),
|
|
183
|
+
] = None,
|
|
184
|
+
llm_model: Annotated[
|
|
185
|
+
str | None,
|
|
186
|
+
typer.Option(help="Model to ask (default depends on the backend).", rich_help_panel=MODELS),
|
|
187
|
+
] = None,
|
|
188
|
+
llm_binary: Annotated[
|
|
189
|
+
str | None,
|
|
190
|
+
typer.Option(
|
|
191
|
+
help="Path to llama-server (default: PATH or WATCHBY_LLAMA_SERVER).",
|
|
192
|
+
rich_help_panel=MODELS,
|
|
193
|
+
),
|
|
194
|
+
] = None,
|
|
195
|
+
allow_remote_llm: Annotated[
|
|
196
|
+
bool,
|
|
197
|
+
typer.Option(
|
|
198
|
+
"--allow-remote-llm",
|
|
199
|
+
help="Allow a model endpoint that is not on this machine.",
|
|
200
|
+
rich_help_panel=MODELS,
|
|
201
|
+
),
|
|
202
|
+
] = False,
|
|
203
|
+
llm_max_screens: Annotated[
|
|
204
|
+
int | None,
|
|
205
|
+
typer.Option(
|
|
206
|
+
min=1,
|
|
207
|
+
help="Most screens sent to the model in one run (default 150).",
|
|
208
|
+
rich_help_panel=MODELS,
|
|
209
|
+
),
|
|
210
|
+
] = None,
|
|
211
|
+
no_pii: Annotated[
|
|
212
|
+
bool,
|
|
213
|
+
typer.Option(
|
|
214
|
+
"--no-pii",
|
|
215
|
+
help="Do not flag screens that show email addresses or phone numbers.",
|
|
216
|
+
rich_help_panel=PRIVACY,
|
|
217
|
+
),
|
|
218
|
+
] = False,
|
|
219
|
+
keep_all_screens: Annotated[
|
|
220
|
+
bool,
|
|
221
|
+
typer.Option(
|
|
222
|
+
"--keep-all-screens",
|
|
223
|
+
help="Also keep captures from stretches with no shared screen.",
|
|
224
|
+
rich_help_panel=PRIVACY,
|
|
225
|
+
),
|
|
226
|
+
] = False,
|
|
227
|
+
strict_offline: Annotated[
|
|
228
|
+
bool,
|
|
229
|
+
typer.Option(
|
|
230
|
+
"--strict-offline",
|
|
231
|
+
help="Fail with exit code 8 if anything tries to leave this machine.",
|
|
232
|
+
rich_help_panel=PRIVACY,
|
|
233
|
+
),
|
|
234
|
+
] = False,
|
|
235
|
+
device: Annotated[
|
|
236
|
+
str | None,
|
|
237
|
+
typer.Option(
|
|
238
|
+
help="auto (default): GPU if usable, else CPU. Or force cuda or cpu. "
|
|
239
|
+
"Applies to saidby and to a local model server.",
|
|
240
|
+
rich_help_panel=BEHAVIOR,
|
|
241
|
+
),
|
|
242
|
+
] = None,
|
|
243
|
+
keep_intermediates: Annotated[
|
|
244
|
+
bool,
|
|
245
|
+
typer.Option(
|
|
246
|
+
"--keep-intermediates",
|
|
247
|
+
help="Keep the .work folder after success (it is kept on failure).",
|
|
248
|
+
rich_help_panel=BEHAVIOR,
|
|
249
|
+
),
|
|
250
|
+
] = False,
|
|
251
|
+
force: Annotated[
|
|
252
|
+
bool,
|
|
253
|
+
typer.Option(
|
|
254
|
+
"--force",
|
|
255
|
+
help="Redo a stage whose results are already in the output folder.",
|
|
256
|
+
rich_help_panel=BEHAVIOR,
|
|
257
|
+
),
|
|
258
|
+
] = False,
|
|
259
|
+
fail_fast: Annotated[
|
|
260
|
+
bool,
|
|
261
|
+
typer.Option(
|
|
262
|
+
"--fail-fast", help="Stop at the first input that fails.", rich_help_panel=BEHAVIOR
|
|
263
|
+
),
|
|
264
|
+
] = False,
|
|
265
|
+
progress: Annotated[
|
|
266
|
+
str | None,
|
|
267
|
+
typer.Option(
|
|
268
|
+
help="human (default, on stderr), json (one event per line on stdout) or none.",
|
|
269
|
+
rich_help_panel=BEHAVIOR,
|
|
270
|
+
),
|
|
271
|
+
] = None,
|
|
272
|
+
strict: Annotated[
|
|
273
|
+
bool,
|
|
274
|
+
typer.Option(
|
|
275
|
+
"--strict",
|
|
276
|
+
help="Exit with code 1 when a run finishes degraded (screens without a transcript, "
|
|
277
|
+
"for example).",
|
|
278
|
+
rich_help_panel=BEHAVIOR,
|
|
279
|
+
),
|
|
280
|
+
] = False,
|
|
281
|
+
) -> None:
|
|
282
|
+
Settings.load(
|
|
283
|
+
config,
|
|
284
|
+
{
|
|
285
|
+
"out": out,
|
|
286
|
+
"source": source,
|
|
287
|
+
"crop": crop,
|
|
288
|
+
"fps": fps,
|
|
289
|
+
"min_screen": min_screen,
|
|
290
|
+
"change_threshold": change_threshold,
|
|
291
|
+
"masks": mask or None,
|
|
292
|
+
"run_saidby": False if no_transcript else None,
|
|
293
|
+
"language": language,
|
|
294
|
+
"saidby_args": saidby_arg or None,
|
|
295
|
+
"ocr": ocr,
|
|
296
|
+
"ocr_lang": ocr_lang or None,
|
|
297
|
+
"describe": describe or None,
|
|
298
|
+
"summarize": summarize or None,
|
|
299
|
+
"llm_backend": llm_backend,
|
|
300
|
+
"llm_url": llm_url,
|
|
301
|
+
"llm_model": llm_model,
|
|
302
|
+
"llm_binary": llm_binary,
|
|
303
|
+
"allow_remote_llm": allow_remote_llm or None,
|
|
304
|
+
"llm_max_screens": llm_max_screens,
|
|
305
|
+
"pii": False if no_pii else None,
|
|
306
|
+
"keep_all_screens": keep_all_screens or None,
|
|
307
|
+
"strict_offline": strict_offline or None,
|
|
308
|
+
"device": device,
|
|
309
|
+
"keep_intermediates": keep_intermediates or None,
|
|
310
|
+
"force": force or None,
|
|
311
|
+
"fail_fast": fail_fast or None,
|
|
312
|
+
"progress": progress,
|
|
313
|
+
"strict": strict or None,
|
|
314
|
+
},
|
|
315
|
+
)
|
|
316
|
+
raise WatchbyError(
|
|
317
|
+
_("watchby {version} cannot process recordings yet: the pipeline arrives in 0.1.0").format(
|
|
318
|
+
version=__version__
|
|
319
|
+
)
|
|
320
|
+
)
|
watchby/cli/term.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 the watchby authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
from watchby.runtime import host
|
|
14
|
+
|
|
15
|
+
RESET = "\x1b[0m"
|
|
16
|
+
CODES = {
|
|
17
|
+
"bold": "1",
|
|
18
|
+
"dim": "2",
|
|
19
|
+
"red": "31",
|
|
20
|
+
"green": "32",
|
|
21
|
+
"yellow": "33",
|
|
22
|
+
"blue": "34",
|
|
23
|
+
"cyan": "36",
|
|
24
|
+
}
|
|
25
|
+
TRUECOLOR_VALUES = {"truecolor", "24bit"}
|
|
26
|
+
VT_PROCESSING = 0x0004
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Terminal:
|
|
31
|
+
tty: bool
|
|
32
|
+
color: bool
|
|
33
|
+
truecolor: bool
|
|
34
|
+
columns: int
|
|
35
|
+
|
|
36
|
+
def paint(self, text: str, *styles: str) -> str:
|
|
37
|
+
if not self.color or not styles or not text:
|
|
38
|
+
return text
|
|
39
|
+
return f"\x1b[{';'.join(CODES[s] for s in styles)}m{text}{RESET}"
|
|
40
|
+
|
|
41
|
+
def fit(self, text: str) -> str:
|
|
42
|
+
limit = max(self.columns - 1, 10)
|
|
43
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect(
|
|
47
|
+
stream: TextIO,
|
|
48
|
+
environ: Mapping[str, str] | None = None,
|
|
49
|
+
*,
|
|
50
|
+
vt: Callable[[TextIO], bool] | None = None,
|
|
51
|
+
) -> Terminal:
|
|
52
|
+
env = os.environ if environ is None else environ
|
|
53
|
+
if host.is_windows():
|
|
54
|
+
return _detect_windows(stream, env, vt or enable_vt)
|
|
55
|
+
tty = stream.isatty() and env.get("TERM", "") not in {"", "dumb"}
|
|
56
|
+
color = tty and not env.get("NO_COLOR")
|
|
57
|
+
columns = shutil.get_terminal_size(fallback=(80, 24)).columns if tty else 80
|
|
58
|
+
return Terminal(
|
|
59
|
+
tty=tty,
|
|
60
|
+
color=color,
|
|
61
|
+
truecolor=color and env.get("COLORTERM", "").lower() in TRUECOLOR_VALUES,
|
|
62
|
+
columns=columns,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _detect_windows(
|
|
67
|
+
stream: TextIO, env: Mapping[str, str], vt: Callable[[TextIO], bool]
|
|
68
|
+
) -> Terminal:
|
|
69
|
+
"""Windows consoles set no TERM; a console is a terminal once it accepts ANSI escapes."""
|
|
70
|
+
tty = stream.isatty() and env.get("TERM", "") != "dumb"
|
|
71
|
+
color = tty and not env.get("NO_COLOR") and vt(stream)
|
|
72
|
+
columns = shutil.get_terminal_size(fallback=(80, 24)).columns if tty else 80
|
|
73
|
+
return Terminal(tty=tty, color=color, truecolor=color, columns=columns)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def enable_vt(stream: TextIO) -> bool:
|
|
77
|
+
"""Switch the console behind `stream` to ANSI escape processing; False when it cannot."""
|
|
78
|
+
if sys.platform != "win32":
|
|
79
|
+
return True
|
|
80
|
+
import ctypes
|
|
81
|
+
import msvcrt
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
handle = msvcrt.get_osfhandle(stream.fileno())
|
|
85
|
+
except (OSError, ValueError, AttributeError):
|
|
86
|
+
return False
|
|
87
|
+
kernel32 = ctypes.windll.kernel32
|
|
88
|
+
kernel32.GetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_uint32)]
|
|
89
|
+
kernel32.SetConsoleMode.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
|
90
|
+
mode = ctypes.c_uint32()
|
|
91
|
+
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
92
|
+
return False
|
|
93
|
+
if mode.value & VT_PROCESSING:
|
|
94
|
+
return True
|
|
95
|
+
return bool(kernel32.SetConsoleMode(handle, mode.value | VT_PROCESSING))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def prepare_streams() -> None:
|
|
99
|
+
"""On Windows a redirected stream uses the ANSI code page: make it UTF-8 and never fail."""
|
|
100
|
+
if not host.is_windows():
|
|
101
|
+
return
|
|
102
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
103
|
+
if not isinstance(stream, io.TextIOWrapper) or stream.isatty():
|
|
104
|
+
continue
|
|
105
|
+
with contextlib.suppress(OSError, ValueError):
|
|
106
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|