@tandryukha/aidemo 0.8.0

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/src/embed.ts ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Always-fresh embeds at zero cost — pure snippet generation (no network, no
3
+ * render). The hosting story is GitHub itself: CI publishes rendered media to a
4
+ * dedicated orphan branch `demo-media` in the CONSUMING repo, at stable paths
5
+ * `media/<demo>/latest.mp4|latest.gif|stills/<name>.png`. The raw URLs below
6
+ * never change, so a README/blog/docs embed updates itself whenever CI
7
+ * re-renders. See docs/EMBEDS.md for the recipe + cache caveats and
8
+ * examples/workflows/demo-publish.yml for the publish workflow.
9
+ *
10
+ * This module only does string generation: it reads the repo's `origin` remote
11
+ * to derive owner/repo and takes the demo name from the directory. It never
12
+ * hits the network beyond the local `git` invocation, and never renders.
13
+ */
14
+ import { execFile } from "node:child_process";
15
+ import { promisify } from "node:util";
16
+ import { resolve, isAbsolute, basename } from "node:path";
17
+ import { exists } from "./util.js";
18
+ import { UPSTREAM_REPO } from "./distribute.js";
19
+
20
+ const execFileAsync = promisify(execFile);
21
+
22
+ /** The dedicated orphan branch CI force-resets on each publish. */
23
+ export const DEMO_MEDIA_BRANCH = "demo-media";
24
+ /** Where the publish workflow lives inside a consuming repo. */
25
+ export const PUBLISH_WORKFLOW_REL = ".github/workflows/demo-publish.yml";
26
+ /** Copy-pasteable pointer to the workflow template in the engine repo. */
27
+ export const PUBLISH_WORKFLOW_TEMPLATE = `https://github.com/${UPSTREAM_REPO}/blob/stable/examples/workflows/demo-publish.yml`;
28
+
29
+ export interface EmbedOptions {
30
+ /** Consuming-repo dir to detect owner/repo from. Default: process.cwd(). */
31
+ repoDir?: string;
32
+ /** Still-frame basename under stills/ (no extension). Default: "poster". */
33
+ still?: string;
34
+ }
35
+
36
+ export interface EmbedResult {
37
+ owner: string;
38
+ repo: string;
39
+ /** Demo name = basename of the demo directory. */
40
+ demo: string;
41
+ branch: string;
42
+ still: string;
43
+ urls: {
44
+ /** raw.githubusercontent GIF — embeds in READMEs, comments, most sites. */
45
+ gif: string;
46
+ /** raw.githubusercontent MP4 — serves as application/octet-stream (see docs). */
47
+ mp4: string;
48
+ /** raw.githubusercontent still PNG. */
49
+ still: string;
50
+ /** GitHub Pages MP4 (correct video/mp4 MIME for <video>) — needs Pages on the branch. */
51
+ pagesMp4: string;
52
+ /** GitHub Pages project-site base. */
53
+ pagesBase: string;
54
+ };
55
+ snippets: {
56
+ /** Markdown animated-GIF embed — for READMEs, issues, PR comments. */
57
+ markdownGif: string;
58
+ /** Markdown still-image embed. */
59
+ markdownStill: string;
60
+ /** HTML <video> (docs sites / blogs) using the Pages MP4 URL. */
61
+ htmlVideo: string;
62
+ };
63
+ workflow: {
64
+ /** Path the publish workflow lives at inside the consuming repo. */
65
+ path: string;
66
+ /** Whether that file is already present. */
67
+ present: boolean;
68
+ /** Pointer to the template to copy if it isn't. */
69
+ template: string;
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Parse an owner/repo slug out of a GitHub remote URL. Handles the common
75
+ * forms: scp-like `git@github.com:owner/repo.git`, `https://github.com/owner/repo(.git)`,
76
+ * and `ssh://git@github.com/owner/repo.git`. Returns null for non-github hosts.
77
+ */
78
+ export function parseGitHubSlug(
79
+ remoteUrl: string
80
+ ): { owner: string; repo: string } | null {
81
+ const s = remoteUrl.trim().replace(/\/+$/, "");
82
+ const scp = s.match(/^[^@]+@([^:]+):(?<owner>[^/]+)\/(?<repo>.+?)(?:\.git)?$/);
83
+ const url = s.match(
84
+ /^(?:https?|ssh|git):\/\/(?:[^@/]+@)?(?<host>[^/]+)\/(?<owner>[^/]+)\/(?<repo>.+?)(?:\.git)?$/
85
+ );
86
+ const host = scp ? scp[1] : url?.groups?.host;
87
+ const m = scp ?? url;
88
+ if (!m?.groups || !host || !/(^|\.)github\.com$/i.test(host)) return null;
89
+ return { owner: m.groups.owner, repo: m.groups.repo };
90
+ }
91
+
92
+ async function detectSlug(
93
+ repoDir: string
94
+ ): Promise<{ owner: string; repo: string }> {
95
+ let url: string;
96
+ try {
97
+ const { stdout } = await execFileAsync(
98
+ "git",
99
+ ["-C", repoDir, "remote", "get-url", "origin"],
100
+ { timeout: 5000 }
101
+ );
102
+ url = stdout.trim();
103
+ } catch {
104
+ throw new Error(
105
+ `could not read the 'origin' remote in ${repoDir} — run \`aidemo embed\` ` +
106
+ `inside the consuming repo, or pass --repo <dir>`
107
+ );
108
+ }
109
+ const slug = parseGitHubSlug(url);
110
+ if (!slug) {
111
+ throw new Error(
112
+ `origin remote is not a github.com repo (${url || "empty"}) — always-fresh ` +
113
+ `embeds use raw.githubusercontent.com URLs, which need a GitHub-hosted repo`
114
+ );
115
+ }
116
+ return slug;
117
+ }
118
+
119
+ /**
120
+ * Build the stable embed URLs + ready-to-paste snippets for one demo. Pure:
121
+ * the only side effect is reading git config + checking whether the publish
122
+ * workflow file exists.
123
+ */
124
+ export async function buildEmbed(
125
+ dir: string,
126
+ opts: EmbedOptions = {}
127
+ ): Promise<EmbedResult> {
128
+ const demoDir = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
129
+ const demo = basename(demoDir);
130
+ const repoDir = opts.repoDir
131
+ ? resolve(process.cwd(), opts.repoDir)
132
+ : process.cwd();
133
+ const { owner, repo } = await detectSlug(repoDir);
134
+ const still = (opts.still ?? "poster").replace(/\.png$/i, "");
135
+
136
+ const rawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${DEMO_MEDIA_BRANCH}/media/${demo}`;
137
+ const gif = `${rawBase}/latest.gif`;
138
+ const mp4 = `${rawBase}/latest.mp4`;
139
+ const stillUrl = `${rawBase}/stills/${still}.png`;
140
+ // GitHub Pages project-site host is the lowercased owner login.
141
+ const pagesBase = `https://${owner.toLowerCase()}.github.io/${repo}`;
142
+ const pagesMp4 = `${pagesBase}/media/${demo}/latest.mp4`;
143
+
144
+ const markdownGif = `![${demo} demo](${gif})`;
145
+ const markdownStill = `![${demo}](${stillUrl})`;
146
+ const htmlVideo =
147
+ `<video controls muted loop playsinline width="960" poster="${stillUrl}">\n` +
148
+ ` <source src="${pagesMp4}" type="video/mp4" />\n` +
149
+ ` <a href="${gif}">▶ Watch the ${demo} demo</a>\n` +
150
+ `</video>`;
151
+
152
+ const workflowPath = resolve(repoDir, PUBLISH_WORKFLOW_REL);
153
+ const present = await exists(workflowPath);
154
+
155
+ return {
156
+ owner,
157
+ repo,
158
+ demo,
159
+ branch: DEMO_MEDIA_BRANCH,
160
+ still,
161
+ urls: { gif, mp4, still: stillUrl, pagesMp4, pagesBase },
162
+ snippets: { markdownGif, markdownStill, htmlVideo },
163
+ workflow: {
164
+ path: PUBLISH_WORKFLOW_REL,
165
+ present,
166
+ template: PUBLISH_WORKFLOW_TEMPLATE,
167
+ },
168
+ };
169
+ }
170
+
171
+ /** Human-readable, copy-paste-friendly rendering of the snippets (stdout). */
172
+ export function formatEmbed(r: EmbedResult): string {
173
+ const out: string[] = [];
174
+ out.push(`# Always-fresh embeds — ${r.owner}/${r.repo}, demo "${r.demo}"`);
175
+ out.push(
176
+ `# Media publishes to the '${r.branch}' branch; these URLs never change,`
177
+ );
178
+ out.push(`# so the embed updates itself whenever CI re-renders.`);
179
+ out.push("");
180
+ out.push("## Markdown — animated GIF (READMEs, issues, PR comments)");
181
+ out.push(r.snippets.markdownGif);
182
+ out.push("");
183
+ out.push("## Markdown — still image (poster)");
184
+ out.push(r.snippets.markdownStill);
185
+ out.push("");
186
+ out.push(
187
+ "## HTML — <video> (docs sites / blogs; needs GitHub Pages on the branch for correct mp4 MIME)"
188
+ );
189
+ out.push(r.snippets.htmlVideo);
190
+ out.push("");
191
+ if (r.workflow.present) {
192
+ out.push(
193
+ `# ✓ ${r.workflow.path} present — CI keeps these URLs fresh on every render.`
194
+ );
195
+ } else {
196
+ out.push(`# ⚠ ${r.workflow.path} is absent — the URLs won't populate yet.`);
197
+ out.push(`# Add the publish workflow so CI keeps them fresh:`);
198
+ out.push(`# ${r.workflow.template}`);
199
+ }
200
+ out.push(`# Recipe + cache caveats: docs/EMBEDS.md`);
201
+ return out.join("\n") + "\n";
202
+ }
package/src/ffmpeg.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ /** Run ffmpeg with the given args; rejects with stderr tail on failure. */
4
+ export function runFfmpeg(args: string[]): Promise<void> {
5
+ return run("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...args]);
6
+ }
7
+
8
+ function run(bin: string, args: string[]): Promise<void> {
9
+ return new Promise((resolve, reject) => {
10
+ const proc = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
11
+ let stderr = "";
12
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
13
+ proc.on("error", reject);
14
+ proc.on("close", (code) => {
15
+ if (code === 0) resolve();
16
+ else
17
+ reject(
18
+ new Error(
19
+ `${bin} exited ${code}\n${stderr.split("\n").slice(-25).join("\n")}`
20
+ )
21
+ );
22
+ });
23
+ });
24
+ }
25
+
26
+ /** Probe a video file's frame dimensions. */
27
+ export function probeVideoDims(
28
+ file: string
29
+ ): Promise<{ width: number; height: number }> {
30
+ return new Promise((resolve, reject) => {
31
+ const proc = spawn("ffprobe", [
32
+ "-v",
33
+ "quiet",
34
+ "-print_format",
35
+ "json",
36
+ "-select_streams",
37
+ "v:0",
38
+ "-show_streams",
39
+ file,
40
+ ]);
41
+ let out = "";
42
+ proc.stdout.on("data", (d) => (out += d.toString()));
43
+ proc.on("error", reject);
44
+ proc.on("close", (code) => {
45
+ if (code !== 0) return reject(new Error(`ffprobe exited ${code}`));
46
+ try {
47
+ const s = JSON.parse(out).streams?.[0];
48
+ if (!s?.width || !s?.height)
49
+ return reject(new Error(`No video stream in ${file}`));
50
+ resolve({ width: s.width, height: s.height });
51
+ } catch (e) {
52
+ reject(e);
53
+ }
54
+ });
55
+ });
56
+ }
57
+
58
+ /** Probe a media file's duration in milliseconds. */
59
+ export function probeDurationMs(file: string): Promise<number> {
60
+ return new Promise((resolve, reject) => {
61
+ const proc = spawn("ffprobe", [
62
+ "-v",
63
+ "quiet",
64
+ "-print_format",
65
+ "json",
66
+ "-show_format",
67
+ file,
68
+ ]);
69
+ let out = "";
70
+ proc.stdout.on("data", (d) => (out += d.toString()));
71
+ proc.on("error", reject);
72
+ proc.on("close", (code) => {
73
+ if (code !== 0) return reject(new Error(`ffprobe exited ${code}`));
74
+ try {
75
+ const json = JSON.parse(out);
76
+ const sec = parseFloat(json.format?.duration ?? "0");
77
+ resolve(Math.round(sec * 1000));
78
+ } catch (e) {
79
+ reject(e);
80
+ }
81
+ });
82
+ });
83
+ }
package/src/gif.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { resolve, isAbsolute, dirname } from "node:path";
3
+ import { Project } from "./project.js";
4
+ import { runFfmpeg } from "./ffmpeg.js";
5
+ import { ensureDir, exists, ok, step } from "./util.js";
6
+
7
+ export interface GifOptions {
8
+ /** Output width in px (height keeps aspect). Default 960. */
9
+ width?: number;
10
+ /** GIF frame rate. Default 12. */
11
+ fps?: number;
12
+ /** Output path. Default <dir>/output/final-demo.gif. */
13
+ out?: string;
14
+ }
15
+
16
+ /**
17
+ * Convert the composed MP4 into a README-ready GIF (GIFs autoplay on GitHub;
18
+ * MP4s don't). Classic high-quality two-pass: palettegen → paletteuse, with
19
+ * ordered bayer dithering — flat UI colors stay clean instead of getting the
20
+ * error-diffusion "crawl". Both filters are baseline ffmpeg.
21
+ */
22
+ export async function exportGif(
23
+ project: Project,
24
+ opts: GifOptions = {}
25
+ ): Promise<string> {
26
+ const input = project.outputPath;
27
+ if (!(await exists(input))) {
28
+ throw new Error(
29
+ `No final video at ${input} — run \`aidemo compose ${project.dir}\` first.`
30
+ );
31
+ }
32
+ const width = opts.width ?? 960;
33
+ const fps = opts.fps ?? 12;
34
+ const out = opts.out
35
+ ? isAbsolute(opts.out)
36
+ ? opts.out
37
+ : resolve(process.cwd(), opts.out)
38
+ : project.gifPath;
39
+ await ensureDir(dirname(out));
40
+
41
+ step(`Exporting GIF (${width}px @ ${fps}fps, two-pass palette)`);
42
+ const tmp = project.gifTmpDir;
43
+ await fs.rm(tmp, { recursive: true, force: true });
44
+ await ensureDir(tmp);
45
+ const palette = resolve(tmp, "palette.png");
46
+ const frames = `fps=${fps},scale=${width}:-1:flags=lanczos`;
47
+ await runFfmpeg(["-i", input, "-vf", `${frames},palettegen`, palette]);
48
+ await runFfmpeg([
49
+ "-i",
50
+ input,
51
+ "-i",
52
+ palette,
53
+ "-filter_complex",
54
+ `${frames}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`,
55
+ out,
56
+ ]);
57
+ // AIDEMO_KEEP_TMP=1 keeps .gif-tmp (palette) for debugging, like compose.
58
+ if (!process.env.AIDEMO_KEEP_TMP) {
59
+ await fs.rm(tmp, { recursive: true, force: true });
60
+ }
61
+
62
+ const { size } = await fs.stat(out);
63
+ ok(`gif → ${out} (${(size / 1024 / 1024).toFixed(1)} MB)`);
64
+ return out;
65
+ }
package/src/golden.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { promises as fs } from "node:fs";
2
+ import type { Project } from "./project.js";
3
+ import {
4
+ ProbeGoldenSchema,
5
+ type ProbeGolden,
6
+ type ProbeGoldenScene,
7
+ type Storyboard,
8
+ } from "./types.js";
9
+ import { writeJson, exists } from "./util.js";
10
+
11
+ /**
12
+ * Golden probe/timeline diffing — a demo doubles as a regression test (vhs's
13
+ * golden-file pattern). `aidemo probe --update-golden` writes a NORMALIZED,
14
+ * deterministic projection of a probe run to golden/probe.json; `--golden`
15
+ * re-runs the probe, normalizes, and deep-compares. The projection is
16
+ * timing-free by construction (see ProbeActionOutcome — only op, resolved
17
+ * target, ok, found, and goto final URL), so it is stable across runs and a
18
+ * mismatch means a real change in the flow (a moved/renamed selector, a broken
19
+ * navigation), not clock jitter.
20
+ */
21
+
22
+ /** Assemble the normalized golden from the storyboard + per-scene outcomes. */
23
+ export function buildProbeGolden(
24
+ storyboard: Storyboard,
25
+ scenes: ProbeGoldenScene[]
26
+ ): ProbeGolden {
27
+ // Parse so the committed baseline and the freshly-built comparison are shaped
28
+ // identically (defaults applied, stray fields stripped) — the diff then only
29
+ // reflects genuine outcome drift.
30
+ return ProbeGoldenSchema.parse({
31
+ version: 1,
32
+ title: storyboard.title,
33
+ video: storyboard.video,
34
+ scenes,
35
+ });
36
+ }
37
+
38
+ export async function writeProbeGolden(
39
+ project: Project,
40
+ golden: ProbeGolden
41
+ ): Promise<void> {
42
+ await writeJson(project.goldenProbePath, golden);
43
+ }
44
+
45
+ /** Read + validate the committed baseline. Null when it doesn't exist yet. */
46
+ export async function readProbeGolden(
47
+ project: Project
48
+ ): Promise<ProbeGolden | null> {
49
+ if (!(await exists(project.goldenProbePath))) return null;
50
+ const raw = JSON.parse(await fs.readFile(project.goldenProbePath, "utf8"));
51
+ return ProbeGoldenSchema.parse(raw);
52
+ }
53
+
54
+ /**
55
+ * A readable, field-level deep diff: each line is `path: expected X, got Y`
56
+ * (e.g. `$.scenes[2].actions[0].ok: expected true, got false`). Empty array =
57
+ * identical. Treats a missing key and an `undefined` value as equal so an
58
+ * omitted optional field never shows as a spurious diff.
59
+ */
60
+ export function diffGolden(
61
+ expected: unknown,
62
+ actual: unknown,
63
+ path = "$"
64
+ ): string[] {
65
+ if (isScalar(expected) || isScalar(actual)) {
66
+ return valuesEqual(expected, actual)
67
+ ? []
68
+ : [`${path}: expected ${fmt(expected)}, got ${fmt(actual)}`];
69
+ }
70
+ const diffs: string[] = [];
71
+ if (Array.isArray(expected) || Array.isArray(actual)) {
72
+ const ea = Array.isArray(expected) ? expected : [];
73
+ const aa = Array.isArray(actual) ? actual : [];
74
+ if (ea.length !== aa.length) {
75
+ diffs.push(`${path}.length: expected ${ea.length}, got ${aa.length}`);
76
+ }
77
+ const n = Math.max(ea.length, aa.length);
78
+ for (let i = 0; i < n; i++) {
79
+ diffs.push(...diffGolden(ea[i], aa[i], `${path}[${i}]`));
80
+ }
81
+ return diffs;
82
+ }
83
+ const eo = (expected ?? {}) as Record<string, unknown>;
84
+ const ao = (actual ?? {}) as Record<string, unknown>;
85
+ const keys = [...new Set([...Object.keys(eo), ...Object.keys(ao)])].sort();
86
+ for (const k of keys) {
87
+ diffs.push(...diffGolden(eo[k], ao[k], `${path}.${k}`));
88
+ }
89
+ return diffs;
90
+ }
91
+
92
+ function isScalar(v: unknown): boolean {
93
+ return (
94
+ v == null ||
95
+ typeof v === "string" ||
96
+ typeof v === "number" ||
97
+ typeof v === "boolean"
98
+ );
99
+ }
100
+
101
+ function valuesEqual(a: unknown, b: unknown): boolean {
102
+ // Missing key vs explicit undefined/null read the same here.
103
+ if (a == null && b == null) return true;
104
+ return Object.is(a, b);
105
+ }
106
+
107
+ function fmt(v: unknown): string {
108
+ return v === undefined ? "(absent)" : JSON.stringify(v);
109
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { Card, Storyboard } from "./types.js";
2
+
3
+ /**
4
+ * Multi-language renders from ONE take.
5
+ *
6
+ * The recorded take (raw video + timeline.json) is language-independent — it's
7
+ * a deterministic replay of the browser action-spec, which never mentions the
8
+ * spoken words. So a storyboard can carry translated narration (per scene) and
9
+ * translated card copy (intro/outro), and the engine can voice → caption →
10
+ * compose it once per language over the SAME footage. compose already stretches
11
+ * each scene's video to its own narration length, so a longer/shorter
12
+ * translation just retimes the shared take — no re-record.
13
+ *
14
+ * Translations are AUTHORED into the storyboard (scene `narrations`, card
15
+ * `i18n`); the engine never calls an LLM to translate. This keeps the capture
16
+ * loop — and the render pipeline — LLM-free.
17
+ */
18
+
19
+ /**
20
+ * Every language code that appears in a storyboard's `narrations` / card `i18n`
21
+ * maps (the default `narration`/card copy is the implicit base language and is
22
+ * NOT listed). Order-stable, de-duplicated.
23
+ */
24
+ export function storyboardLangs(sb: Storyboard): string[] {
25
+ const langs = new Set<string>();
26
+ for (const scene of sb.scenes)
27
+ for (const code of Object.keys(scene.narrations ?? {})) langs.add(code);
28
+ for (const card of [sb.intro, sb.outro])
29
+ for (const code of Object.keys(card?.i18n ?? {})) langs.add(code);
30
+ return [...langs];
31
+ }
32
+
33
+ /** Scene ids that have no translation for `lang` (fall back to the base narration). */
34
+ export function missingNarrations(sb: Storyboard, lang: string): string[] {
35
+ return sb.scenes
36
+ .filter((s) => s.narrations?.[lang] == null)
37
+ .map((s) => s.id);
38
+ }
39
+
40
+ /** Apply a card's per-language title/subtitle override (untouched if none). */
41
+ function localizeCard(card: Card | undefined, lang: string): Card | undefined {
42
+ if (!card) return card;
43
+ const override = card.i18n?.[lang];
44
+ if (!override) return card;
45
+ return {
46
+ ...card,
47
+ title: override.title ?? card.title,
48
+ subtitle: override.subtitle ?? card.subtitle,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * A language-specific VIEW of the storyboard: each scene's `narration` becomes
54
+ * its `narrations[lang]` translation (falling back to the base narration where
55
+ * a scene isn't translated), and intro/outro cards pick up any `i18n` override.
56
+ * Everything else — actions, timing, voice/music/zoom — is identical, so the
57
+ * shared take composes unchanged. Pure and side-effect-free; the original
58
+ * storyboard is left intact.
59
+ */
60
+ export function localizeStoryboard(sb: Storyboard, lang: string): Storyboard {
61
+ return {
62
+ ...sb,
63
+ intro: localizeCard(sb.intro, lang),
64
+ outro: localizeCard(sb.outro, lang),
65
+ scenes: sb.scenes.map((scene) => ({
66
+ ...scene,
67
+ narration: scene.narrations?.[lang] ?? scene.narration,
68
+ })),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * The spoken language of a given render, for STT purposes (Whisper's
74
+ * `language` hint in src/captions.ts) — NOT the same lookup as
75
+ * `localizeStoryboard`'s translation selection. An active `--lang <code>`
76
+ * render wins (it names the translation being spoken); otherwise the
77
+ * storyboard's own optional `language` field describes the base narration
78
+ * (e.g. a monolingual non-English demo with no `narrations` translations at
79
+ * all). Undefined when neither is set — Whisper auto-detects.
80
+ */
81
+ export function resolveNarrationLanguage(
82
+ sb: Storyboard | undefined,
83
+ lang?: string
84
+ ): string | undefined {
85
+ return lang ?? sb?.language;
86
+ }