tutorial-forge 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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +19 -0
  3. package/dist/browser/callout.d.ts +7 -0
  4. package/dist/browser/callout.js +33 -0
  5. package/dist/browser/callout.js.map +1 -0
  6. package/dist/browser/cursor.d.ts +9 -0
  7. package/dist/browser/cursor.js +61 -0
  8. package/dist/browser/cursor.js.map +1 -0
  9. package/dist/browser/instrument.d.ts +16 -0
  10. package/dist/browser/instrument.js +114 -0
  11. package/dist/browser/instrument.js.map +1 -0
  12. package/dist/browser/timing.d.ts +20 -0
  13. package/dist/browser/timing.js +26 -0
  14. package/dist/browser/timing.js.map +1 -0
  15. package/dist/config.d.ts +25 -0
  16. package/dist/config.js +36 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/index.d.ts +17 -0
  19. package/dist/index.js +18 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/pipeline/post.d.ts +22 -0
  22. package/dist/pipeline/post.js +63 -0
  23. package/dist/pipeline/post.js.map +1 -0
  24. package/dist/pipeline/record.d.ts +23 -0
  25. package/dist/pipeline/record.js +176 -0
  26. package/dist/pipeline/record.js.map +1 -0
  27. package/dist/pipeline/render.d.ts +12 -0
  28. package/dist/pipeline/render.js +91 -0
  29. package/dist/pipeline/render.js.map +1 -0
  30. package/dist/pipeline/tts.d.ts +23 -0
  31. package/dist/pipeline/tts.js +42 -0
  32. package/dist/pipeline/tts.js.map +1 -0
  33. package/dist/post/ffmpeg.d.ts +53 -0
  34. package/dist/post/ffmpeg.js +162 -0
  35. package/dist/post/ffmpeg.js.map +1 -0
  36. package/dist/post/subtitles.d.ts +14 -0
  37. package/dist/post/subtitles.js +47 -0
  38. package/dist/post/subtitles.js.map +1 -0
  39. package/dist/spec.d.ts +7 -0
  40. package/dist/spec.js +51 -0
  41. package/dist/spec.js.map +1 -0
  42. package/dist/tts/cache.d.ts +10 -0
  43. package/dist/tts/cache.js +43 -0
  44. package/dist/tts/cache.js.map +1 -0
  45. package/dist/tts/elevenlabs.d.ts +11 -0
  46. package/dist/tts/elevenlabs.js +37 -0
  47. package/dist/tts/elevenlabs.js.map +1 -0
  48. package/dist/tts/openai.d.ts +9 -0
  49. package/dist/tts/openai.js +29 -0
  50. package/dist/tts/openai.js.map +1 -0
  51. package/dist/tts/piper.d.ts +10 -0
  52. package/dist/tts/piper.js +20 -0
  53. package/dist/tts/piper.js.map +1 -0
  54. package/dist/tts/provider.d.ts +6 -0
  55. package/dist/tts/provider.js +9 -0
  56. package/dist/tts/provider.js.map +1 -0
  57. package/dist/tts/silent.d.ts +8 -0
  58. package/dist/tts/silent.js +37 -0
  59. package/dist/tts/silent.js.map +1 -0
  60. package/dist/types.d.ts +105 -0
  61. package/dist/types.js +14 -0
  62. package/dist/types.js.map +1 -0
  63. package/dist/util/fs.d.ts +5 -0
  64. package/dist/util/fs.js +30 -0
  65. package/dist/util/fs.js.map +1 -0
  66. package/dist/util/hash.d.ts +1 -0
  67. package/dist/util/hash.js +7 -0
  68. package/dist/util/hash.js.map +1 -0
  69. package/dist/util/logger.d.ts +6 -0
  70. package/dist/util/logger.js +21 -0
  71. package/dist/util/logger.js.map +1 -0
  72. package/package.json +55 -0
