@a1knowhow/demo-maker 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +104 -0
  3. package/SECURITY.md +33 -0
  4. package/bin/demo-maker.mjs +14 -0
  5. package/docs/README.md +35 -0
  6. package/docs/actions.md +160 -0
  7. package/docs/cli.md +73 -0
  8. package/docs/getting-started.md +220 -0
  9. package/docs/imports-and-fragments.md +78 -0
  10. package/docs/library-api.md +64 -0
  11. package/docs/locators.md +119 -0
  12. package/docs/recording.md +59 -0
  13. package/docs/repairing-scenarios.md +73 -0
  14. package/docs/scenario-schema.md +80 -0
  15. package/docs/url-capture.md +75 -0
  16. package/docs/variables.md +48 -0
  17. package/docs/video-and-pacing.md +109 -0
  18. package/examples/demo1-introduction.yaml +166 -0
  19. package/examples/demo2-github-actions.yaml +109 -0
  20. package/examples/files/demo1-introduction-run.log +50 -0
  21. package/examples/fragments/open-repository.yaml +15 -0
  22. package/examples/smoke.yaml +18 -0
  23. package/llms.txt +32 -0
  24. package/package.json +66 -0
  25. package/src/cli.ts +226 -0
  26. package/src/env.ts +112 -0
  27. package/src/export-chapters.ts +99 -0
  28. package/src/ffmpeg.ts +168 -0
  29. package/src/index.ts +54 -0
  30. package/src/load.ts +143 -0
  31. package/src/logger.ts +126 -0
  32. package/src/main.ts +68 -0
  33. package/src/parse-errors.ts +99 -0
  34. package/src/parse.ts +622 -0
  35. package/src/playwright-selector-to-locator.ts +328 -0
  36. package/src/record-cli.ts +179 -0
  37. package/src/recorder.ts +442 -0
  38. package/src/recording-to-yaml.ts +435 -0
  39. package/src/runner.ts +1582 -0
  40. package/src/selector-to-locator.ts +69 -0
  41. package/src/templates.ts +48 -0
  42. package/src/types.ts +319 -0
  43. package/src/url-capture.ts +105 -0
  44. package/src/variables.ts +64 -0
