scriptcast 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.
scriptcast/__init__.py ADDED
File without changes
scriptcast/__main__.py ADDED
@@ -0,0 +1,371 @@
1
+ # scriptcast/__main__.py
2
+ import json
3
+ import logging
4
+ import os
5
+ import platform
6
+ import shlex
7
+ import shutil
8
+ import sys
9
+ import tempfile
10
+ import urllib.request
11
+ import zipfile as _zipfile
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import click
16
+
17
+ from .config import ScriptcastConfig, extract_config_prefix
18
+ from .export import AggNotFoundError, apply_scriptcast_watermark, generate_export
19
+ from .generator import build_config_from_sc_text, generate_from_sc
20
+ from .recorder import record as do_record
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _default_shell() -> str:
26
+ return os.environ.get("SHELL", "bash")
27
+
28
+
29
+ def _resolve_theme(theme: str) -> Path:
30
+ _BUILTIN_THEMES_DIR = Path(__file__).parent / "assets" / "themes"
31
+ builtin = _BUILTIN_THEMES_DIR / f"{theme}.sh"
32
+ theme_path = builtin if builtin.exists() else Path(theme)
33
+ if not theme_path.exists():
34
+ raise click.ClickException(f"Theme not found: {theme!r}")
35
+ return theme_path
36
+
37
+
38
+ def build_config(
39
+ script_path: Path | None,
40
+ theme_path: Path | None,
41
+ directive_prefix: str,
42
+ trace_prefix: str,
43
+ shell: str,
44
+ ) -> ScriptcastConfig:
45
+ """Build a fully resolved ScriptcastConfig before recording starts.
46
+
47
+ Layer order: defaults → theme prefix → script prefix.
48
+ Theme and script prefixes are concatenated into a single tmp .sh,
49
+ recorded (fast — all no-ops/assignments), and fed into
50
+ build_config_from_sc_text so shell variable expansion is handled
51
+ correctly (e.g. : SC set prompt "${GREEN} > ${RESET}").
52
+
53
+ For .sc inputs, also applies the .sc's pre-scene set directives on top.
54
+ """
55
+ base = ScriptcastConfig(
56
+ directive_prefix=directive_prefix,
57
+ trace_prefix=trace_prefix,
58
+ )
59
+
60
+ prefix_parts: list[str] = []
61
+ if theme_path is not None:
62
+ prefix_parts.append(extract_config_prefix(theme_path.read_text(), directive_prefix))
63
+ if script_path is not None and script_path.suffix.lower() == ".sh":
64
+ prefix_parts.append(extract_config_prefix(script_path.read_text(), directive_prefix))
65
+
66
+ if prefix_parts:
67
+ combined = "#!/bin/sh\n" + "\n".join(filter(None, prefix_parts))
68
+ tmp_fd, tmp_sh_str = tempfile.mkstemp(suffix=".sh")
69
+ os.close(tmp_fd)
70
+ tmp_sh = Path(tmp_sh_str)
71
+ tmp_sc = tmp_sh.with_suffix(".sc")
72
+ try:
73
+ tmp_sh.write_text(combined)
74
+ tmp_sh.chmod(0o755)
75
+ do_record(tmp_sh, tmp_sc, base, shell)
76
+ if tmp_sc.exists():
77
+ base = build_config_from_sc_text(tmp_sc.read_text())
78
+ base.directive_prefix = directive_prefix
79
+ base.trace_prefix = trace_prefix
80
+ finally:
81
+ tmp_sh.unlink(missing_ok=True)
82
+ tmp_sc.unlink(missing_ok=True)
83
+
84
+ if script_path is not None and script_path.suffix.lower() == ".sc":
85
+ base = build_config_from_sc_text(script_path.read_text(), base=base)
86
+
87
+ return base
88
+
89
+
90
+ class _ScriptOrSubcommandGroup(click.Group):
91
+ """Click group that treats an unknown first positional as an input file path.
92
+
93
+ Overrides ``parse_args`` so that when the first remaining token after
94
+ option parsing is not a registered subcommand name, the group delegates to
95
+ ``click.Command.parse_args`` instead of ``click.Group.parse_args``. This
96
+ leaves the token in ``ctx.args`` (never in ``ctx._protected_args``) and
97
+ allows ``invoke_without_command=True`` to route straight to the callback.
98
+ No private Click APIs are accessed.
99
+ """
100
+
101
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
102
+ # Use click.Command.parse_args (grandparent) to parse options, which
103
+ # puts remaining tokens into ctx.args without touching _protected_args.
104
+ rest = click.Command.parse_args(self, ctx, args)
105
+ if rest and rest[0] not in self.commands:
106
+ # First remaining token is not a subcommand — treat it as an input
107
+ # file path positional. Keep everything in ctx.args so that
108
+ # Group.invoke sees empty _protected_args and routes via
109
+ # invoke_without_command to our callback.
110
+ return rest
111
+ # First remaining token IS a known subcommand (or there are no tokens).
112
+ # Delegate to the standard Group.parse_args to set up routing correctly.
113
+ # We reset ctx first since Command.parse_args already wrote to ctx.args.
114
+ # ctx.params is already populated from the first pass above.
115
+ # click.Group.parse_args calls Command.parse_args internally, but
116
+ # Parameter.handle_parse_result skips keys already present in ctx.params,
117
+ # so options are not double-applied. This holds as of Click 8.x.
118
+ ctx.args = args
119
+ return click.Group.parse_args(self, ctx, args)
120
+
121
+
122
+ @click.group(
123
+ cls=_ScriptOrSubcommandGroup,
124
+ invoke_without_command=True,
125
+ context_settings={"allow_extra_args": True, "allow_interspersed_args": False},
126
+ )
127
+ @click.option("--output-dir", default=None, type=click.Path())
128
+ @click.option("--no-export", is_flag=True, default=False,
129
+ help="Stop after generating .cast file(s); do not export to image.")
130
+ @click.option("--theme", default=None,
131
+ help="Visual theme: built-in name (e.g. 'aurora') or path to a .sh theme file.")
132
+ @click.option(
133
+ "--format", "output_format",
134
+ default="png",
135
+ type=click.Choice(["gif", "png"]),
136
+ show_default=True,
137
+ help="Export format.",
138
+ )
139
+ @click.option("--directive-prefix", default="SC", show_default=True)
140
+ @click.option("--trace-prefix", default="+", show_default=True)
141
+ @click.option("--shell", default=None)
142
+ @click.option("--split-scenes/--no-split-scenes", default=False)
143
+ @click.option("--verbose", "-v", is_flag=True, default=False, help="Enable debug logging.")
144
+ @click.option("--xtrace-log", is_flag=True, default=False,
145
+ help="Save raw xtrace capture to <stem>.xtrace (only valid for .sh input).")
146
+ @click.pass_context
147
+ def cli(
148
+ ctx: click.Context,
149
+ output_dir: str | None,
150
+ no_export: bool,
151
+ theme: str | None,
152
+ output_format: str,
153
+ directive_prefix: str,
154
+ trace_prefix: str,
155
+ shell: str | None,
156
+ split_scenes: bool,
157
+ verbose: bool,
158
+ xtrace_log: bool,
159
+ ) -> None:
160
+ """Generate terminal demos from shell scripts, .sc files, or .cast files.
161
+
162
+ The input file type determines the start stage:
163
+
164
+ \b
165
+ .sh record → generate → export (all stages)
166
+ .sc generate → export
167
+ .cast export
168
+
169
+ Use --no-export to stop after the generate stage (.sh and .sc only).
170
+
171
+ Options must be placed before the input file path:
172
+
173
+ scriptcast [OPTIONS] INPUT
174
+ """
175
+ log_level = logging.DEBUG if verbose else logging.INFO
176
+ log_format = "%(name)s %(levelname)s %(message)s" if verbose else "%(message)s"
177
+ logging.basicConfig(level=log_level, format=log_format)
178
+
179
+ if ctx.invoked_subcommand is not None:
180
+ return
181
+
182
+ if not ctx.args:
183
+ click.echo(ctx.get_help())
184
+ return
185
+
186
+ if len(ctx.args) > 1:
187
+ raise click.UsageError(
188
+ f"Unexpected arguments after input path: {ctx.args[1:]}. "
189
+ "All options must be placed before the input path."
190
+ )
191
+
192
+ in_path = Path(ctx.args[0])
193
+ if not in_path.exists():
194
+ raise click.ClickException(f"File not found: {ctx.args[0]}")
195
+
196
+ suffix = in_path.suffix.lower()
197
+ if suffix not in (".sh", ".sc", ".cast"):
198
+ raise click.UsageError(
199
+ f"Unsupported file type '{suffix}'. Expected .sh, .sc, or .cast."
200
+ )
201
+
202
+ if no_export and suffix == ".cast":
203
+ raise click.UsageError(
204
+ "--no-export requires a .sh or .sc input (a .cast file is already at the export stage)."
205
+ )
206
+
207
+ if xtrace_log and suffix != ".sh":
208
+ raise click.UsageError(
209
+ "--xtrace-log requires a .sh input (xtrace is captured during recording)."
210
+ )
211
+
212
+ out_dir = Path(output_dir) if output_dir else in_path.parent
213
+ out_dir.mkdir(parents=True, exist_ok=True)
214
+ resolved_shell = shell or _default_shell()
215
+ theme_path = _resolve_theme(theme) if theme else None
216
+
217
+ config = build_config(
218
+ script_path=in_path if suffix != ".cast" else None,
219
+ theme_path=theme_path,
220
+ directive_prefix=directive_prefix,
221
+ trace_prefix=trace_prefix,
222
+ shell=resolved_shell,
223
+ )
224
+ logger.debug(
225
+ "Config: width=%d height=%d type_speed=%d prompt=%r",
226
+ config.width, config.height, config.type_speed, config.prompt,
227
+ )
228
+
229
+ # Stage 1: record (only for .sh input)
230
+ sc_path: Path | None = None
231
+ if suffix == ".sh":
232
+ sc_path = out_dir / in_path.with_suffix(".sc").name
233
+ logger.info("Recording %s ...", in_path.name)
234
+ do_record(in_path, sc_path, config, resolved_shell, xtrace_log=xtrace_log)
235
+ elif suffix == ".sc":
236
+ sc_path = in_path
237
+
238
+ # Stage 2: generate .cast(s)
239
+ if suffix == ".cast":
240
+ cast_paths = [in_path]
241
+ else:
242
+ assert sc_path is not None
243
+ logger.info("Generating .cast ...")
244
+ cast_paths = generate_from_sc(
245
+ sc_path,
246
+ out_dir,
247
+ output_stem=in_path.stem,
248
+ split_scenes=split_scenes,
249
+ base=config,
250
+ )
251
+
252
+ if no_export:
253
+ for p in cast_paths:
254
+ logger.info("Generated: %s", p)
255
+ return
256
+
257
+ # Stage 3: export
258
+ logger.info("Exporting to %s ...", output_format.upper())
259
+ for cast_path in cast_paths:
260
+ _bar: list[Any] = [None]
261
+
262
+ def on_frame(current: int, total: int) -> None:
263
+ if _bar[0] is None:
264
+ pb = click.progressbar(
265
+ length=total,
266
+ label=f"{output_format.upper()} ",
267
+ width=0,
268
+ show_eta=True,
269
+ file=sys.stderr,
270
+ )
271
+ pb.__enter__()
272
+ _bar[0] = pb
273
+ _bar[0].update(1)
274
+
275
+ try:
276
+ export_path = generate_export(
277
+ cast_path,
278
+ config.theme if config.theme.frame else None,
279
+ format=output_format,
280
+ on_frame=on_frame,
281
+ )
282
+ if not config.theme.frame and config.theme.scriptcast_watermark:
283
+ apply_scriptcast_watermark(export_path, config.theme)
284
+ except (AggNotFoundError, RuntimeError) as e:
285
+ raise click.ClickException(str(e))
286
+ finally:
287
+ if _bar[0] is not None:
288
+ _bar[0].__exit__(None, None, None)
289
+ logger.info("Generated: %s", export_path)
290
+
291
+
292
+ @cli.command()
293
+ @click.option(
294
+ "--prefix",
295
+ default=str(Path.home() / ".local" / "bin"),
296
+ show_default=True,
297
+ help="Installation directory for agg and fonts.",
298
+ )
299
+ def install(prefix: str) -> None:
300
+ """Install the agg binary and JetBrains Mono fonts."""
301
+ prefix_path = Path(prefix).expanduser()
302
+ prefix_path.mkdir(parents=True, exist_ok=True)
303
+ fonts_dir = prefix_path / "fonts"
304
+ fonts_dir.mkdir(exist_ok=True)
305
+
306
+ machine = platform.machine().lower()
307
+ if sys.platform == "linux":
308
+ if machine == "x86_64":
309
+ agg_asset = "agg-x86_64-unknown-linux-gnu"
310
+ elif machine in ("aarch64", "arm64"):
311
+ agg_asset = "agg-aarch64-unknown-linux-gnu"
312
+ else:
313
+ raise click.ClickException(f"Unsupported Linux architecture: {machine}")
314
+ elif sys.platform == "darwin":
315
+ if machine in ("arm64", "aarch64"):
316
+ agg_asset = "agg-aarch64-apple-darwin"
317
+ else:
318
+ agg_asset = "agg-x86_64-apple-darwin"
319
+ else:
320
+ raise click.ClickException(f"Unsupported OS: {sys.platform}")
321
+
322
+ agg_url = f"https://github.com/asciinema/agg/releases/latest/download/{agg_asset}"
323
+
324
+ with urllib.request.urlopen(
325
+ "https://api.github.com/repos/JetBrains/JetBrainsMono/releases/latest"
326
+ ) as _resp:
327
+ _release = json.loads(_resp.read())
328
+ font_url = next(
329
+ a["browser_download_url"]
330
+ for a in _release["assets"]
331
+ if a["name"].endswith(".zip") and "JetBrainsMono" in a["name"]
332
+ )
333
+
334
+ agg_bin = prefix_path / ".agg-real"
335
+ click.echo("Downloading agg ...")
336
+ urllib.request.urlretrieve(agg_url, agg_bin)
337
+ agg_bin.chmod(0o755)
338
+
339
+ agg_wrapper = prefix_path / "agg"
340
+ agg_wrapper.write_text(
341
+ '#!/bin/sh\n'
342
+ 'exec "$(dirname "$0")/.agg-real"'
343
+ ' --font-dir "$(dirname "$0")/fonts"'
344
+ ' --font-family "JetBrains Mono"'
345
+ ' "$@"\n'
346
+ )
347
+ agg_wrapper.chmod(0o755)
348
+
349
+ click.echo("Downloading JetBrains Mono fonts ...")
350
+ with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tf:
351
+ zip_path = Path(tf.name)
352
+ try:
353
+ urllib.request.urlretrieve(font_url, zip_path)
354
+ with _zipfile.ZipFile(zip_path) as zf:
355
+ for name in zf.namelist():
356
+ if name.startswith("fonts/ttf/") and name.endswith(".ttf"):
357
+ (fonts_dir / Path(name).name).write_bytes(zf.read(name))
358
+ finally:
359
+ zip_path.unlink(missing_ok=True)
360
+
361
+ click.echo(f"Installed to {prefix_path}")
362
+ if shutil.which("agg") is None:
363
+ click.echo(f"Warning: {prefix_path} is not in PATH. Add it to use agg directly:", err=True)
364
+ click.echo(f' export PATH="{prefix_path}:$PATH"', err=True)
365
+
366
+
367
+ if __name__ == "__main__":
368
+ if len(sys.argv) >= 2 and sys.argv[1].startswith("--") and " " in sys.argv[1]:
369
+ extra = shlex.split(sys.argv[1])
370
+ sys.argv = [sys.argv[0]] + extra + sys.argv[2:]
371
+ cli()
File without changes
Binary file
@@ -0,0 +1,20 @@
1
+ # scriptcast built-in theme: aurora
2
+ : SC set terminal-theme dark
3
+ : SC set theme-frame true
4
+ : SC set theme-frame-bar true
5
+ : SC set theme-frame-bar-title Terminal
6
+ : SC set theme-frame-bar-color 252535
7
+ : SC set theme-frame-bar-buttons true
8
+ : SC set theme-scriptcast-watermark true
9
+ : SC set theme-background 1e1b4b,0d3b66
10
+ : SC set theme-margin 82 82 120
11
+ : SC set theme-padding 14
12
+ : SC set theme-radius 12
13
+ : SC set theme-border-color ffffff30
14
+ : SC set theme-border-width 1
15
+ : SC set theme-shadow true
16
+ : SC set theme-shadow-color 0000004d
17
+ : SC set theme-shadow-radius 20
18
+ : SC set theme-shadow-offset-y 21
19
+ : SC set theme-shadow-offset-x 0
20
+ : SC set prompt "\x1b[92m> \x1b[0m"
@@ -0,0 +1,19 @@
1
+ # scriptcast built-in theme: dark
2
+ : SC set terminal-theme dark
3
+ : SC set theme-frame true
4
+ : SC set theme-frame-bar true
5
+ : SC set theme-frame-bar-title Terminal
6
+ : SC set theme-frame-bar-color ffffff30
7
+ : SC set theme-frame-bar-buttons true
8
+ : SC set theme-scriptcast-watermark true
9
+ : SC set theme-background 1a1a2e,16213e
10
+ : SC set theme-margin 82 82 120
11
+ : SC set theme-padding 14
12
+ : SC set theme-radius 12
13
+ : SC set theme-border-color ffffff30
14
+ : SC set theme-border-width 1
15
+ : SC set theme-shadow true
16
+ : SC set theme-shadow-color 0000004d
17
+ : SC set theme-shadow-radius 20
18
+ : SC set theme-shadow-offset-y 21
19
+ : SC set theme-shadow-offset-x 0
@@ -0,0 +1,19 @@
1
+ # scriptcast built-in theme: light
2
+ : SC set terminal-theme light
3
+ : SC set theme-frame true
4
+ : SC set theme-frame-bar true
5
+ : SC set theme-frame-bar-title Terminal
6
+ : SC set theme-frame-bar-color e8ecf8
7
+ : SC set theme-frame-bar-buttons true
8
+ : SC set theme-scriptcast-watermark true
9
+ : SC set theme-background f0f4ff,e2e8f8
10
+ : SC set theme-border-color 00000020
11
+ : SC set theme-border-width 1
12
+ : SC set theme-margin 82 82 120
13
+ : SC set theme-padding 14
14
+ : SC set theme-radius 12
15
+ : SC set theme-shadow true
16
+ : SC set theme-shadow-color 00000030
17
+ : SC set theme-shadow-radius 20
18
+ : SC set theme-shadow-offset-y 21
19
+ : SC set theme-shadow-offset-x 0
scriptcast/config.py ADDED
@@ -0,0 +1,199 @@
1
+ # scriptcast/config.py
2
+ import copy
3
+ import re as _re
4
+ import typing
5
+ from dataclasses import dataclass, field
6
+
7
+ _INT_KEYS = {
8
+ "type_speed", "cmd_wait", "input_wait", "exit_wait",
9
+ "width", "height", "enter_wait", "word_speed", "cr_delay",
10
+ }
11
+ _STR_KEYS = {"terminal_theme", "prompt", "directive_prefix", "trace_prefix"}
12
+ _BOOL_KEYS = {"split_scenes"}
13
+
14
+ _INT_THEME_PROPS = {
15
+ "radius", "border-width",
16
+ "shadow-radius", "shadow-offset-y", "shadow-offset-x", "watermark-size",
17
+ "margin-top", "margin-right", "margin-bottom", "margin-left",
18
+ "padding-top", "padding-right", "padding-bottom", "padding-left",
19
+ }
20
+ _BOOL_THEME_PROPS = {"shadow", "frame-bar", "frame-bar-buttons", "frame", "scriptcast-watermark"}
21
+
22
+
23
+ def _parse_css_shorthand(value: str) -> tuple[int, int, int, int]:
24
+ """Parse 1–4 space-separated ints using CSS shorthand rules (top, right, bottom, left)."""
25
+ vals = [int(p) for p in value.split()]
26
+ match len(vals):
27
+ case 1:
28
+ return (vals[0], vals[0], vals[0], vals[0])
29
+ case 2:
30
+ return (vals[0], vals[1], vals[0], vals[1])
31
+ case 3:
32
+ return (vals[0], vals[1], vals[2], vals[1])
33
+ case 4:
34
+ return (vals[0], vals[1], vals[2], vals[3])
35
+ case _:
36
+ raise ValueError(f"CSS shorthand expects 1-4 values, got {len(vals)}: {value!r}")
37
+
38
+
39
+ @dataclass
40
+ class ThemeConfig:
41
+ # Inner padding — individual sides
42
+ padding_top: int = 14
43
+ padding_right: int = 14
44
+ padding_bottom: int = 14
45
+ padding_left: int = 14
46
+
47
+ # Window appearance
48
+ radius: int = 12
49
+ border_color: str = "ffffff30" # RGBA hex, no # prefix
50
+ border_width: int = 1
51
+
52
+ # Outer background (None = no background)
53
+ background: str | None = "1e1b4b,0d3b66" # aurora gradient
54
+
55
+ # Outer margins — individual sides (None = auto: 0 if no bg, 82 if bg set)
56
+ margin_top: int | None = None
57
+ margin_right: int | None = None
58
+ margin_bottom: int | None = None
59
+ margin_left: int | None = None
60
+
61
+ # Drop shadow
62
+ shadow: bool = True
63
+ shadow_color: str = "0000004d" # RGBA hex, no # prefix
64
+ shadow_radius: int = 20
65
+ shadow_offset_y: int = 21
66
+ shadow_offset_x: int = 0
67
+
68
+ # Title bar
69
+ frame_bar: bool = True
70
+ frame_bar_title: str = "Terminal"
71
+ frame_bar_color: str = "252535"
72
+ frame_bar_buttons: bool = True
73
+
74
+ # Watermark (opt-in)
75
+ watermark: str | None = None
76
+ watermark_color: str = "ffffff"
77
+ watermark_size: int | None = None
78
+
79
+ # Frame style
80
+ frame: bool = True
81
+
82
+ # Scriptcast brand watermark (opt-out)
83
+ scriptcast_watermark: bool = True
84
+
85
+ def apply(self, key: str, value: str) -> None:
86
+ """Apply a single theme key-value pair. key is dash-form without 'theme-' prefix."""
87
+ if key == "margin":
88
+ t, r, b, left = _parse_css_shorthand(value)
89
+ self.margin_top, self.margin_right = t, r
90
+ self.margin_bottom, self.margin_left = b, left
91
+ elif key == "padding":
92
+ t, r, b, left = _parse_css_shorthand(value)
93
+ self.padding_top, self.padding_right = t, r
94
+ self.padding_bottom, self.padding_left = b, left
95
+ elif key in _INT_THEME_PROPS:
96
+ setattr(self, key.replace("-", "_"), int(value))
97
+ elif key in _BOOL_THEME_PROPS:
98
+ setattr(self, key.replace("-", "_"), value.lower() in ("1", "true", "yes"))
99
+ else:
100
+ attr = key.replace("-", "_")
101
+ if hasattr(self, attr):
102
+ hints = typing.get_type_hints(type(self))
103
+ hint = hints.get(attr)
104
+ is_nullable = hint is not None and type(None) in typing.get_args(hint)
105
+ setattr(self, attr, None if (value.lower() == "none" and is_nullable) else value)
106
+
107
+
108
+ @dataclass
109
+ class ScriptcastConfig:
110
+ type_speed: int = 40 # ms per character when typing commands
111
+ cmd_wait: int = 80 # ms after typing a command, before output appears
112
+ input_wait: int = 80 # ms to pause before typing an InputLine response
113
+ exit_wait: int = 120 # ms after last output line of a command
114
+ enter_wait: int = 80 # ms to pause at scene start, after clear and optional title
115
+ word_speed: int | None = None # ms extra pause after each space; None = same as type_speed
116
+ cr_delay: int = 0 # ms to wait between \r-split segments of an out event
117
+ width: int = 100
118
+ height: int = 28
119
+ terminal_theme: str = "dark" # was: theme
120
+ prompt: str = "$ "
121
+ directive_prefix: str = "SC"
122
+ trace_prefix: str = "+"
123
+ split_scenes: bool = False
124
+ theme: ThemeConfig = field(default_factory=ThemeConfig)
125
+
126
+ def apply(self, name: str, args: list[str]) -> None:
127
+ """Apply an SC directive. Only 'set' directives mutate config; others are ignored."""
128
+ if name != "set" or len(args) < 2:
129
+ return
130
+ raw_key = args[0] # original dash-form key
131
+ key = raw_key.replace("-", "_") # underscore-normalised
132
+ value = " ".join(args[1:]) # join multi-word values (e.g. "14 0 0 0")
133
+ if raw_key.startswith("theme-"):
134
+ self.theme.apply(raw_key[6:], value) # strip "theme-" prefix, pass dash form
135
+ elif key in _INT_KEYS:
136
+ setattr(self, key, int(value))
137
+ elif key in _STR_KEYS:
138
+ setattr(self, key, value)
139
+ elif key in _BOOL_KEYS:
140
+ setattr(self, key, value.lower() in ("1", "true", "yes"))
141
+
142
+ def copy(self) -> "ScriptcastConfig":
143
+ return copy.deepcopy(self)
144
+
145
+ @property
146
+ def effective_word_pause_s(self) -> float:
147
+ """Pause to insert after each space when typing, in seconds."""
148
+ ms = self.word_speed if self.word_speed is not None else self.type_speed
149
+ return ms / 1000.0
150
+
151
+
152
+ def extract_config_prefix(sh_text: str, directive_prefix: str = "SC") -> str:
153
+ """Return the config-safe prefix of a .sh script.
154
+
155
+ Collects lines from the top of the script until the first non-config-safe
156
+ line is encountered. Config-safe lines are:
157
+ - Blank lines and comments
158
+ - Variable assignments (FOO=..., export FOO=..., readonly FOO=...)
159
+ - `: <prefix> set ...` directives
160
+ - `: <prefix> record pause` ... `: <prefix> record resume` blocks
161
+
162
+ Stops at (and excludes) the first line that is any other SC directive
163
+ (scene, type, expect, filter, sleep, mock, ...) or any real shell command.
164
+ """
165
+ dp = _re.escape(directive_prefix)
166
+ _sc_set = _re.compile(rf"^\s*:\s+{dp}\s+set\b")
167
+ _sc_rec_pause = _re.compile(rf"^\s*:\s+{dp}\s+record\s+pause\b")
168
+ _sc_rec_resume = _re.compile(rf"^\s*:\s+{dp}\s+record\s+resume\b")
169
+ _sc_any = _re.compile(rf"^\s*:\s+{dp}\s+\w+")
170
+ _var_assign = _re.compile(r"^(export\s+|readonly\s+)?\w+=")
171
+ _blank_or_comment = _re.compile(r"^\s*(#.*)?$")
172
+
173
+ result: list[str] = []
174
+ in_pause = False
175
+
176
+ for raw_line in sh_text.splitlines(keepends=True):
177
+ line = raw_line.rstrip("\n").rstrip("\r")
178
+
179
+ if in_pause:
180
+ result.append(raw_line)
181
+ if _sc_rec_resume.match(line):
182
+ in_pause = False
183
+ continue
184
+
185
+ if _blank_or_comment.match(line):
186
+ result.append(raw_line)
187
+ elif _sc_rec_pause.match(line):
188
+ result.append(raw_line)
189
+ in_pause = True
190
+ elif _sc_set.match(line):
191
+ result.append(raw_line)
192
+ elif _sc_any.match(line):
193
+ break # non-config-safe SC directive
194
+ elif _var_assign.match(line):
195
+ result.append(raw_line)
196
+ else:
197
+ break # real shell command
198
+
199
+ return "".join(result)