@@ -0,0 +1,176 @@
1
+ import { join } from 'node:path';
2
+ import { writeFile, readFile, rename } from 'node:fs/promises';
3
+ import { chromium } from 'playwright';
4
+ import { StepError } from '../types.js';
5
+ import { stepId } from '../spec.js';
6
+ import { CURSOR_INIT_SCRIPT } from '../browser/cursor.js';
7
+ import { CALLOUT_INIT_SCRIPT } from '../browser/callout.js';
8
+ import { RecordingClock, stepHoldUntilMs } from '../browser/timing.js';
9
+ import { instrumentPage } from '../browser/instrument.js';
10
+ import { ensureDir } from '../util/fs.js';
11
+ import { logger } from '../util/logger.js';
12
+ export const RAW_VIDEO_FILE = 'raw.webm';
13
+ export const MANIFEST_FILE = 'manifest.json';
14
+ const FINAL_HOLD_MS = 1000;
15
+ const FLASH_MS = 120;
16
+ /**
17
+ * Phase 2 — drive the browser through the tutorial while Playwright records
18
+ * video, pacing each step to its narration budget. Writes raw.webm and
19
+ * manifest.json into workDir.
20
+ */
21
+ export async function runRecordPhase(tutorial, adapter, tts, opts) {
22
+ const videoDir = join(opts.workDir, 'video');
23
+ await ensureDir(videoDir);
24
+ const browser = await launchChromium(opts.headless);
25
+ try {
26
+ // Playwright's screencast captures at CSS-viewport size and pads (never
27
+ // scales up) when recordVideo.size is larger, so record at viewport size.
28
+ // deviceScaleFactor 2 still sharpens text: Chromium rasterizes at 2x and
29
+ // downscales into the captured frame.
30
+ const context = await browser.newContext({
31
+ viewport: opts.viewport,
32
+ deviceScaleFactor: 2,
33
+ recordVideo: { dir: videoDir, size: opts.viewport },
34
+ });
35
+ if (opts.cursor)
36
+ await context.addInitScript(CURSOR_INIT_SCRIPT);
37
+ if (opts.callouts || opts.cursor)
38
+ await context.addInitScript(CALLOUT_INIT_SCRIPT);
39
+ const page = await context.newPage();
40
+ const clock = new RecordingClock();
41
+ // Clock zero + calibration flash: paint a magenta frame the post phase
42
+ // can find to align the manifest clock with the video's first frame.
43
+ await page.goto('about:blank');
44
+ await page.waitForTimeout(250);
45
+ clock.zero();
46
+ await page.evaluate((ms) => {
47
+ document.documentElement.style.background = '#ff00ff';
48
+ return new Promise((resolve) => setTimeout(() => {
49
+ document.documentElement.style.background = '';
50
+ resolve();
51
+ }, ms));
52
+ }, FLASH_MS);
53
+ logger.info(`record: setup (${adapter.baseURL})`);
54
+ await adapter.setup(page);
55
+ const callouts = tutorial.steps.map(() => []);
56
+ let currentStep = 0;
57
+ const instrumented = instrumentPage(page, {
58
+ cursor: opts.cursor,
59
+ callouts: opts.callouts,
60
+ nowMs: () => clock.now(),
61
+ onCallout: (c) => callouts[currentStep]?.push(c),
62
+ });
63
+ const manifestSteps = [];
64
+ for (let i = 0; i < tutorial.steps.length; i++) {
65
+ currentStep = i;
66
+ const step = tutorial.steps[i];
67
+ const id = stepId(step, i);
68
+ const audio = tts.steps[i];
69
+ if (!audio || audio.id !== id) {
70
+ throw new Error(`tts.json is stale (step ${i}: expected "${id}", got "${audio?.id}") — re-run the tts phase`);
71
+ }
72
+ const startMs = clock.now();
73
+ logger.info(`record: step ${i + 1}/${tutorial.steps.length} "${id}"`);
74
+ await page.waitForTimeout(opts.leadInMs);
75
+ const actionStartMs = clock.now();
76
+ try {
77
+ await step.run(instrumented);
78
+ await step.waitFor?.(instrumented);
79
+ }
80
+ catch (cause) {
81
+ await captureFailure(page, opts.workDir, id);
82
+ await saveManifest(tutorial, clock, manifestSteps, opts.workDir);
83
+ await safeClose(context.close());
84
+ throw new StepError(tutorial.id, id, cause);
85
+ }
86
+ const actionEndMs = clock.now();
87
+ const holdUntil = stepHoldUntilMs({
88
+ startMs,
89
+ leadInMs: opts.leadInMs,
90
+ audioDurationMs: audio.audioDurationMs,
91
+ actionEndMs,
92
+ settleMs: step.settleMs ?? 400,
93
+ });
94
+ const remaining = holdUntil - clock.now();
95
+ if (remaining > 0)
96
+ await page.waitForTimeout(remaining);
97
+ manifestSteps.push({
98
+ id,
99
+ narration: step.narration,
100
+ audioFile: audio.audioFile,
101
+ audioDurationMs: audio.audioDurationMs,
102
+ startMs,
103
+ actionStartMs,
104
+ actionEndMs,
105
+ endMs: clock.now(),
106
+ callouts: callouts[i],
107
+ });
108
+ }
109
+ await page.waitForTimeout(FINAL_HOLD_MS);
110
+ const manifest = await saveManifest(tutorial, clock, manifestSteps, opts.workDir);
111
+ if (adapter.teardown) {
112
+ try {
113
+ await adapter.teardown(page);
114
+ }
115
+ catch (err) {
116
+ logger.warn(`teardown failed (ignored): ${err instanceof Error ? err.message : err}`);
117
+ }
118
+ }
119
+ const video = page.video();
120
+ await context.close(); // flushes the webm
121
+ if (!video)
122
+ throw new Error('Playwright returned no video — recordVideo was not active');
123
+ await rename(await video.path(), join(opts.workDir, RAW_VIDEO_FILE));
124
+ return manifest;
125
+ }
126
+ finally {
127
+ await safeClose(browser.close());
128
+ }
129
+ }
130
+ async function launchChromium(headless) {
131
+ try {
132
+ return await chromium.launch({ headless, channel: 'chromium' });
133
+ }
134
+ catch {
135
+ // 'chromium' channel (new headless) not installed; default build works too.
136
+ return chromium.launch({ headless });
137
+ }
138
+ }
139
+ async function saveManifest(tutorial, clock, steps, workDir) {
140
+ const manifest = {
141
+ tutorialId: tutorial.id,
142
+ fps: 25,
143
+ recordingStartEpochMs: clock.zeroEpoch,
144
+ steps,
145
+ totalDurationMs: clock.now(),
146
+ };
147
+ await writeFile(join(workDir, MANIFEST_FILE), JSON.stringify(manifest, null, 2));
148
+ return manifest;
149
+ }
150
+ async function captureFailure(page, workDir, id) {
151
+ try {
152
+ await page.screenshot({ path: join(workDir, `failure-${id}.png`) });
153
+ logger.error(`record: step "${id}" failed — screenshot at ${join(workDir, `failure-${id}.png`)}`);
154
+ }
155
+ catch {
156
+ /* page may already be unusable */
157
+ }
158
+ }
159
+ async function safeClose(p) {
160
+ try {
161
+ await p;
162
+ }
163
+ catch {
164
+ /* already closed */
165
+ }
166
+ }
167
+ /** Load a previous run's manifest.json (for `--phase post`). */
168
+ export async function loadManifest(workDir) {
169
+ try {
170
+ return JSON.parse(await readFile(join(workDir, MANIFEST_FILE), 'utf8'));
171
+ }
172
+ catch (err) {
173
+ throw new Error(`No ${MANIFEST_FILE} in ${workDir} — run the record phase first (cause: ${err instanceof Error ? err.message : err})`);
174
+ }
175
+ }
176
+ //# sourceMappingURL=record.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"record.js","sourceRoot":"","sources":["../../src/pipeline/record.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import type { RenderOptions, TimingManifest, Tutorial, TutorialAdapter } from '../types.js';
2
+ import { type PostPhaseResult } from './post.js';
3
+ export interface RenderResult extends PostPhaseResult {
4
+ manifest: TimingManifest;
5
+ workDir: string;
6
+ }
7
+ /**
8
+ * Run the full pipeline (or a single phase) for one tutorial.
9
+ * Phases: tts → record → post. The work directory is kept on failure
10
+ * (and on success with keepWorkDir: true) so every stage is inspectable.
11
+ */
12
+ export declare function render(tutorial: Tutorial, adapter: TutorialAdapter, options: RenderOptions): Promise<RenderResult>;
@@ -0,0 +1,91 @@
1
+ import { resolve, join } from 'node:path';
2
+ import { validateTutorial } from '../spec.js';
3
+ import { runTTSPhase, loadTTSResult } from './tts.js';
4
+ import { runRecordPhase, loadManifest } from './record.js';
5
+ import { runPostPhase } from './post.js';
6
+ import { defaultCacheDir } from '../tts/cache.js';
7
+ import { ensureDir, removeDir } from '../util/fs.js';
8
+ import { logger } from '../util/logger.js';
9
+ /**
10
+ * Run the full pipeline (or a single phase) for one tutorial.
11
+ * Phases: tts → record → post. The work directory is kept on failure
12
+ * (and on success with keepWorkDir: true) so every stage is inspectable.
13
+ */
14
+ export async function render(tutorial, adapter, options) {
15
+ validateTutorial(tutorial);
16
+ const workDir = resolve(options.workDir ?? join('.forge', tutorial.id));
17
+ const output = resolve(options.output);
18
+ const viewport = options.viewport ?? { width: 1920, height: 1080 };
19
+ const leadInMs = options.leadInMs ?? 300;
20
+ const phase = options.phase ?? 'all';
21
+ await ensureDir(workDir);
22
+ try {
23
+ const tts = phase === 'all' || phase === 'tts'
24
+ ? await runTTSPhase(tutorial, {
25
+ provider: options.tts,
26
+ workDir,
27
+ cacheDir: options.ttsCacheDir ?? defaultCacheDir(),
28
+ concurrency: options.ttsConcurrency ?? 4,
29
+ })
30
+ : await loadTTSResult(workDir);
31
+ if (phase === 'tts') {
32
+ return partialResult(workDir, output, await safeLoadManifest(workDir, tutorial));
33
+ }
34
+ const manifest = phase === 'all' || phase === 'record'
35
+ ? await runRecordPhase(tutorial, adapter, tts, {
36
+ workDir,
37
+ viewport,
38
+ headless: options.headless ?? true,
39
+ cursor: options.cursor ?? true,
40
+ callouts: options.callouts ?? true,
41
+ leadInMs,
42
+ })
43
+ : await loadManifest(workDir);
44
+ if (phase === 'record') {
45
+ return partialResult(workDir, output, manifest);
46
+ }
47
+ const post = await runPostPhase(manifest, {
48
+ workDir,
49
+ output,
50
+ viewport,
51
+ subtitles: options.subtitles ?? 'sidecar',
52
+ leadInMs,
53
+ });
54
+ if (!(options.keepWorkDir ?? false)) {
55
+ await removeDir(workDir);
56
+ }
57
+ else {
58
+ logger.info(`work dir kept at ${workDir}`);
59
+ }
60
+ return { ...post, manifest, workDir };
61
+ }
62
+ catch (err) {
63
+ logger.error(`render failed — work dir kept at ${workDir}`);
64
+ throw err;
65
+ }
66
+ }
67
+ async function safeLoadManifest(workDir, tutorial) {
68
+ try {
69
+ return await loadManifest(workDir);
70
+ }
71
+ catch {
72
+ return {
73
+ tutorialId: tutorial.id,
74
+ fps: 25,
75
+ recordingStartEpochMs: 0,
76
+ steps: [],
77
+ totalDurationMs: 0,
78
+ };
79
+ }
80
+ }
81
+ function partialResult(workDir, output, manifest) {
82
+ return {
83
+ output,
84
+ srtPath: null,
85
+ videoClockOffsetMs: manifest.videoClockOffsetMs ?? 0,
86
+ outputDurationMs: 0,
87
+ manifest,
88
+ workDir,
89
+ };
90
+ }
91
+ //# sourceMappingURL=render.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.js","sourceRoot":"","sources":["../../src/pipeline/render.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ import type { TTSProvider, Tutorial } from '../types.js';
2
+ export interface TTSPhaseResult {
3
+ /** Per step, in step order. Null audioFile / 0 duration for silent steps. */
4
+ steps: Array<{
5
+ id: string;
6
+ narration: string;
7
+ audioFile: string | null;
8
+ audioDurationMs: number;
9
+ }>;
10
+ }
11
+ /**
12
+ * Phase 1 — synthesize + measure every narration line. No browser dependency;
13
+ * bounded concurrency. Result is persisted to workDir/tts.json so the record
14
+ * phase can re-run without re-synthesizing.
15
+ */
16
+ export declare function runTTSPhase(tutorial: Tutorial, opts: {
17
+ provider: TTSProvider;
18
+ workDir: string;
19
+ cacheDir: string;
20
+ concurrency: number;
21
+ }): Promise<TTSPhaseResult>;
22
+ /** Load a previous run's tts.json (for `--phase record`/`--phase post`). */
23
+ export declare function loadTTSResult(workDir: string): Promise<TTSPhaseResult>;
@@ -0,0 +1,42 @@
1
+ import { join } from 'node:path';
2
+ import { writeFile, readFile } from 'node:fs/promises';
3
+ import { stepId } from '../spec.js';
4
+ import { synthesizeCached } from '../tts/cache.js';
5
+ import { probeDurationMs } from '../post/ffmpeg.js';
6
+ import { ensureDir, mapLimit } from '../util/fs.js';
7
+ import { logger } from '../util/logger.js';
8
+ const TTS_RESULT_FILE = 'tts.json';
9
+ /**
10
+ * Phase 1 — synthesize + measure every narration line. No browser dependency;
11
+ * bounded concurrency. Result is persisted to workDir/tts.json so the record
12
+ * phase can re-run without re-synthesizing.
13
+ */
14
+ export async function runTTSPhase(tutorial, opts) {
15
+ const audioDir = join(opts.workDir, 'audio');
16
+ await ensureDir(audioDir);
17
+ const steps = await mapLimit(tutorial.steps, opts.concurrency, async (step, i) => {
18
+ const id = stepId(step, i);
19
+ if (!step.narration.trim()) {
20
+ return { id, narration: step.narration, audioFile: null, audioDurationMs: 0 };
21
+ }
22
+ const audioFile = join(audioDir, `step-${id}.wav`);
23
+ await synthesizeCached(opts.provider, step.narration, audioFile, opts.cacheDir);
24
+ const audioDurationMs = await probeDurationMs(audioFile);
25
+ return { id, narration: step.narration, audioFile, audioDurationMs };
26
+ });
27
+ const result = { steps };
28
+ await writeFile(join(opts.workDir, TTS_RESULT_FILE), JSON.stringify(result, null, 2));
29
+ logger.info(`tts: ${steps.filter((s) => s.audioFile).length} narrated step(s), ` +
30
+ `${Math.round(steps.reduce((a, s) => a + s.audioDurationMs, 0) / 1000)}s of narration`);
31
+ return result;
32
+ }
33
+ /** Load a previous run's tts.json (for `--phase record`/`--phase post`). */
34
+ export async function loadTTSResult(workDir) {
35
+ try {
36
+ return JSON.parse(await readFile(join(workDir, TTS_RESULT_FILE), 'utf8'));
37
+ }
38
+ catch (err) {
39
+ throw new Error(`No ${TTS_RESULT_FILE} in ${workDir} — run the tts phase first (cause: ${err instanceof Error ? err.message : err})`);
40
+ }
41
+ }
42
+ //# sourceMappingURL=tts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tts.js","sourceRoot":"","sources":["../../src/pipeline/tts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAEvD,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AACpC,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAO3C,MAAM,eAAe,GAAG,UAAU,CAAC;AAEnC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,QAAkB,EAClB,IAAuF;IAEvF,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IAC7C,MAAM,SAAS,CAAC,QAAQ,CAAC,CAAC;IAE1B,MAAM,KAAK,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE;QAC/E,MAAM,EAAE,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC;QAC3B,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,EAAE,CAAC;YAC3B,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,EAAE,eAAe,EAAE,CAAC,EAAE,CAAC;QAChF,CAAC;QACD,MAAM,SAAS,GAAG,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;QACnD,MAAM,gBAAgB,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;QAChF,MAAM,eAAe,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,CAAC;QACzD,OAAO,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC;IACvE,CAAC,CAAC,CAAC;IAEH,MAAM,MAAM,GAAmB,EAAE,KAAK,EAAE,CAAC;IACzC,MAAM,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,eAAe,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;IACtF,MAAM,CAAC,IAAI,CACT,QAAQ,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,MAAM,qBAAqB;QAClE,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC,gBAAgB,CACzF,CAAC;IACF,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,4EAA4E;AAC5E,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAe;IACjD,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,eAAe,CAAC,EAAE,MAAM,CAAC,CAAmB,CAAC;IAC9F,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,MAAM,eAAe,OAAO,OAAO,sCAAsC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,GAAG,CACrH,CAAC;IACJ,CAAC;AACH,CAAC"}
@@ -0,0 +1,53 @@
1
+ import type { TimingManifest } from '../types.js';
2
+ export declare class FfmpegError extends Error {
3
+ readonly stderr: string;
4
+ constructor(cmd: string, stderr: string);
5
+ }
6
+ export declare function ffmpegVersion(bin?: 'ffmpeg' | 'ffprobe'): Promise<string | null>;
7
+ /** Exact media duration in milliseconds, via ffprobe. */
8
+ export declare function probeDurationMs(file: string): Promise<number>;
9
+ /** Re-encode any audio file to 48kHz mono 16-bit WAV. */
10
+ export declare function normalizeToWav(input: string, output: string): Promise<void>;
11
+ /**
12
+ * Find the calibration flash (a solid magenta frame painted at clock zero) in
13
+ * the first seconds of the raw recording. Returns the offset in ms from the
14
+ * start of the video file to the first flash frame, or null if not found.
15
+ *
16
+ * Uses the signalstats filter: magenta has extreme U and V averages
17
+ * (both near max), which never occurs in normal page content.
18
+ */
19
+ export declare function detectFlashOffsetMs(video: string, scanSeconds?: number): Promise<number | null>;
20
+ /**
21
+ * Parse signalstats metadata=print output. Frames arrive as
22
+ * frame:N pts:P pts_time:T
23
+ * lavfi.signalstats.UAVG=...
24
+ * lavfi.signalstats.VAVG=...
25
+ * A full-frame magenta (#ff00ff) flash measures UAVG≈201 / VAVG≈221 in
26
+ * Chromium's VP8 webm; we accept anything with both chroma averages far from
27
+ * neutral (128) in the magenta direction.
28
+ */
29
+ export declare function parseFlashFromMetadata(out: string): number | null;
30
+ export interface MergeArgsInput {
31
+ rawVideo: string;
32
+ manifest: TimingManifest;
33
+ /** Resolved absolute audio file per narrated step, in step order (nulls for silent steps). */
34
+ audioFiles: Array<string | null>;
35
+ output: string;
36
+ leadInMs: number;
37
+ /** ms on the manifest clock where step playback starts (pre-roll trim point). */
38
+ trimStartMs: number;
39
+ /** ms into the raw video where the manifest clock's zero falls (calibration flash). */
40
+ videoOffsetMs: number;
41
+ /** Scale to this size in post (recording runs at deviceScaleFactor 2). */
42
+ targetWidth: number;
43
+ targetHeight: number;
44
+ /** Burn this .srt into the video, if set. */
45
+ burnSrt?: string;
46
+ }
47
+ /**
48
+ * Build the single-invocation ffmpeg arg list that trims pre-roll, lays each
49
+ * narration clip at its manifest offset over silence, downscales, and
50
+ * transcodes to H.264/AAC. Pure function: tested by asserting on args.
51
+ */
52
+ export declare function buildMergeArgs(input: MergeArgsInput): string[];
53
+ export declare function runFfmpeg(args: string[]): Promise<void>;
@@ -0,0 +1,162 @@
1
+ import { execa } from 'execa';
2
+ import { logger } from '../util/logger.js';
3
+ export class FfmpegError extends Error {
4
+ stderr;
5
+ constructor(cmd, stderr) {
6
+ super(`${cmd} failed:\n${stderr.split('\n').slice(-12).join('\n')}`);
7
+ this.stderr = stderr;
8
+ this.name = 'FfmpegError';
9
+ }
10
+ }
11
+ async function run(bin, args) {
12
+ logger.debug(`${bin} ${args.join(' ')}`);
13
+ try {
14
+ const { stdout } = await execa(bin, args);
15
+ return stdout;
16
+ }
17
+ catch (err) {
18
+ const stderr = err.stderr ?? String(err);
19
+ throw new FfmpegError(bin, stderr);
20
+ }
21
+ }
22
+ export async function ffmpegVersion(bin = 'ffmpeg') {
23
+ try {
24
+ const { stdout } = await execa(bin, ['-version']);
25
+ return /version\s+(\S+)/.exec(stdout)?.[1] ?? null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Exact media duration in milliseconds, via ffprobe. */
32
+ export async function probeDurationMs(file) {
33
+ const out = await run('ffprobe', [
34
+ '-v', 'error',
35
+ '-show_entries', 'format=duration',
36
+ '-of', 'default=noprint_wrappers=1:nokey=1',
37
+ file,
38
+ ]);
39
+ const seconds = parseFloat(out.trim());
40
+ if (!Number.isFinite(seconds))
41
+ throw new Error(`ffprobe returned no duration for ${file}`);
42
+ return Math.round(seconds * 1000);
43
+ }
44
+ /** Re-encode any audio file to 48kHz mono 16-bit WAV. */
45
+ export async function normalizeToWav(input, output) {
46
+ // -f wav: output may land at a temp path without a .wav extension.
47
+ await run('ffmpeg', ['-y', '-i', input, '-ar', '48000', '-ac', '1', '-c:a', 'pcm_s16le', '-f', 'wav', output]);
48
+ }
49
+ /**
50
+ * Find the calibration flash (a solid magenta frame painted at clock zero) in
51
+ * the first seconds of the raw recording. Returns the offset in ms from the
52
+ * start of the video file to the first flash frame, or null if not found.
53
+ *
54
+ * Uses the signalstats filter: magenta has extreme U and V averages
55
+ * (both near max), which never occurs in normal page content.
56
+ */
57
+ export async function detectFlashOffsetMs(video, scanSeconds = 4) {
58
+ try {
59
+ const { stdout } = await execa('ffmpeg', [
60
+ '-t', String(scanSeconds),
61
+ '-i', video,
62
+ '-vf', 'signalstats,metadata=print:file=-',
63
+ '-f', 'null', '-',
64
+ ]);
65
+ return parseFlashFromMetadata(stdout);
66
+ }
67
+ catch (err) {
68
+ logger.warn(`flash detection failed: ${err instanceof Error ? err.message : err}`);
69
+ return null;
70
+ }
71
+ }
72
+ /**
73
+ * Parse signalstats metadata=print output. Frames arrive as
74
+ * frame:N pts:P pts_time:T
75
+ * lavfi.signalstats.UAVG=...
76
+ * lavfi.signalstats.VAVG=...
77
+ * A full-frame magenta (#ff00ff) flash measures UAVG≈201 / VAVG≈221 in
78
+ * Chromium's VP8 webm; we accept anything with both chroma averages far from
79
+ * neutral (128) in the magenta direction.
80
+ */
81
+ export function parseFlashFromMetadata(out) {
82
+ let ptsTime = null;
83
+ let uavg = null;
84
+ let vavg = null;
85
+ for (const line of out.split('\n')) {
86
+ const frame = /pts_time:([\d.]+)/.exec(line);
87
+ if (frame) {
88
+ ptsTime = parseFloat(frame[1]);
89
+ uavg = vavg = null;
90
+ continue;
91
+ }
92
+ const u = /lavfi\.signalstats\.UAVG=([\d.]+)/.exec(line);
93
+ if (u)
94
+ uavg = parseFloat(u[1]);
95
+ const v = /lavfi\.signalstats\.VAVG=([\d.]+)/.exec(line);
96
+ if (v)
97
+ vavg = parseFloat(v[1]);
98
+ if (ptsTime !== null && uavg !== null && vavg !== null) {
99
+ if (uavg > 180 && vavg > 170)
100
+ return Math.round(ptsTime * 1000);
101
+ uavg = vavg = null;
102
+ }
103
+ }
104
+ return null;
105
+ }
106
+ /**
107
+ * Build the single-invocation ffmpeg arg list that trims pre-roll, lays each
108
+ * narration clip at its manifest offset over silence, downscales, and
109
+ * transcodes to H.264/AAC. Pure function: tested by asserting on args.
110
+ */
111
+ export function buildMergeArgs(input) {
112
+ const { manifest, audioFiles, leadInMs, trimStartMs, videoOffsetMs } = input;
113
+ const args = ['-y', '-i', input.rawVideo];
114
+ const narrated = [];
115
+ manifest.steps.forEach((s, i) => {
116
+ const file = audioFiles[i];
117
+ if (!file)
118
+ return;
119
+ args.push('-i', file);
120
+ // Audio offsets are relative to the *trimmed* video start.
121
+ const delayMs = Math.max(0, s.startMs + leadInMs - trimStartMs);
122
+ narrated.push({ inputIndex: narrated.length + 1, delayMs });
123
+ });
124
+ const filters = [];
125
+ const durationS = ((manifest.totalDurationMs - trimStartMs) / 1000).toFixed(3);
126
+ // Video chain: trim pre-roll + post-tutorial tail (manifest clock shifted
127
+ // into video time by the flash offset), reset timestamps, downscale.
128
+ const videoTrimStartS = ((trimStartMs + videoOffsetMs) / 1000).toFixed(3);
129
+ const videoTrimEndS = ((manifest.totalDurationMs + videoOffsetMs) / 1000).toFixed(3);
130
+ const vf = [
131
+ `trim=start=${videoTrimStartS}:end=${videoTrimEndS}`,
132
+ 'setpts=PTS-STARTPTS',
133
+ ];
134
+ vf.push(`scale=${input.targetWidth}:${input.targetHeight}:flags=lanczos`);
135
+ if (input.burnSrt) {
136
+ vf.push(`subtitles=${escapeFilterPath(input.burnSrt)}`);
137
+ }
138
+ filters.push(`[0:v]${vf.join(',')}[vout]`);
139
+ // Audio chain: silence base + each clip delayed to its slot, mixed.
140
+ filters.push(`anullsrc=channel_layout=mono:sample_rate=48000,atrim=duration=${durationS}[abase]`);
141
+ const mixInputs = ['[abase]'];
142
+ narrated.forEach(({ inputIndex, delayMs }, n) => {
143
+ filters.push(`[${inputIndex}:a]adelay=${delayMs}:all=1[a${n}]`);
144
+ mixInputs.push(`[a${n}]`);
145
+ });
146
+ if (narrated.length > 0) {
147
+ filters.push(`${mixInputs.join('')}amix=inputs=${mixInputs.length}:duration=first:normalize=0[aout]`);
148
+ }
149
+ else {
150
+ filters.push('[abase]acopy[aout]');
151
+ }
152
+ args.push('-filter_complex', filters.join(';'), '-map', '[vout]', '-map', '[aout]', '-c:v', 'libx264', '-crf', '18', '-preset', 'slow', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', input.output);
153
+ return args;
154
+ }
155
+ /** ffmpeg filter args need ':' and '\' escaped inside path values. */
156
+ function escapeFilterPath(p) {
157
+ return `'${p.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/:/g, '\\:')}'`;
158
+ }
159
+ export async function runFfmpeg(args) {
160
+ await run('ffmpeg', args);
161
+ }
162
+ //# sourceMappingURL=ffmpeg.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ffmpeg.js","sourceRoot":"","sources":["../../src/post/ffmpeg.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import type { TimingManifest } from '../types.js';
2
+ /**
3
+ * One SRT cue per narrated step: text = narration, range = narration playback
4
+ * window. Cue times are relative to the trimmed video, so trimStartMs (manifest
5
+ * clock) is subtracted.
6
+ */
7
+ export declare function generateSrt(manifest: TimingManifest, opts: {
8
+ leadInMs: number;
9
+ trimStartMs: number;
10
+ maxLineChars?: number;
11
+ }): string;
12
+ export declare function srtTime(ms: number): string;
13
+ /** Greedy word wrap at ~max chars/line. */
14
+ export declare function wrapText(text: string, max: number): string;