package/src/cli.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * CLI entry for `demo-maker run` / `demo-maker check`.
3
+ * Loads variables from --env-file if given; builds RunContext from env, runs scenario.
4
+ */
5
+
6
+ import { basename, resolve } from "path";
7
+ import {
8
+ loadScenario,
9
+ runScenario,
10
+ createDemoLogger,
11
+ type RunContext,
12
+ } from "./index";
13
+ import {
14
+ applyBaseUrlOverride,
15
+ buildRunVariables,
16
+ collectFlatPlaceholderKeys,
17
+ DEMO_BASE_URL_ENV_KEY,
18
+ loadEnvFile,
19
+ } from "./env";
20
+ import {
21
+ buildUrlCaptureHooks,
22
+ loadUrlCaptureRulesFile,
23
+ parseCaptureFromUrlArg,
24
+ type UrlCaptureRule,
25
+ } from "./url-capture";
26
+
27
+ /** Minimal delay overrides when --fast is used (quick tests, no video pacing). */
28
+ const FAST_DELAYS = {
29
+ step_delay_ms: 0,
30
+ slow_mo_ms: 0,
31
+ typing_delay_ms: 0,
32
+ post_click_delay_ms: 0,
33
+ navigate_hold_ms: 100,
34
+ pre_action_delay_ms: 0,
35
+ } as const;
36
+
37
+ function parsePositiveInt(raw: string | undefined): number | undefined {
38
+ if (raw === undefined || raw === "") {
39
+ return undefined;
40
+ }
41
+ const n = Number.parseInt(raw, 10);
42
+ return Number.isFinite(n) && n > 0 ? n : undefined;
43
+ }
44
+
45
+ function parseCaptureFromUrlArgs(args: string[]): UrlCaptureRule[] {
46
+ const rules: UrlCaptureRule[] = [];
47
+ for (let i = 0; i < args.length; i++) {
48
+ const a = args[i]!;
49
+ if (a.startsWith("--capture-from-url=")) {
50
+ rules.push(parseCaptureFromUrlArg(a.slice("--capture-from-url=".length)));
51
+ continue;
52
+ }
53
+ if (a === "--capture-from-url") {
54
+ const next = args[i + 1];
55
+ if (next && !next.startsWith("--")) {
56
+ rules.push(parseCaptureFromUrlArg(next));
57
+ i++;
58
+ }
59
+ }
60
+ }
61
+ return rules;
62
+ }
63
+
64
+ function parseUrlCaptureFile(args: string[]): string | undefined {
65
+ const eq = args.find((a) => a.startsWith("--url-capture-file="));
66
+ if (eq) return eq.slice("--url-capture-file=".length);
67
+ const idx = args.indexOf("--url-capture-file");
68
+ if (idx >= 0) {
69
+ const next = args[idx + 1];
70
+ if (next && !next.startsWith("--")) return next;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function parseArgs(argv: string[]) {
76
+ const args = argv.slice(2);
77
+ const positionals = args.filter((a) => !a.startsWith("--"));
78
+ const scenarioPath = positionals[0];
79
+ const baseUrl = args.find((a) => a.startsWith("--base-url="))?.slice("--base-url=".length);
80
+ const headless = args.includes("--headless");
81
+ const fast = args.includes("--fast");
82
+ const failFast = args.includes("--fail-fast");
83
+ const pauseAtEnd = args.includes("--pause-at-end");
84
+ const envFile =
85
+ args.find((a) => a.startsWith("--env-file="))?.slice("--env-file=".length) ?? positionals[1];
86
+ const storageState = args.find((a) => a.startsWith("--storage-state="))?.slice("--storage-state=".length);
87
+ const saveStorageState = args.find((a) => a.startsWith("--save-storage-state="))?.slice("--save-storage-state=".length);
88
+ const debugDir = args.find((a) => a.startsWith("--debug-dir="))?.slice("--debug-dir=".length);
89
+ const endStep =
90
+ parsePositiveInt(args.find((a) => a.startsWith("--end-step="))?.slice("--end-step=".length)) ??
91
+ (args.includes("--end-step") ? parsePositiveInt(args[args.indexOf("--end-step") + 1]) : undefined);
92
+ const captureFromUrl = parseCaptureFromUrlArgs(args);
93
+ const urlCaptureFile = parseUrlCaptureFile(args);
94
+ return {
95
+ scenarioPath,
96
+ baseUrl,
97
+ headless,
98
+ fast,
99
+ failFast,
100
+ pauseAtEnd,
101
+ envFile,
102
+ storageState,
103
+ saveStorageState,
104
+ debugDir,
105
+ endStep,
106
+ captureFromUrl,
107
+ urlCaptureFile,
108
+ };
109
+ }
110
+
111
+ export async function runMain(argv: string[] = process.argv): Promise<number> {
112
+ const logger = createDemoLogger("info");
113
+ const {
114
+ scenarioPath,
115
+ baseUrl,
116
+ headless,
117
+ fast,
118
+ failFast,
119
+ pauseAtEnd,
120
+ envFile,
121
+ storageState,
122
+ saveStorageState,
123
+ debugDir,
124
+ endStep,
125
+ captureFromUrl,
126
+ urlCaptureFile,
127
+ } = parseArgs(argv);
128
+
129
+ if (!scenarioPath) {
130
+ logger.error(
131
+ "Usage: demo-maker run <path-to-scenario.yaml> [env-file-path] [--base-url=URL] [--headless] [--fast] [--fail-fast] [--end-step=N] [--pause-at-end] [--debug-dir=path] [--env-file=path] [--storage-state=path] [--save-storage-state=path] [--capture-from-url VAR=regex] [--url-capture-file=path.json]"
132
+ );
133
+ return 1;
134
+ }
135
+
136
+ const resolvedPath = resolve(process.cwd(), scenarioPath);
137
+ let scenario;
138
+ try {
139
+ scenario = loadScenario(resolvedPath, { cwd: process.cwd() });
140
+ } catch (err) {
141
+ const detail = err instanceof Error ? err.message : String(err);
142
+ logger.error(`Failed to load scenario (${resolvedPath}):`, detail);
143
+ return 1;
144
+ }
145
+
146
+ const envFromFile = envFile ? loadEnvFile(envFile) : {};
147
+ applyBaseUrlOverride(scenario, { envFromFile, cliBaseUrl: baseUrl });
148
+ logger.info(`Base URL: ${scenario.base_url}`);
149
+ if (fast) {
150
+ Object.assign(scenario, FAST_DELAYS);
151
+ logger.info("Running in fast mode (minimal delays).");
152
+ }
153
+
154
+ if (failFast) {
155
+ logger.info("Fail-fast enabled (stop on first step failure).");
156
+ }
157
+ const emailEnv = scenario.auth?.email_env ?? "E2E_TEST_USER_EMAIL";
158
+ const passwordEnv = scenario.auth?.password_env ?? "E2E_TEST_USER_PASSWORD";
159
+ const email = envFromFile[emailEnv] ?? process.env[emailEnv];
160
+ const password = envFromFile[passwordEnv] ?? process.env[passwordEnv];
161
+
162
+ if (scenario.auth && (!email || !password)) {
163
+ logger.error(
164
+ `Missing credentials. Set ${emailEnv} and ${passwordEnv} (in env or via --env-file).`
165
+ );
166
+ return 1;
167
+ }
168
+
169
+ const allowFromProcess = [
170
+ emailEnv,
171
+ passwordEnv,
172
+ DEMO_BASE_URL_ENV_KEY,
173
+ ...collectFlatPlaceholderKeys(JSON.stringify(scenario)),
174
+ ];
175
+ const context: RunContext = {
176
+ auth: { email: email ?? "", password: password ?? "" },
177
+ variables: buildRunVariables({
178
+ envFromFile,
179
+ baseUrl: scenario.base_url,
180
+ allowFromProcess,
181
+ }),
182
+ };
183
+
184
+ let urlCaptureHooks;
185
+ try {
186
+ const fileRules = urlCaptureFile
187
+ ? loadUrlCaptureRulesFile(urlCaptureFile, process.cwd())
188
+ : [];
189
+ urlCaptureHooks = buildUrlCaptureHooks({
190
+ scenarioRules: scenario.url_capture,
191
+ cliRules: [...fileRules, ...captureFromUrl],
192
+ });
193
+ } catch (err) {
194
+ logger.error(err instanceof Error ? err.message : String(err));
195
+ return 1;
196
+ }
197
+
198
+ const stepCount = endStep ? Math.min(endStep, scenario.steps.length) : scenario.steps.length;
199
+ logger.info(`Running scenario: ${scenario.name} (${stepCount}/${scenario.steps.length} steps)`);
200
+ const scenarioLabel = basename(resolvedPath, ".yaml");
201
+ const result = await runScenario(scenario, context, {
202
+ logger,
203
+ headless,
204
+ storageStatePath: storageState,
205
+ saveStorageStatePath: saveStorageState,
206
+ failFast,
207
+ endStep,
208
+ pauseAtEnd,
209
+ debugDir,
210
+ scenarioLabel,
211
+ urlCaptureHooks,
212
+ });
213
+
214
+ if (result.success) {
215
+ logger.info("Scenario completed successfully.");
216
+ if (result.videoPath) {
217
+ logger.info("Video saved:", result.videoPath);
218
+ }
219
+ if (result.timingPath) {
220
+ logger.info("Timing saved:", result.timingPath);
221
+ }
222
+ return 0;
223
+ }
224
+ logger.error("Scenario failed:", result.error);
225
+ return 1;
226
+ }
package/src/env.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Load .env-style files for auth and variables. Shared by run CLI and record CLI.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from "fs";
6
+ import { resolve } from "path";
7
+
8
+ /**
9
+ * Parse a .env-style file into key-value pairs. Supports KEY=VALUE, # comments, blank lines,
10
+ * and quoted values ("value" or 'value').
11
+ */
12
+ export function loadEnvFile(filePath: string): Record<string, string> {
13
+ const resolved = resolve(process.cwd(), filePath);
14
+ if (!existsSync(resolved)) {
15
+ return {};
16
+ }
17
+ const content = readFileSync(resolved, "utf-8");
18
+ const result: Record<string, string> = {};
19
+ for (const line of content.split("\n")) {
20
+ const trimmed = line.trim();
21
+ if (!trimmed || trimmed.startsWith("#")) {
22
+ continue;
23
+ }
24
+ const eq = trimmed.indexOf("=");
25
+ if (eq <= 0) {
26
+ continue;
27
+ }
28
+ const key = trimmed.slice(0, eq).trim();
29
+ let value = trimmed.slice(eq + 1).trim();
30
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
31
+ value = value.slice(1, -1).replace(/\\(.)/g, "$1");
32
+ }
33
+ result[key] = value;
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export const DEMO_BASE_URL_ENV_KEY = "BASE_URL";
39
+
40
+ /** Single-segment `{{ NAME }}` placeholders (excludes nested paths like `auth.email`). */
41
+ const FLAT_PLACEHOLDER_REGEX = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
42
+
43
+ /**
44
+ * Collect flat `{{ NAME }}` keys from text blobs (e.g. stringified scenario).
45
+ */
46
+ export function collectFlatPlaceholderKeys(...texts: string[]): string[] {
47
+ const keys = new Set<string>();
48
+ for (const text of texts) {
49
+ FLAT_PLACEHOLDER_REGEX.lastIndex = 0;
50
+ for (const match of text.matchAll(FLAT_PLACEHOLDER_REGEX)) {
51
+ keys.add(match[1]!);
52
+ }
53
+ }
54
+ return [...keys];
55
+ }
56
+
57
+ /**
58
+ * Build template variables without copying the entire process environment.
59
+ * Includes: allowlisted process.env keys, --env-file entries (override), and BASE_URL.
60
+ */
61
+ export function buildRunVariables(options: {
62
+ envFromFile: Record<string, string>;
63
+ baseUrl: string;
64
+ allowFromProcess: Iterable<string>;
65
+ processEnv?: Record<string, string | undefined>;
66
+ }): Record<string, string> {
67
+ const processEnv = options.processEnv ?? process.env;
68
+ const fromProcess: Record<string, string> = {};
69
+ for (const key of options.allowFromProcess) {
70
+ const value = processEnv[key];
71
+ if (typeof value === "string") {
72
+ fromProcess[key] = value;
73
+ }
74
+ }
75
+ return {
76
+ ...fromProcess,
77
+ ...options.envFromFile,
78
+ BASE_URL: options.baseUrl,
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Resolve demo base URL from env file (preferred) or process.env.
84
+ */
85
+ export function resolveBaseUrlFromEnv(
86
+ envFromFile: Record<string, string>,
87
+ processEnv: Record<string, string | undefined> = process.env
88
+ ): string | undefined {
89
+ const fromFile = envFromFile[DEMO_BASE_URL_ENV_KEY]?.trim();
90
+ if (fromFile) {
91
+ return fromFile;
92
+ }
93
+ const fromProcess = processEnv[DEMO_BASE_URL_ENV_KEY]?.trim();
94
+ return fromProcess || undefined;
95
+ }
96
+
97
+ /**
98
+ * Override scenario.base_url from env file or CLI. CLI wins when set.
99
+ */
100
+ export function applyBaseUrlOverride(
101
+ scenario: { base_url: string },
102
+ options: { envFromFile?: Record<string, string>; cliBaseUrl?: string }
103
+ ): void {
104
+ if (options.cliBaseUrl) {
105
+ scenario.base_url = options.cliBaseUrl;
106
+ return;
107
+ }
108
+ const resolved = resolveBaseUrlFromEnv(options.envFromFile ?? {});
109
+ if (resolved) {
110
+ scenario.base_url = resolved;
111
+ }
112
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Emit YouTube chapter timestamps from a demo-maker timing.json file.
3
+ *
4
+ * Usage:
5
+ * npx tsx demo-maker/export-chapters.ts path/to/video.timing.json
6
+ * npx tsx demo-maker/export-chapters.ts output/videos # uses newest *.timing.json in dir
7
+ */
8
+
9
+ import { readdirSync, readFileSync, statSync } from "fs";
10
+ import { join, resolve } from "path";
11
+ import type { TimingExport } from "./types";
12
+
13
+ function formatChapterTime(sec: number): string {
14
+ const total = Math.max(0, Math.floor(sec));
15
+ const m = Math.floor(total / 60);
16
+ const s = total % 60;
17
+ return `${m}:${String(s).padStart(2, "0")}`;
18
+ }
19
+
20
+ function chapterLabel(step: TimingExport["steps"][number]): string | undefined {
21
+ if (step.caption?.trim()) {
22
+ return step.caption.trim();
23
+ }
24
+ const msg = step.message?.trim() ?? "";
25
+ if (/pre-video/i.test(msg)) {
26
+ return undefined;
27
+ }
28
+ const scene = msg.match(/──\s*Scene\s*\d+:\s*(.+)/i);
29
+ if (scene?.[1]) {
30
+ return scene[1].trim();
31
+ }
32
+ if (msg.startsWith("──")) {
33
+ return msg.replace(/^──\s*/, "").trim();
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ export function formatYouTubeChapters(timing: TimingExport): string {
39
+ const lines: string[] = [];
40
+ const seen = new Set<number>();
41
+
42
+ for (const step of timing.steps) {
43
+ if (step.excluded) continue;
44
+ const label = chapterLabel(step);
45
+ if (!label) continue;
46
+ const at = Math.floor(step.startSec);
47
+ if (seen.has(at)) continue;
48
+ seen.add(at);
49
+ lines.push(`${formatChapterTime(step.startSec)} ${label}`);
50
+ }
51
+
52
+ if (lines.length === 0) {
53
+ return "# No chapter markers found (add caption: or Scene log messages on key steps)";
54
+ }
55
+ return lines.join("\n");
56
+ }
57
+
58
+ function findNewestTimingInDir(dir: string): string | undefined {
59
+ const files = readdirSync(dir)
60
+ .filter((f) => f.endsWith(".timing.json"))
61
+ .map((f) => join(dir, f));
62
+ if (files.length === 0) return undefined;
63
+ files.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
64
+ return files[0];
65
+ }
66
+
67
+ export function exportChaptersMain(argv: string[] = process.argv): number {
68
+ const arg = argv[2];
69
+ if (!arg) {
70
+ console.error("Usage: npx tsx demo-maker/export-chapters.ts <timing.json | directory>");
71
+ return 1;
72
+ }
73
+
74
+ const resolved = resolve(process.cwd(), arg);
75
+ let timingPath = resolved;
76
+ try {
77
+ const st = statSync(resolved);
78
+ if (st.isDirectory()) {
79
+ const found = findNewestTimingInDir(resolved);
80
+ if (!found) {
81
+ console.error(`No *.timing.json files in ${resolved}`);
82
+ return 1;
83
+ }
84
+ timingPath = found;
85
+ }
86
+ } catch {
87
+ console.error(`Path not found: ${resolved}`);
88
+ return 1;
89
+ }
90
+
91
+ const raw = readFileSync(timingPath, "utf8");
92
+ const timing = JSON.parse(raw) as TimingExport;
93
+ console.log(`# ${timing.scenarioName} (${timing.durationSec}s)`);
94
+ console.log(`# source: ${timingPath}`);
95
+ console.log("");
96
+ console.log(formatYouTubeChapters(timing));
97
+ return 0;
98
+ }
99
+
package/src/ffmpeg.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * ffmpeg helpers for demo-maker video post-processing (splice excluded ranges).
3
+ */
4
+
5
+ import { spawnSync } from "child_process";
6
+
7
+ export interface TimeRangeSec {
8
+ startSec: number;
9
+ endSec: number;
10
+ }
11
+
12
+ export interface KeepSegment {
13
+ startSec: number;
14
+ endSec: number;
15
+ }
16
+
17
+ /** Sort and merge overlapping exclude ranges. */
18
+ export function normalizeExcludeRanges(ranges: TimeRangeSec[]): TimeRangeSec[] {
19
+ if (ranges.length === 0) {
20
+ return [];
21
+ }
22
+ const sorted = [...ranges]
23
+ .map((r) => ({
24
+ startSec: Math.max(0, r.startSec),
25
+ endSec: Math.max(r.startSec, r.endSec),
26
+ }))
27
+ .sort((a, b) => a.startSec - b.startSec);
28
+
29
+ const merged: TimeRangeSec[] = [];
30
+ for (const r of sorted) {
31
+ const last = merged[merged.length - 1];
32
+ if (last && r.startSec <= last.endSec) {
33
+ last.endSec = Math.max(last.endSec, r.endSec);
34
+ } else {
35
+ merged.push({ ...r });
36
+ }
37
+ }
38
+ return merged;
39
+ }
40
+
41
+ /** Build keep-segments from full duration and exclude ranges. */
42
+ export function buildKeepSegments(durationSec: number, excludeRanges: TimeRangeSec[]): KeepSegment[] {
43
+ const excludes = normalizeExcludeRanges(excludeRanges);
44
+ const keeps: KeepSegment[] = [];
45
+ let cursor = 0;
46
+
47
+ for (const ex of excludes) {
48
+ if (ex.startSec > cursor + 0.01) {
49
+ keeps.push({ startSec: cursor, endSec: ex.startSec });
50
+ }
51
+ cursor = Math.max(cursor, ex.endSec);
52
+ }
53
+
54
+ if (cursor < durationSec - 0.01) {
55
+ keeps.push({ startSec: cursor, endSec: durationSec });
56
+ }
57
+
58
+ return keeps.filter((k) => k.endSec > k.startSec + 0.01);
59
+ }
60
+
61
+ /** Map a timestamp on the original timeline to the spliced timeline. */
62
+ export function remapTimelineSec(tSec: number, excludeRanges: TimeRangeSec[]): number {
63
+ const excludes = normalizeExcludeRanges(excludeRanges);
64
+ let removed = 0;
65
+ for (const ex of excludes) {
66
+ if (tSec <= ex.startSec) {
67
+ break;
68
+ }
69
+ if (tSec >= ex.endSec) {
70
+ removed += ex.endSec - ex.startSec;
71
+ } else {
72
+ removed += tSec - ex.startSec;
73
+ break;
74
+ }
75
+ }
76
+ return Math.max(0, tSec - removed);
77
+ }
78
+
79
+ export interface SpliceResult {
80
+ ok: boolean;
81
+ stderr?: string;
82
+ removedSec?: number;
83
+ }
84
+
85
+ /**
86
+ * Remove exclude ranges from a video file using ffmpeg trim+concat.
87
+ * Re-encodes video (required for mid-stream cuts). No audio track is expected on Playwright webm.
88
+ */
89
+ export function spliceVideoExcludingRanges(
90
+ inputPath: string,
91
+ outputPath: string,
92
+ durationSec: number,
93
+ excludeRanges: TimeRangeSec[]
94
+ ): SpliceResult {
95
+ const excludes = normalizeExcludeRanges(excludeRanges);
96
+ if (excludes.length === 0) {
97
+ return { ok: true, removedSec: 0 };
98
+ }
99
+
100
+ const keeps = buildKeepSegments(durationSec, excludes);
101
+ if (keeps.length === 0) {
102
+ return { ok: false, stderr: "splice would remove entire video" };
103
+ }
104
+
105
+ const removedSec = excludes.reduce((sum, r) => sum + (r.endSec - r.startSec), 0);
106
+
107
+ if (keeps.length === 1 && excludes.length === 1 && keeps[0]!.startSec === 0 && keeps[0]!.endSec >= durationSec - removedSec - 0.05) {
108
+ // Single leading or trailing keep only — use simple trim
109
+ const k = keeps[0]!;
110
+ const ff = spawnSync(
111
+ "ffmpeg",
112
+ [
113
+ "-y",
114
+ "-i",
115
+ inputPath,
116
+ "-ss",
117
+ String(k.startSec),
118
+ "-to",
119
+ String(k.endSec),
120
+ "-c:v",
121
+ "libvpx-vp9",
122
+ "-b:v",
123
+ "2M",
124
+ outputPath,
125
+ ],
126
+ { encoding: "utf8" }
127
+ );
128
+ if (ff.status === 0) {
129
+ return { ok: true, removedSec };
130
+ }
131
+ return { ok: false, stderr: ff.stderr?.slice(-800) ?? String(ff.error) };
132
+ }
133
+
134
+ const filterParts: string[] = [];
135
+ const labels: string[] = [];
136
+ for (let i = 0; i < keeps.length; i++) {
137
+ const k = keeps[i]!;
138
+ filterParts.push(
139
+ `[0:v]trim=start=${k.startSec}:end=${k.endSec},setpts=PTS-STARTPTS[v${i}]`
140
+ );
141
+ labels.push(`[v${i}]`);
142
+ }
143
+ filterParts.push(`${labels.join("")}concat=n=${keeps.length}:v=1:a=0[outv]`);
144
+
145
+ const ff = spawnSync(
146
+ "ffmpeg",
147
+ [
148
+ "-y",
149
+ "-i",
150
+ inputPath,
151
+ "-filter_complex",
152
+ filterParts.join(";"),
153
+ "-map",
154
+ "[outv]",
155
+ "-c:v",
156
+ "libvpx-vp9",
157
+ "-b:v",
158
+ "2M",
159
+ outputPath,
160
+ ],
161
+ { encoding: "utf8" }
162
+ );
163
+
164
+ if (ff.status === 0) {
165
+ return { ok: true, removedSec };
166
+ }
167
+ return { ok: false, stderr: ff.stderr?.slice(-800) ?? String(ff.error) };
168
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Entry point for CLI and local scripts. Exports parseScenario, loadScenario, runScenario, createDemoLogger, resolveVariables, and types.
3
+ */
4
+
5
+ export type {
6
+ ActionType,
7
+ AuthConfig,
8
+ CaptionStyle,
9
+ DemoScenario,
10
+ DemoStep,
11
+ IDemoLogger,
12
+ IDemoRunner,
13
+ IScenarioParser,
14
+ Locator,
15
+ ParsedScenario,
16
+ RunContext,
17
+ RunResult,
18
+ StepImport,
19
+ StepOrImport,
20
+ StepResult,
21
+ TimingExport,
22
+ TimingStepEntry,
23
+ VideoConfig,
24
+ } from "./types";
25
+ export {
26
+ createDemoLogger,
27
+ formatLocalTimeHHMMSS,
28
+ formatStepFailedLogLine,
29
+ formatStepProgressLogLine,
30
+ } from "./logger";
31
+ export type { StepProgressLogParts } from "./logger";
32
+ export { loadScenario } from "./load";
33
+ export type { LoadScenarioOptions } from "./load";
34
+ export { resolveVariables, defaultVariableResolver } from "./variables";
35
+ export type { IVariableResolver } from "./variables";
36
+ export { parseScenario, type ParseScenarioOptions } from "./parse";
37
+ export { runScenario, runScenarioOnExistingPage, sliceScenarioByEndStep } from "./runner";
38
+ export { formatYouTubeChapters } from "./export-chapters";
39
+ export type {
40
+ RunScenarioOnPageOptions,
41
+ RunScenarioOnPageResult,
42
+ RunScenarioOptions,
43
+ } from "./runner";
44
+
45
+ export {
46
+ applyUrlCapture,
47
+ buildUrlCaptureHooks,
48
+ createRegexUrlCapture,
49
+ loadUrlCaptureRulesFile,
50
+ parseCaptureFromUrlArg,
51
+ } from "./url-capture";
52
+ export type { UrlCaptureHook } from "./url-capture";
53
+ export type { UrlCaptureRule } from "./types";
54
+