@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/.claude/skills/record-demo/SKILL.md +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tandryukha/aidemo",
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"aidemo": "bin/aidemo.mjs"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"mcpName": "io.github.tandryukha/aidemo",
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"src/",
|
|
17
|
+
"docs/AUTHORING.md",
|
|
18
|
+
".claude/skills/record-demo/SKILL.md"
|
|
19
|
+
],
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/tandryukha/aidemo.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://aidemo.top/",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"aidemo": "tsx src/cli.ts",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"serve-example": "node examples/local-demo/serve.mjs",
|
|
29
|
+
"mcp-smoke": "node test/mcp-smoke.mjs",
|
|
30
|
+
"guard-openai-key": "tsx scripts/guard-openai-key.ts",
|
|
31
|
+
"sync:plugin-skill": "cp .claude/skills/record-demo/SKILL.md plugins/record-demo/skills/record-demo/SKILL.md",
|
|
32
|
+
"check:plugin-skill": "diff -q .claude/skills/record-demo/SKILL.md plugins/record-demo/skills/record-demo/SKILL.md"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
36
|
+
"commander": "^12.1.0",
|
|
37
|
+
"openai": "^4.67.0",
|
|
38
|
+
"playwright": "^1.48.0",
|
|
39
|
+
"tsx": "^4.23.1",
|
|
40
|
+
"zod": "^3.23.8",
|
|
41
|
+
"zod-to-json-schema": "3.25.2"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.20.1",
|
|
45
|
+
"typescript": "^5.6.0"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { Cue } from "./captions.js";
|
|
4
|
+
import { ensureDir } from "./util.js";
|
|
5
|
+
|
|
6
|
+
export interface RenderedCue {
|
|
7
|
+
png: string;
|
|
8
|
+
startMs: number;
|
|
9
|
+
endMs: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Fixed caption strip height (px) at the video's native width. */
|
|
14
|
+
const STRIP_HEIGHT = 160;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Rasterizes each caption cue to a transparent PNG using headless Chrome. We do
|
|
18
|
+
* this because this ffmpeg build lacks libass/libfreetype (no subtitles/drawtext
|
|
19
|
+
* filters) — but `overlay` is always available, and CSS gives nicer captions.
|
|
20
|
+
* Each PNG is <videoWidth> x STRIP_HEIGHT with a centered pill near the bottom.
|
|
21
|
+
*/
|
|
22
|
+
export async function renderCaptionPngs(
|
|
23
|
+
cues: Cue[],
|
|
24
|
+
outDir: string,
|
|
25
|
+
videoWidth: number,
|
|
26
|
+
scale = 1
|
|
27
|
+
): Promise<RenderedCue[]> {
|
|
28
|
+
await ensureDir(outDir);
|
|
29
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
30
|
+
// Layout at the storyboard's logical width; deviceScaleFactor matches the
|
|
31
|
+
// raw video's pixel density (1 for Playwright capture, 2 for native/OBS
|
|
32
|
+
// retina capture) so the PNG overlays the frame 1:1.
|
|
33
|
+
const context = await browser.newContext({
|
|
34
|
+
viewport: { width: videoWidth, height: STRIP_HEIGHT },
|
|
35
|
+
deviceScaleFactor: scale,
|
|
36
|
+
});
|
|
37
|
+
const page = await context.newPage();
|
|
38
|
+
|
|
39
|
+
const rendered: RenderedCue[] = [];
|
|
40
|
+
try {
|
|
41
|
+
for (const cue of cues) {
|
|
42
|
+
await page.setContent(html(cue.text), { waitUntil: "load" });
|
|
43
|
+
const png = resolve(outDir, `cue-${String(cue.index).padStart(3, "0")}.png`);
|
|
44
|
+
await page.screenshot({ path: png, omitBackground: true });
|
|
45
|
+
rendered.push({ png, startMs: cue.startMs, endMs: cue.endMs, height: STRIP_HEIGHT });
|
|
46
|
+
}
|
|
47
|
+
} finally {
|
|
48
|
+
await browser.close();
|
|
49
|
+
}
|
|
50
|
+
return rendered;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function html(text: string): string {
|
|
54
|
+
const safe = text
|
|
55
|
+
.replace(/&/g, "&")
|
|
56
|
+
.replace(/</g, "<")
|
|
57
|
+
.replace(/>/g, ">");
|
|
58
|
+
return `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
59
|
+
html,body{margin:0;padding:0;background:transparent;width:100%;height:100%;}
|
|
60
|
+
.wrap{position:absolute;inset:0;display:flex;align-items:flex-end;
|
|
61
|
+
justify-content:center;padding-bottom:22px;}
|
|
62
|
+
.cap{max-width:80%;font-family:-apple-system,"Helvetica Neue",Arial,sans-serif;
|
|
63
|
+
font-size:30px;line-height:1.3;font-weight:650;color:#fff;text-align:center;
|
|
64
|
+
background:rgba(12,14,22,.66);padding:10px 20px;border-radius:12px;
|
|
65
|
+
-webkit-font-smoothing:antialiased;
|
|
66
|
+
text-shadow:0 1px 3px rgba(0,0,0,.55);
|
|
67
|
+
box-shadow:0 4px 18px rgba(0,0,0,.35);}
|
|
68
|
+
</style></head><body><div class="wrap"><div class="cap">${safe}</div></div></body></html>`;
|
|
69
|
+
}
|
package/src/captions.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import { createReadStream, promises as fs } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { Project } from "./project.js";
|
|
4
|
+
import {
|
|
5
|
+
requireOpenAiKey,
|
|
6
|
+
openAiBaseUrl,
|
|
7
|
+
STT_MODEL,
|
|
8
|
+
explainAudioEndpointError,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
import {
|
|
11
|
+
VoiceManifestSchema,
|
|
12
|
+
CaptionsManifestSchema,
|
|
13
|
+
type CaptionsManifest,
|
|
14
|
+
type Storyboard,
|
|
15
|
+
} from "./types.js";
|
|
16
|
+
import { resolveNarrationLanguage } from "./i18n.js";
|
|
17
|
+
import {
|
|
18
|
+
srtTime,
|
|
19
|
+
readJson,
|
|
20
|
+
writeJson,
|
|
21
|
+
exists,
|
|
22
|
+
ok,
|
|
23
|
+
step,
|
|
24
|
+
log,
|
|
25
|
+
type SceneProgress,
|
|
26
|
+
} from "./util.js";
|
|
27
|
+
|
|
28
|
+
export interface Cue {
|
|
29
|
+
index: number;
|
|
30
|
+
startMs: number;
|
|
31
|
+
endMs: number;
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Word {
|
|
36
|
+
word: string;
|
|
37
|
+
start: number; // seconds
|
|
38
|
+
end: number; // seconds
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_WORDS = 6;
|
|
42
|
+
const MAX_CUE_MS = 3000;
|
|
43
|
+
|
|
44
|
+
/** Whisper's `prompt` is only used to bias roughly its first ~224 tokens of
|
|
45
|
+
* context; cap what we send so a long demo's script doesn't balloon the
|
|
46
|
+
* request for no benefit beyond that window. */
|
|
47
|
+
const MAX_PROMPT_CHARS = 900;
|
|
48
|
+
|
|
49
|
+
/** Explicit per-run STT overrides (CLI `--stt-lang`, future MCP param) plus
|
|
50
|
+
* the shared per-scene progress hooks. */
|
|
51
|
+
export interface CaptionsSttOptions extends SceneProgress {
|
|
52
|
+
/** ISO-639-1/BCP-47 language hint for Whisper, overriding --lang / storyboard.language. */
|
|
53
|
+
language?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Transcribes the composed narration track with word-level timestamps and
|
|
58
|
+
* writes captions.srt + captions.vtt + captions.cues.json. Using the real audio
|
|
59
|
+
* (not the script) means caption timing matches the actual voiceover.
|
|
60
|
+
*
|
|
61
|
+
* When `storyboard` is given (the already-localized storyboard for this
|
|
62
|
+
* render), the request is biased toward the known script: the narration text
|
|
63
|
+
* is sent as Whisper's `prompt` (nudges the transcript to converge on the
|
|
64
|
+
* scripted words/spelling instead of guessing from audio alone — this is the
|
|
65
|
+
* fix for non-English narration getting mangled, e.g. Estonian "algab"
|
|
66
|
+
* transcribed as "aldab") and a language hint is sent when known (an active
|
|
67
|
+
* `--lang` render, `opts.language`, or the storyboard's own `language`
|
|
68
|
+
* field). See docs/AUTHORING.md "Captions: Whisper STT vs. offline".
|
|
69
|
+
*
|
|
70
|
+
* This path is a single Whisper/STT call, not a per-scene loop — so progress
|
|
71
|
+
* is coarse (best-effort): `onSceneStart` fires once up-front with the full
|
|
72
|
+
* scene count (so a poller at least sees scenesTotal), and `onSceneComplete`
|
|
73
|
+
* fires once per scene, all at once, right after transcription lands.
|
|
74
|
+
*/
|
|
75
|
+
export async function generateCaptions(
|
|
76
|
+
project: Project,
|
|
77
|
+
storyboard?: Storyboard,
|
|
78
|
+
opts: CaptionsSttOptions = {}
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const base = openAiBaseUrl();
|
|
81
|
+
step(
|
|
82
|
+
base
|
|
83
|
+
? `Generating captions (STT @ ${base})`
|
|
84
|
+
: "Generating captions (Whisper word timestamps)"
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Scene boundaries (ms) in the narration track keep a cue from spanning two
|
|
88
|
+
// scenes, so caption breaks line up with the on-screen beats.
|
|
89
|
+
const sceneEnds = await sceneEndTimes(project);
|
|
90
|
+
const ids = await sceneIdsFor(project);
|
|
91
|
+
const total = ids.length || sceneEnds.length;
|
|
92
|
+
const reportDone = () => {
|
|
93
|
+
for (let i = 0; i < total; i++) opts.onSceneComplete?.(ids[i] ?? "", i, total);
|
|
94
|
+
};
|
|
95
|
+
if (total > 0) opts.onSceneStart?.(ids[0] ?? "", 0, total);
|
|
96
|
+
const config = captionConfig(await loadGapMs(project));
|
|
97
|
+
|
|
98
|
+
const prompt = storyboard ? buildSttPrompt(storyboard) : undefined;
|
|
99
|
+
const language = opts.language ?? resolveNarrationLanguage(storyboard, project.lang);
|
|
100
|
+
if (language && !/^en\b/i.test(language)) {
|
|
101
|
+
log(
|
|
102
|
+
`⚠ non-English narration ("${language}") — Whisper is biased with the ` +
|
|
103
|
+
`script + language hint, but if burned-in captions still look wrong, ` +
|
|
104
|
+
`\`aidemo captions ${project.dir} --offline\` is the guaranteed-correct ` +
|
|
105
|
+
`fallback (cues derived from the storyboard script, no STT).`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Content-hash reuse (mirrors voice.ts): transcription is a pure function of
|
|
110
|
+
// the narration audio + model + endpoint + scene boundaries + grouping config
|
|
111
|
+
// + prompt bias + language hint. Unchanged → reuse the stored cues and skip
|
|
112
|
+
// the Whisper/STT call entirely.
|
|
113
|
+
const model = STT_MODEL();
|
|
114
|
+
const endpoint = base ?? "openai";
|
|
115
|
+
const narrationHash = await fileHash(project.narrationPath);
|
|
116
|
+
const inputHash = hashOf({
|
|
117
|
+
mode: "stt",
|
|
118
|
+
narrationHash,
|
|
119
|
+
model,
|
|
120
|
+
endpoint,
|
|
121
|
+
sceneEnds,
|
|
122
|
+
config,
|
|
123
|
+
prompt,
|
|
124
|
+
language,
|
|
125
|
+
});
|
|
126
|
+
const prior = await readCaptionsManifest(project);
|
|
127
|
+
if (prior && prior.mode === "stt" && prior.inputHash === inputHash) {
|
|
128
|
+
await writeCaptionFiles(project, prior.cues);
|
|
129
|
+
ok(`captions unchanged, reusing (cached; skipped transcription)`);
|
|
130
|
+
reportDone();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Lazy import: the offline path below must work without touching the SDK.
|
|
135
|
+
const { default: OpenAI } = await import("openai");
|
|
136
|
+
// baseURL undefined → api.openai.com; set → any OpenAI-compatible server.
|
|
137
|
+
const client = new OpenAI({ apiKey: requireOpenAiKey(), baseURL: base });
|
|
138
|
+
|
|
139
|
+
const res = await client.audio.transcriptions
|
|
140
|
+
.create({
|
|
141
|
+
file: createReadStream(project.narrationPath),
|
|
142
|
+
model,
|
|
143
|
+
response_format: "verbose_json",
|
|
144
|
+
timestamp_granularities: ["word"],
|
|
145
|
+
...(prompt ? { prompt } : {}),
|
|
146
|
+
...(language ? { language } : {}),
|
|
147
|
+
})
|
|
148
|
+
.catch((err) => explainAudioEndpointError(err, "stt"));
|
|
149
|
+
|
|
150
|
+
const words: Word[] = (res as unknown as { words?: Word[] }).words ?? [];
|
|
151
|
+
if (words.length === 0) {
|
|
152
|
+
log("no word timestamps returned; captions may be empty");
|
|
153
|
+
}
|
|
154
|
+
const cues = groupWords(words, sceneEnds);
|
|
155
|
+
await writeCaptionFiles(project, cues);
|
|
156
|
+
await writeCaptionsManifest(project, { mode: "stt", inputHash, config, cues });
|
|
157
|
+
reportDone();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Whisper prompt bias: the known narration script, in scene order, so the
|
|
162
|
+
* transcript converges on the correctly-spelled words instead of guessing
|
|
163
|
+
* from audio phonetics alone. Capped to MAX_PROMPT_CHARS (see above).
|
|
164
|
+
*/
|
|
165
|
+
function buildSttPrompt(storyboard: Storyboard): string | undefined {
|
|
166
|
+
const text = storyboard.scenes
|
|
167
|
+
.map((s) => s.narration.trim())
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
.join(" ");
|
|
170
|
+
if (!text) return undefined;
|
|
171
|
+
return text.length > MAX_PROMPT_CHARS ? text.slice(0, MAX_PROMPT_CHARS) : text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Offline fallback: builds captions from the storyboard script + voice.json
|
|
176
|
+
* timings instead of transcribing the audio — no network, no STT. Timing is
|
|
177
|
+
* exact at scene boundaries (each scene's measured narration duration) but
|
|
178
|
+
* approximate within a scene: words are spread across it proportional to
|
|
179
|
+
* their length, not the actual speech rhythm.
|
|
180
|
+
*/
|
|
181
|
+
export async function generateCaptionsOffline(
|
|
182
|
+
project: Project,
|
|
183
|
+
storyboard: Storyboard,
|
|
184
|
+
opts: SceneProgress = {}
|
|
185
|
+
): Promise<void> {
|
|
186
|
+
step("Generating captions (offline, from script + voice.json timings)");
|
|
187
|
+
const voice = VoiceManifestSchema.parse(
|
|
188
|
+
await readJson(project.voiceManifestPath)
|
|
189
|
+
);
|
|
190
|
+
const durById = new Map(voice.scenes.map((s) => [s.id, s.durationMs]));
|
|
191
|
+
const config = captionConfig(voice.gapMs);
|
|
192
|
+
|
|
193
|
+
// Per-scene caption identity = narration + this scene's measured duration (+
|
|
194
|
+
// grouping config). The scene-relative word timings depend on nothing else, so
|
|
195
|
+
// a scene whose identity is unchanged reuses its stored words — only a changed
|
|
196
|
+
// scene is re-derived (mirrors voice.ts's per-scene reuse).
|
|
197
|
+
type SceneMeta = { id: string; hash: string; durationMs: number };
|
|
198
|
+
const meta: SceneMeta[] = storyboard.scenes.map((scene) => {
|
|
199
|
+
const durationMs = durById.get(scene.id);
|
|
200
|
+
if (durationMs == null) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`voice.json has no scene "${scene.id}" — re-run: aidemo voice ${project.dir}`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
id: scene.id,
|
|
207
|
+
durationMs,
|
|
208
|
+
hash: hashOf({ narration: scene.narration, durationMs, config }),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const prior = await readCaptionsManifest(project);
|
|
213
|
+
const priorOffline = prior && prior.mode === "offline" ? prior : null;
|
|
214
|
+
const inputHash = hashOf({ mode: "offline", config, meta });
|
|
215
|
+
const total = meta.length;
|
|
216
|
+
|
|
217
|
+
// Fast path: every scene identical → reuse the stored cues verbatim, no
|
|
218
|
+
// derivation at all. This is the "cached; skipped transcription" 2nd-run case.
|
|
219
|
+
if (priorOffline && priorOffline.inputHash === inputHash) {
|
|
220
|
+
await writeCaptionFiles(project, priorOffline.cues);
|
|
221
|
+
ok(`captions unchanged, reusing (cached; skipped transcription)`);
|
|
222
|
+
meta.forEach((m, i) => opts.onSceneComplete?.(m.id, i, total));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const priorById = new Map(
|
|
227
|
+
(priorOffline?.scenes ?? []).map((s) => [s.id, s])
|
|
228
|
+
);
|
|
229
|
+
const narrationById = new Map(
|
|
230
|
+
storyboard.scenes.map((s) => [s.id, s.narration])
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const manifestScenes: NonNullable<CaptionsManifest["scenes"]> = [];
|
|
234
|
+
let derived = 0;
|
|
235
|
+
let reused = 0;
|
|
236
|
+
for (let i = 0; i < meta.length; i++) {
|
|
237
|
+
const m = meta[i];
|
|
238
|
+
opts.onSceneStart?.(m.id, i, total);
|
|
239
|
+
const prev = priorById.get(m.id);
|
|
240
|
+
if (prev && prev.hash === m.hash) {
|
|
241
|
+
manifestScenes.push({ ...m, words: prev.words });
|
|
242
|
+
log(`scene ${m.id}: unchanged, reusing caption timing`);
|
|
243
|
+
reused++;
|
|
244
|
+
} else {
|
|
245
|
+
const words = deriveSceneWords(narrationById.get(m.id) ?? "", m.durationMs);
|
|
246
|
+
manifestScenes.push({ ...m, words });
|
|
247
|
+
log(`scene ${m.id}: deriving caption timing`);
|
|
248
|
+
derived++;
|
|
249
|
+
}
|
|
250
|
+
opts.onSceneComplete?.(m.id, i, total);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Assemble scene-relative words into the absolute narration timeline. Scene
|
|
254
|
+
// durations + gaps come from voice.json, so a change in one scene's duration
|
|
255
|
+
// correctly shifts later scenes without re-deriving their word *content*.
|
|
256
|
+
const words: Word[] = [];
|
|
257
|
+
const sceneEnds: number[] = [];
|
|
258
|
+
let cursor = 0; // ms into the assembled narration track
|
|
259
|
+
for (const s of manifestScenes) {
|
|
260
|
+
for (const w of s.words) {
|
|
261
|
+
words.push({
|
|
262
|
+
word: w.word,
|
|
263
|
+
start: cursor / 1000 + w.start,
|
|
264
|
+
end: cursor / 1000 + w.end,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
cursor += s.durationMs;
|
|
268
|
+
sceneEnds.push(cursor);
|
|
269
|
+
cursor += voice.gapMs;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const cues = groupWords(words, sceneEnds);
|
|
273
|
+
await writeCaptionFiles(project, cues);
|
|
274
|
+
await writeCaptionsManifest(project, {
|
|
275
|
+
mode: "offline",
|
|
276
|
+
inputHash,
|
|
277
|
+
config,
|
|
278
|
+
scenes: manifestScenes,
|
|
279
|
+
cues,
|
|
280
|
+
});
|
|
281
|
+
log(`captions: ${derived} derived, ${reused} reused`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Scene-relative word timings for the offline path: each word starts at 0 within
|
|
286
|
+
* its scene. Weight by length (+1 for the following pause) so long words get
|
|
287
|
+
* proportionally more of the scene's measured duration.
|
|
288
|
+
*/
|
|
289
|
+
function deriveSceneWords(narration: string, durationMs: number): Word[] {
|
|
290
|
+
const tokens = narration.split(/\s+/).filter(Boolean);
|
|
291
|
+
const weights = tokens.map((t) => t.length + 1);
|
|
292
|
+
const total = weights.reduce((a, w) => a + w, 0) || 1;
|
|
293
|
+
const words: Word[] = [];
|
|
294
|
+
let at = 0; // ms, relative to the scene start
|
|
295
|
+
tokens.forEach((t, i) => {
|
|
296
|
+
const wordMs = (weights[i] / total) * durationMs;
|
|
297
|
+
words.push({ word: t, start: at / 1000, end: (at + wordMs) / 1000 });
|
|
298
|
+
at += wordMs;
|
|
299
|
+
});
|
|
300
|
+
return words;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Shared tail of both paths — same files, same formats, either way. */
|
|
304
|
+
async function writeCaptionFiles(project: Project, cues: Cue[]): Promise<void> {
|
|
305
|
+
await fs.writeFile(project.captionsSrtPath, toSrt(cues));
|
|
306
|
+
await fs.writeFile(project.captionsVttPath, toVtt(cues));
|
|
307
|
+
await writeJson(project.captionsCuesPath, cues);
|
|
308
|
+
ok(`captions → ${project.captionsSrtPath} (${cues.length} cues)`);
|
|
309
|
+
ok(`captions → ${project.captionsCuesPath}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Content-hash reuse (mirrors voice.ts): a caption manifest records the hash of
|
|
314
|
+
// every caption-affecting input + the transcription result, so a re-run whose
|
|
315
|
+
// inputs are unchanged skips Whisper/local transcription and reuses stored word
|
|
316
|
+
// timings. A UI-only re-render (recompose) no longer re-transcribes.
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/** Grouping parameters that affect cue segmentation — part of the cache key. */
|
|
320
|
+
function captionConfig(gapMs: number): CaptionsManifest["config"] {
|
|
321
|
+
return { gapMs, maxWords: MAX_WORDS, maxCueMs: MAX_CUE_MS };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Inter-scene gap from voice.json (0 when absent — captions need voice.json). */
|
|
325
|
+
async function loadGapMs(project: Project): Promise<number> {
|
|
326
|
+
if (!(await exists(project.voiceManifestPath))) return 0;
|
|
327
|
+
const v = VoiceManifestSchema.safeParse(
|
|
328
|
+
await readJson(project.voiceManifestPath)
|
|
329
|
+
);
|
|
330
|
+
return v.success ? v.data.gapMs : 0;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Short stable hash of any JSON-serializable input identity. */
|
|
334
|
+
function hashOf(identity: unknown): string {
|
|
335
|
+
return createHash("sha256")
|
|
336
|
+
.update(JSON.stringify(identity))
|
|
337
|
+
.digest("hex")
|
|
338
|
+
.slice(0, 16);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** sha256 of a file's bytes — the narration audio's content identity (STT path). */
|
|
342
|
+
async function fileHash(path: string): Promise<string> {
|
|
343
|
+
const buf = await fs.readFile(path);
|
|
344
|
+
return createHash("sha256").update(buf).digest("hex").slice(0, 16);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function readCaptionsManifest(
|
|
348
|
+
project: Project
|
|
349
|
+
): Promise<CaptionsManifest | null> {
|
|
350
|
+
if (!(await exists(project.captionsManifestPath))) return null;
|
|
351
|
+
const parsed = CaptionsManifestSchema.safeParse(
|
|
352
|
+
await readJson(project.captionsManifestPath)
|
|
353
|
+
);
|
|
354
|
+
return parsed.success ? parsed.data : null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function writeCaptionsManifest(
|
|
358
|
+
project: Project,
|
|
359
|
+
manifest: CaptionsManifest
|
|
360
|
+
): Promise<void> {
|
|
361
|
+
await writeJson(project.captionsManifestPath, manifest);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Cumulative end time (ms) of each scene's narration in the assembled track. */
|
|
365
|
+
async function sceneEndTimes(project: Project): Promise<number[]> {
|
|
366
|
+
if (!(await exists(project.voiceManifestPath))) return [];
|
|
367
|
+
const voice = VoiceManifestSchema.parse(
|
|
368
|
+
await readJson(project.voiceManifestPath)
|
|
369
|
+
);
|
|
370
|
+
const ends: number[] = [];
|
|
371
|
+
let cum = 0;
|
|
372
|
+
for (const s of voice.scenes) {
|
|
373
|
+
cum += s.durationMs;
|
|
374
|
+
ends.push(cum);
|
|
375
|
+
cum += voice.gapMs;
|
|
376
|
+
}
|
|
377
|
+
return ends;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Scene ids from voice.json, in order — used for the STT path's best-effort
|
|
381
|
+
* progress reporting (there's no natural per-scene loop, unlike offline). */
|
|
382
|
+
async function sceneIdsFor(project: Project): Promise<string[]> {
|
|
383
|
+
if (!(await exists(project.voiceManifestPath))) return [];
|
|
384
|
+
const voice = VoiceManifestSchema.safeParse(
|
|
385
|
+
await readJson(project.voiceManifestPath)
|
|
386
|
+
);
|
|
387
|
+
return voice.success ? voice.data.scenes.map((s) => s.id) : [];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function groupWords(words: Word[], sceneEnds: number[]): Cue[] {
|
|
391
|
+
const cues: Cue[] = [];
|
|
392
|
+
let cur: Word[] = [];
|
|
393
|
+
let sceneIdx = 0;
|
|
394
|
+
const flush = () => {
|
|
395
|
+
if (cur.length === 0) return;
|
|
396
|
+
cues.push({
|
|
397
|
+
index: cues.length + 1,
|
|
398
|
+
startMs: Math.round(cur[0].start * 1000),
|
|
399
|
+
endMs: Math.round(cur[cur.length - 1].end * 1000),
|
|
400
|
+
// trim each word: faster-whisper servers pad words with leading spaces
|
|
401
|
+
text: cur.map((w) => w.word.trim()).join(" ").replace(/\s+([,.!?])/g, "$1").trim(),
|
|
402
|
+
});
|
|
403
|
+
cur = [];
|
|
404
|
+
};
|
|
405
|
+
for (const w of words) {
|
|
406
|
+
const startMs = w.start * 1000;
|
|
407
|
+
// Crossed into a later scene? Break the cue there.
|
|
408
|
+
while (
|
|
409
|
+
sceneIdx < sceneEnds.length - 1 &&
|
|
410
|
+
startMs >= sceneEnds[sceneIdx]
|
|
411
|
+
) {
|
|
412
|
+
flush();
|
|
413
|
+
sceneIdx++;
|
|
414
|
+
}
|
|
415
|
+
cur.push(w);
|
|
416
|
+
const durMs = (w.end - cur[0].start) * 1000;
|
|
417
|
+
const endsSentence = /[.!?]$/.test(w.word.trim());
|
|
418
|
+
if (cur.length >= MAX_WORDS || durMs >= MAX_CUE_MS || endsSentence) flush();
|
|
419
|
+
}
|
|
420
|
+
flush();
|
|
421
|
+
return cues;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function toSrt(cues: Cue[]): string {
|
|
425
|
+
return (
|
|
426
|
+
cues
|
|
427
|
+
.map(
|
|
428
|
+
(c) =>
|
|
429
|
+
`${c.index}\n${srtTime(c.startMs)} --> ${srtTime(c.endMs)}\n${c.text}\n`
|
|
430
|
+
)
|
|
431
|
+
.join("\n") + "\n"
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function toVtt(cues: Cue[]): string {
|
|
436
|
+
const vt = (ms: number) => srtTime(ms).replace(",", ".");
|
|
437
|
+
return (
|
|
438
|
+
"WEBVTT\n\n" +
|
|
439
|
+
cues.map((c) => `${vt(c.startMs)} --> ${vt(c.endMs)}\n${c.text}\n`).join("\n")
|
|
440
|
+
);
|
|
441
|
+
}
|