@tandryukha/aidemo 0.16.1 → 0.17.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/README.md +5 -3
- package/docs/AUTHORING.md +35 -11
- package/package.json +4 -5
- package/src/caption-track.ts +176 -0
- package/src/captions.ts +2 -2
- package/src/compose.ts +99 -6
- package/src/lint.ts +21 -0
- package/src/mcp/server.ts +33 -10
- package/src/project.ts +12 -1
- package/src/types.ts +21 -2
- package/src/walkthrough.ts +11 -10
package/README.md
CHANGED
|
@@ -103,8 +103,10 @@ storyboard.json
|
|
|
103
103
|
→ compose trim idle · sync · auto-zoom · cards · caption · mux → output/final-demo.mp4
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
**4 · What you get** — `output/final-demo.mp4
|
|
107
|
-
|
|
106
|
+
**4 · What you get** — `output/final-demo.mp4` with `final-demo.srt`/`.vtt`
|
|
107
|
+
captions beside it (timed to the video; `captions.track` also embeds them as a
|
|
108
|
+
subtitle track viewers can toggle), plus a README-ready GIF (`aidemo gif`) and
|
|
109
|
+
named stills (`aidemo stills`) from the same take. UI changed?
|
|
108
110
|
Re-run against the same storyboard — no re-recording by hand.
|
|
109
111
|
|
|
110
112
|
The design goal: demos that look **human-made and snappy**, not like an AI
|
|
@@ -282,7 +284,7 @@ demos/<name>/ ← your working area (untracked; scaffold with `aidemo i
|
|
|
282
284
|
generated/ storyboard.json timeline.json captions.{srt,vtt,cues.json}
|
|
283
285
|
recordings/ raw.webm (or raw.mp4 for native/OBS capture)
|
|
284
286
|
audio/ scene-*.mp3 narration.mp3 voice.json
|
|
285
|
-
output/ final-demo.mp4
|
|
287
|
+
output/ final-demo.mp4 final-demo.{srt,vtt} (captions timed to the video)
|
|
286
288
|
logs/ <command>.log fail-<scene>-<n>.{png,json} (on a failed action)
|
|
287
289
|
```
|
|
288
290
|
|
package/docs/AUTHORING.md
CHANGED
|
@@ -104,7 +104,8 @@ so the forecast is in the log next to the take. The same numbers come back
|
|
|
104
104
|
`storyboard.json` → **voice** (TTS per scene) → **record** (drives Chrome,
|
|
105
105
|
injected animated cursor, video + timeline) → **captions** (Whisper word
|
|
106
106
|
timing) → **compose** (trim idle "thinking" spans, sync each scene's video to
|
|
107
|
-
its narration length, overlay captions, mux audio) → `output/final-demo.mp4
|
|
107
|
+
its narration length, overlay captions, mux audio) → `output/final-demo.mp4`,
|
|
108
|
+
with `final-demo.srt`/`.vtt` captions beside it, timed to the video.
|
|
108
109
|
|
|
109
110
|
No LLM runs during capture — the recording is a deterministic replay of your
|
|
110
111
|
fixed action-spec. Voice and captions call an OpenAI-compatible endpoint
|
|
@@ -285,10 +286,11 @@ Cinematic keys (all opt-in; omit for the plain look):
|
|
|
285
286
|
(per-action `keystrokes` overrides). See *Attention*.
|
|
286
287
|
- `captions: {position?="bottom"|"top", style?="pill"|"bar"|"none", font?,
|
|
287
288
|
size?, color?, background?, maxWords?, maxCueMs?, minCueMs?, maxLines?,
|
|
288
|
-
maxCharsPerLine
|
|
289
|
-
override `position` with their own `captions`. Compose also auto-flips a
|
|
290
|
-
to the top while an overlay occupies the bottom band.
|
|
291
|
-
|
|
289
|
+
maxCharsPerLine?, track?=false}` — caption strip placement and look; scenes
|
|
290
|
+
may override `position` with their own `captions`. Compose also auto-flips a
|
|
291
|
+
cue to the top while an overlay occupies the bottom band. `track: true`
|
|
292
|
+
(top level only) also embeds the captions as a soft subtitle track. See
|
|
293
|
+
*Attention* and *Produced look*.
|
|
292
294
|
- `redact: [{selector, frame?, blur?}]` — **blur regions at compose time**
|
|
293
295
|
(prices, emails, tokens); scenes may add their own `redact`. See *Attention*.
|
|
294
296
|
- `hide: [selector, …]` — hide elements at **record** time (teasers, cookie
|
|
@@ -583,10 +585,27 @@ shadowed window on a styled canvas, with captions in the padding band below.
|
|
|
583
585
|
|
|
584
586
|
**Caption styles** (`captions`, top level): `style` is `"pill"` (default —
|
|
585
587
|
centered rounded box), `"bar"` (full-width band flush with the frame edge; the
|
|
586
|
-
usual choice under a `frame`), or `"none"` (no captions burned — the
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
588
|
+
usual choice under a `frame`), or `"none"` (no captions burned — use the
|
|
589
|
+
sidecar files or the subtitle track below instead). `font`, `size` (default
|
|
590
|
+
30, logical px), `color`, `background` (any CSS) restyle the strip;
|
|
591
|
+
`position` still flips per scene / automatically.
|
|
592
|
+
|
|
593
|
+
**Caption files and subtitle track.** Every compose writes
|
|
594
|
+
`output/final-demo.srt` and `output/final-demo.vtt` (`final-demo.<lang>.*`
|
|
595
|
+
for language renders) beside the video, **timed to the final video**: every
|
|
596
|
+
cue is shifted by the intro card's length, exactly like the burned strip and
|
|
597
|
+
the narration. That's what a player or host (YouTube, Loom, Confluence, an
|
|
598
|
+
LMS) wants when it loads captions by file name. `generated/captions.*` stay
|
|
599
|
+
narration-relative (t=0 is the first narrated word) for the engine's own
|
|
600
|
+
use, so don't ship those next to a video that has an intro card. With
|
|
601
|
+
`captions.track: true` compose also muxes the same cues into the MP4 as a
|
|
602
|
+
`mov_text` subtitle track, tagged with the narration language (`--lang`, else
|
|
603
|
+
the storyboard's `language`, else English) and **off until the viewer turns
|
|
604
|
+
it on**. Re-composing replaces the track rather than adding another. For
|
|
605
|
+
captions a viewer can toggle instead of burned ones, combine
|
|
606
|
+
`style: "none"` with `track: true`; with burned captions as well, a viewer
|
|
607
|
+
who turns the track on sees them twice (lint note `caption-track-burned`).
|
|
608
|
+
`report.json` → `captions.srt` / `.vtt` / `.track` record what was written.
|
|
590
609
|
|
|
591
610
|
**Output presets** (`output.preset`) fill `width`/`height`/`fit` when you
|
|
592
611
|
don't set them explicitly: `youtube` 1920×1080, `short` 1080×1920,
|
|
@@ -606,7 +625,8 @@ artifacts in `output/walkthrough/`: `index.html` (one card per scene with
|
|
|
606
625
|
its payoff frame, title, narration and a jump-to-time button over the video;
|
|
607
626
|
← / → keys step through, Enter plays from a step), `guide.md` (the same as a
|
|
608
627
|
README/SOP section: heading + frame + narration per scene), `scene-NN-<id>.png`
|
|
609
|
-
frames, the SRT/VTT captions
|
|
628
|
+
frames, the SRT/VTT captions (timed to the video, like the sidecars), and
|
|
629
|
+
`walkthrough.json`. Frames come from the
|
|
610
630
|
**final** video, so they carry zoom, cursor, overlays and the frame. Scene
|
|
611
631
|
`title`s name the steps (falling back to ids).
|
|
612
632
|
|
|
@@ -1162,7 +1182,11 @@ off), focus events kept vs dropped, and `warnings[]` —
|
|
|
1162
1182
|
the narration even at the x1.6 ceiling and were cut), `blank-tail`,
|
|
1163
1183
|
`stale-captions`, `focus-dropped`, `cursor-missing`, `anchor-unreachable`
|
|
1164
1184
|
(an anchored action could not reach its word within the retime limits; the
|
|
1165
|
-
scene's `anchors[]` shows `targetMs` / `landedMs` / `offMs`)
|
|
1185
|
+
scene's `anchors[]` shows `targetMs` / `landedMs` / `offMs`),
|
|
1186
|
+
`caption-track-empty` (`captions.track` with no caption cues: run `captions`
|
|
1187
|
+
first) and `caption-track-failed` (the ffmpeg build could not mux the
|
|
1188
|
+
subtitle track; the video ships without it and the sidecars still carry the
|
|
1189
|
+
captions). A held scene
|
|
1166
1190
|
is a storyboard problem, not a compose problem: give it on-screen beats or
|
|
1167
1191
|
shorten its narration, then re-run `voice` + `compose`.
|
|
1168
1192
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tandryukha/aidemo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
|
|
6
6
|
"type": "module",
|
|
@@ -37,12 +37,11 @@
|
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@modelcontextprotocol/sdk": "1.32.2",
|
|
39
39
|
"commander": "^12.1.0",
|
|
40
|
-
"openai": "^
|
|
40
|
+
"openai": "^6.49.0",
|
|
41
41
|
"playwright": "^1.55.1",
|
|
42
|
-
"re2js": "2.
|
|
42
|
+
"re2js": "2.9.1",
|
|
43
43
|
"tsx": "^4.23.1",
|
|
44
|
-
"zod": "^
|
|
45
|
-
"zod-to-json-schema": "3.25.2"
|
|
44
|
+
"zod": "^4.6.7"
|
|
46
45
|
},
|
|
47
46
|
"devDependencies": {
|
|
48
47
|
"@types/node": "^22.20.1",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import type { Project } from "./project.js";
|
|
3
|
+
import { toSrt, toVtt, type Cue } from "./captions.js";
|
|
4
|
+
import { exists, readJson } from "./util.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Captions timed to the FINAL video (issue #89).
|
|
8
|
+
*
|
|
9
|
+
* generated/captions.* are narration-relative: t=0 is the start of the
|
|
10
|
+
* narration track. The final MP4 opens on the intro card, and compose delays
|
|
11
|
+
* the narration (adelay) and the burned cues by the card's length — so those
|
|
12
|
+
* files run early by exactly that much against final-demo.mp4. Compose writes
|
|
13
|
+
* re-timed copies beside the video (what players and hosts pick up by file
|
|
14
|
+
* name) and, with `captions.track`, muxes them as an off-by-default subtitle
|
|
15
|
+
* track. The walkthrough bundle ships the same re-timed cues.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Cues re-timed onto the final video: the intro card delays every cue by `introMs`. */
|
|
19
|
+
export function shiftCues(cues: Cue[], introMs: number): Cue[] {
|
|
20
|
+
if (!introMs) return cues;
|
|
21
|
+
return cues.map((c) => ({ ...c, startMs: c.startMs + introMs, endMs: c.endMs + introMs }));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* This project's caption cues (language-scoped), re-timed onto the final
|
|
26
|
+
* video. null when the captions stage has produced none.
|
|
27
|
+
*/
|
|
28
|
+
export async function finalVideoCues(project: Project, introMs: number): Promise<Cue[] | null> {
|
|
29
|
+
if (!(await exists(project.captionsCuesPath))) return null;
|
|
30
|
+
const cues = (await readJson<Cue[]>(project.captionsCuesPath)) ?? [];
|
|
31
|
+
return cues.length ? shiftCues(cues, introMs) : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Write output/final-demo[.<lang>].srt/.vtt for these cues, or — when there
|
|
36
|
+
* are none — remove sidecars an earlier compose left, so a stale file never
|
|
37
|
+
* sits beside a video it doesn't match.
|
|
38
|
+
*/
|
|
39
|
+
export async function writeCaptionSidecars(
|
|
40
|
+
project: Project,
|
|
41
|
+
cues: Cue[] | null
|
|
42
|
+
): Promise<{ srt: string; vtt: string } | null> {
|
|
43
|
+
if (!cues?.length) {
|
|
44
|
+
await Promise.all(
|
|
45
|
+
[project.outputSrtPath, project.outputVttPath].map((p) => fs.rm(p, { force: true }))
|
|
46
|
+
);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
await fs.writeFile(project.outputSrtPath, toSrt(cues));
|
|
50
|
+
await fs.writeFile(project.outputVttPath, toVtt(cues));
|
|
51
|
+
return { srt: project.outputSrtPath, vtt: project.outputVttPath };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** ISO 639-1 → ISO 639-2/T, the three-letter code an MP4 track's language carries. */
|
|
55
|
+
const ISO639_2T: Record<string, string> = {
|
|
56
|
+
af: "afr", ar: "ara", bg: "bul", bn: "ben", ca: "cat", cs: "ces", cy: "cym",
|
|
57
|
+
da: "dan", de: "deu", el: "ell", en: "eng", es: "spa", et: "est", eu: "eus",
|
|
58
|
+
fa: "fas", fi: "fin", fr: "fra", ga: "gle", gl: "glg", he: "heb", hi: "hin",
|
|
59
|
+
hr: "hrv", hu: "hun", hy: "hye", id: "ind", is: "isl", it: "ita", ja: "jpn",
|
|
60
|
+
ka: "kat", kk: "kaz", ko: "kor", lt: "lit", lv: "lav", mk: "mkd", ms: "msa",
|
|
61
|
+
mt: "mlt", nb: "nob", nl: "nld", nn: "nno", no: "nor", pl: "pol", pt: "por",
|
|
62
|
+
ro: "ron", ru: "rus", sk: "slk", sl: "slv", sq: "sqi", sr: "srp", sv: "swe",
|
|
63
|
+
sw: "swa", ta: "tam", te: "tel", th: "tha", tl: "tgl", tr: "tur", uk: "ukr",
|
|
64
|
+
ur: "urd", vi: "vie", zh: "zho",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* MP4 language code for a BCP-47 tag ("en", "en-GB", "pt-BR"): its ISO
|
|
69
|
+
* 639-2/T code. A tag that already leads with a three-letter code ("fil",
|
|
70
|
+
* "yue") passes through; anything unknown is "und" (undetermined).
|
|
71
|
+
*/
|
|
72
|
+
export function mp4Language(tag: string | undefined): string {
|
|
73
|
+
const primary = (tag ?? "").trim().toLowerCase().split(/[-_]/)[0];
|
|
74
|
+
if (/^[a-z]{3}$/.test(primary)) return primary;
|
|
75
|
+
return ISO639_2T[primary] ?? "und";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Track handler types that carry subtitles/captions in ISO-BMFF / QuickTime. */
|
|
79
|
+
const SUBTITLE_HANDLERS = new Set(["sbtl", "text", "subt"]);
|
|
80
|
+
|
|
81
|
+
interface Box {
|
|
82
|
+
type: string;
|
|
83
|
+
start: number;
|
|
84
|
+
size: number;
|
|
85
|
+
header: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The boxes laid end to end in buf[from, to); stops at the first malformed one. */
|
|
89
|
+
function* boxes(buf: Buffer, from: number, to: number): Generator<Box> {
|
|
90
|
+
let p = from;
|
|
91
|
+
while (p + 8 <= to) {
|
|
92
|
+
let size = buf.readUInt32BE(p);
|
|
93
|
+
const type = buf.toString("latin1", p + 4, p + 8);
|
|
94
|
+
let header = 8;
|
|
95
|
+
if (size === 1) {
|
|
96
|
+
if (p + 16 > to) return;
|
|
97
|
+
size = Number(buf.readBigUInt64BE(p + 8));
|
|
98
|
+
header = 16;
|
|
99
|
+
} else if (size === 0) {
|
|
100
|
+
size = to - p;
|
|
101
|
+
}
|
|
102
|
+
if (size < header || p + size > to) return;
|
|
103
|
+
yield { type, start: p, size, header };
|
|
104
|
+
p += size;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const child = (buf: Buffer, parent: Box, type: string): Box | undefined => {
|
|
109
|
+
for (const b of boxes(buf, parent.start + parent.header, parent.start + parent.size)) {
|
|
110
|
+
if (b.type === type) return b;
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Clear `track_enabled` in every subtitle track's `tkhd`, in place — one byte
|
|
117
|
+
* per track, nothing moves, so the faststart layout survives. ffmpeg's mov
|
|
118
|
+
* muxer enables the first track of each media type whatever `-disposition`
|
|
119
|
+
* says, and players show an enabled subtitle track from the start; with the
|
|
120
|
+
* flag cleared the track is listed but off until the viewer picks it.
|
|
121
|
+
* `tracks` = subtitle tracks found, `changed` = flags actually cleared.
|
|
122
|
+
*/
|
|
123
|
+
export async function disableSubtitleTracks(
|
|
124
|
+
file: string
|
|
125
|
+
): Promise<{ tracks: number; changed: number }> {
|
|
126
|
+
const fh = await fs.open(file, "r+");
|
|
127
|
+
try {
|
|
128
|
+
const { size: fileSize } = await fh.stat();
|
|
129
|
+
// Top-level boxes by header only (mdat is most of the file): find moov.
|
|
130
|
+
const head = Buffer.alloc(16);
|
|
131
|
+
let pos = 0;
|
|
132
|
+
let moov: { start: number; size: number } | null = null;
|
|
133
|
+
while (pos + 8 <= fileSize) {
|
|
134
|
+
const { bytesRead } = await fh.read(head, 0, 16, pos);
|
|
135
|
+
if (bytesRead < 8) break;
|
|
136
|
+
let size = head.readUInt32BE(0);
|
|
137
|
+
const type = head.toString("latin1", 4, 8);
|
|
138
|
+
if (size === 1) size = bytesRead >= 16 ? Number(head.readBigUInt64BE(8)) : 0;
|
|
139
|
+
else if (size === 0) size = fileSize - pos;
|
|
140
|
+
if (size < 8) break;
|
|
141
|
+
if (type === "moov") {
|
|
142
|
+
moov = { start: pos, size };
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
pos += size;
|
|
146
|
+
}
|
|
147
|
+
if (!moov) return { tracks: 0, changed: 0 };
|
|
148
|
+
const buf = Buffer.alloc(moov.size);
|
|
149
|
+
await fh.read(buf, 0, moov.size, moov.start);
|
|
150
|
+
const moovBox = boxes(buf, 0, buf.length).next().value;
|
|
151
|
+
if (!moovBox) return { tracks: 0, changed: 0 };
|
|
152
|
+
let tracks = 0;
|
|
153
|
+
let changed = 0;
|
|
154
|
+
for (const trak of boxes(buf, moovBox.header, moovBox.size)) {
|
|
155
|
+
if (trak.type !== "trak") continue;
|
|
156
|
+
const tkhd = child(buf, trak, "tkhd");
|
|
157
|
+
const mdia = child(buf, trak, "mdia");
|
|
158
|
+
const hdlr = mdia && child(buf, mdia, "hdlr");
|
|
159
|
+
if (!tkhd || !hdlr) continue;
|
|
160
|
+
// hdlr payload: version(1) flags(3) pre_defined(4) handler_type(4)
|
|
161
|
+
const at = hdlr.start + hdlr.header + 8;
|
|
162
|
+
if (!SUBTITLE_HANDLERS.has(buf.toString("latin1", at, at + 4))) continue;
|
|
163
|
+
tracks++;
|
|
164
|
+
// tkhd payload: version(1) flags(3); track_enabled = bit 0 of the low byte.
|
|
165
|
+
const flags = tkhd.start + tkhd.header + 3;
|
|
166
|
+
if (buf[flags] & 0x01) {
|
|
167
|
+
buf[flags] &= ~0x01;
|
|
168
|
+
await fh.write(buf, flags, 1, moov.start + flags);
|
|
169
|
+
changed++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { tracks, changed };
|
|
173
|
+
} finally {
|
|
174
|
+
await fh.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/captions.ts
CHANGED
|
@@ -707,7 +707,7 @@ export function cueStats(cues: Cue[], minCueMs = DEFAULT_SEG.minCueMs): {
|
|
|
707
707
|
return { short, midClause, midClausePct: Math.round((midClause / breaks) * 100) };
|
|
708
708
|
}
|
|
709
709
|
|
|
710
|
-
function toSrt(cues: Cue[]): string {
|
|
710
|
+
export function toSrt(cues: Cue[]): string {
|
|
711
711
|
return (
|
|
712
712
|
cues
|
|
713
713
|
.map(
|
|
@@ -718,7 +718,7 @@ function toSrt(cues: Cue[]): string {
|
|
|
718
718
|
);
|
|
719
719
|
}
|
|
720
720
|
|
|
721
|
-
function toVtt(cues: Cue[]): string {
|
|
721
|
+
export function toVtt(cues: Cue[]): string {
|
|
722
722
|
const vt = (ms: number) => srtTime(ms).replace(",", ".");
|
|
723
723
|
return (
|
|
724
724
|
"WEBVTT\n\n" +
|
package/src/compose.ts
CHANGED
|
@@ -19,7 +19,14 @@ import {
|
|
|
19
19
|
HoldSchema,
|
|
20
20
|
AutoIdleSchema,
|
|
21
21
|
} from "./types.js";
|
|
22
|
-
import { sceneWordsFor, type Cue } from "./captions.js";
|
|
22
|
+
import { sceneWordsFor, toSrt, type Cue } from "./captions.js";
|
|
23
|
+
import {
|
|
24
|
+
disableSubtitleTracks,
|
|
25
|
+
finalVideoCues,
|
|
26
|
+
mp4Language,
|
|
27
|
+
writeCaptionSidecars,
|
|
28
|
+
} from "./caption-track.js";
|
|
29
|
+
import { resolveNarrationLanguage } from "./i18n.js";
|
|
23
30
|
import { wordStartMs } from "./anchors.js";
|
|
24
31
|
import { renderCaptionPngs } from "./caption-render.js";
|
|
25
32
|
import { renderCardPng } from "./cards.js";
|
|
@@ -1099,8 +1106,60 @@ export async function compose(
|
|
|
1099
1106
|
: []),
|
|
1100
1107
|
])
|
|
1101
1108
|
: null;
|
|
1102
|
-
|
|
1109
|
+
// Captions timed to the FINAL video (issue #89): the cues the strip burns,
|
|
1110
|
+
// shifted by the intro card exactly like the overlay and the narration's
|
|
1111
|
+
// adelay. Always written beside the MP4; `captions.track` also embeds them.
|
|
1112
|
+
const videoCues = await finalVideoCues(project, introMs);
|
|
1113
|
+
let subtitle: SubtitleTrack | null = null;
|
|
1114
|
+
if (storyboard.captions?.track) {
|
|
1115
|
+
if (videoCues) {
|
|
1116
|
+
subtitle = {
|
|
1117
|
+
path: resolve(tmp, "track.srt"),
|
|
1118
|
+
language: mp4Language(resolveNarrationLanguage(storyboard, project.lang) ?? "en"),
|
|
1119
|
+
};
|
|
1120
|
+
await fs.writeFile(subtitle.path, toSrt(videoCues));
|
|
1121
|
+
} else {
|
|
1122
|
+
warn(
|
|
1123
|
+
"caption-track-empty",
|
|
1124
|
+
`captions.track is set but there are no caption cues — run: aidemo captions ${project.dir}`
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
try {
|
|
1129
|
+
await muxAudio(project, storyboard, finalVideo, introMs, chapters, subtitle);
|
|
1130
|
+
} catch (err) {
|
|
1131
|
+
if (!subtitle) throw err;
|
|
1132
|
+
// An ffmpeg build without the srt demuxer / mov_text encoder: keep the
|
|
1133
|
+
// video, drop the track — the sidecars below still carry the captions.
|
|
1134
|
+
// runFfmpeg's message is "ffmpeg exited N" + the stderr tail; ffmpeg
|
|
1135
|
+
// names the root cause on the first stderr line.
|
|
1136
|
+
const [exit, ...stderr] = String((err as Error).message ?? err).split("\n");
|
|
1137
|
+
const why = stderr.map((l) => l.trim()).find(Boolean);
|
|
1138
|
+
warn(
|
|
1139
|
+
"caption-track-failed",
|
|
1140
|
+
`could not embed the subtitle track, so the video has none (the .srt/.vtt sidecars ` +
|
|
1141
|
+
`still carry the captions): ${exit}${why ? ` — ${why.slice(0, 200)}` : ""}`
|
|
1142
|
+
);
|
|
1143
|
+
subtitle = null;
|
|
1144
|
+
await muxAudio(project, storyboard, finalVideo, introMs, chapters);
|
|
1145
|
+
}
|
|
1146
|
+
if (subtitle) {
|
|
1147
|
+
const { tracks } = await disableSubtitleTracks(project.outputPath);
|
|
1148
|
+
if (tracks === 0) {
|
|
1149
|
+
warn("caption-track-failed", "the muxed MP4 carries no subtitle track");
|
|
1150
|
+
subtitle = null;
|
|
1151
|
+
} else {
|
|
1152
|
+
log(`subtitle track: ${subtitle.language} (mov_text, off until the viewer turns it on)`);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1103
1155
|
ok(`final video → ${project.outputPath}`);
|
|
1156
|
+
const sidecars = await writeCaptionSidecars(project, videoCues);
|
|
1157
|
+
if (sidecars) {
|
|
1158
|
+
ok(
|
|
1159
|
+
`captions → ${relative(project.dir, sidecars.srt)} + .vtt (timed to the video` +
|
|
1160
|
+
`${introMs ? `: cues +${introMs}ms for the intro card` : ""})`
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1104
1163
|
// Blank-open warning + poster (issue #47; measured after scene assembly,
|
|
1105
1164
|
// above). The poster used to be taken at a fixed offset, which on a demo
|
|
1106
1165
|
// that opens with a `goto` is exactly the white load frame; pick the first
|
|
@@ -1166,7 +1225,12 @@ export async function compose(
|
|
|
1166
1225
|
scenes: sceneReports,
|
|
1167
1226
|
zoom: { focusTotal: zoomEvents.length, focusDropped },
|
|
1168
1227
|
cursorPoints: cursorPts.length,
|
|
1169
|
-
captions: {
|
|
1228
|
+
captions: {
|
|
1229
|
+
cues: captionsResult.cues,
|
|
1230
|
+
passes: captionsResult.passes,
|
|
1231
|
+
...(sidecars ? { srt: sidecars.srt, vtt: sidecars.vtt } : {}),
|
|
1232
|
+
...(subtitle ? { track: subtitle.language } : {}),
|
|
1233
|
+
},
|
|
1170
1234
|
hold: hold.mode,
|
|
1171
1235
|
...(poster ? { poster } : {}),
|
|
1172
1236
|
blankOpenMs: Math.round(blankOpenMs),
|
|
@@ -1902,7 +1966,7 @@ async function burnCaptions(
|
|
|
1902
1966
|
): Promise<{ video: string; cues: number; passes: number; flipped: number }> {
|
|
1903
1967
|
const none = { video: silentVideo, cues: 0, passes: 0, flipped: 0 };
|
|
1904
1968
|
if (placement.look?.style === "none") {
|
|
1905
|
-
log("captions: style none — not burned (
|
|
1969
|
+
log("captions: style none — not burned (the .srt/.vtt beside the video carry them)");
|
|
1906
1970
|
return none;
|
|
1907
1971
|
}
|
|
1908
1972
|
if (!(await exists(project.captionsCuesPath))) return none;
|
|
@@ -2007,12 +2071,20 @@ async function fileToDataUri(path: string): Promise<string> {
|
|
|
2007
2071
|
return `data:${mime};base64,${buf.toString("base64")}`;
|
|
2008
2072
|
}
|
|
2009
2073
|
|
|
2074
|
+
/** An SRT (already on the final video's clock) to embed as a soft subtitle track. */
|
|
2075
|
+
interface SubtitleTrack {
|
|
2076
|
+
path: string;
|
|
2077
|
+
/** ISO 639-2 code for the track's language tag. */
|
|
2078
|
+
language: string;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2010
2081
|
async function muxAudio(
|
|
2011
2082
|
project: Project,
|
|
2012
2083
|
storyboard: Storyboard,
|
|
2013
2084
|
video: string,
|
|
2014
2085
|
introMs: number,
|
|
2015
|
-
chaptersPath: string | null = null
|
|
2086
|
+
chaptersPath: string | null = null,
|
|
2087
|
+
subtitle: SubtitleTrack | null = null
|
|
2016
2088
|
): Promise<void> {
|
|
2017
2089
|
let musicPath = await resolveMusic(project, storyboard);
|
|
2018
2090
|
const videoMs = await probeDurationMs(video);
|
|
@@ -2041,6 +2113,12 @@ async function muxAudio(
|
|
|
2041
2113
|
chaptersIdx = musicPath ? 3 : 2;
|
|
2042
2114
|
args.push("-i", chaptersPath);
|
|
2043
2115
|
}
|
|
2116
|
+
// Subtitle SRT last, so every index above is unchanged without one.
|
|
2117
|
+
let subtitleIdx = -1;
|
|
2118
|
+
if (subtitle) {
|
|
2119
|
+
subtitleIdx = 2 + (musicPath ? 1 : 0) + (chaptersPath ? 1 : 0);
|
|
2120
|
+
args.push("-i", subtitle.path);
|
|
2121
|
+
}
|
|
2044
2122
|
|
|
2045
2123
|
const delay = introMs > 0 ? `,adelay=${Math.round(introMs)}:all=1` : "";
|
|
2046
2124
|
const filters: string[] = [];
|
|
@@ -2111,6 +2189,7 @@ async function muxAudio(
|
|
|
2111
2189
|
|
|
2112
2190
|
args.push("-filter_complex", filters.join(";"));
|
|
2113
2191
|
args.push("-map", "0:v", "-map", "[aout]");
|
|
2192
|
+
if (subtitle) args.push("-map", `${subtitleIdx}:s`);
|
|
2114
2193
|
if (chaptersIdx >= 0) args.push("-map_metadata", String(chaptersIdx));
|
|
2115
2194
|
args.push(
|
|
2116
2195
|
"-c:v",
|
|
@@ -2118,7 +2197,21 @@ async function muxAudio(
|
|
|
2118
2197
|
"-c:a",
|
|
2119
2198
|
"aac",
|
|
2120
2199
|
"-b:a",
|
|
2121
|
-
"192k"
|
|
2200
|
+
"192k"
|
|
2201
|
+
);
|
|
2202
|
+
if (subtitle) {
|
|
2203
|
+
// mov_text is the MP4 text-subtitle codec; not default — and compose then
|
|
2204
|
+
// clears the tkhd enabled flag ffmpeg sets anyway (disableSubtitleTracks).
|
|
2205
|
+
args.push(
|
|
2206
|
+
"-c:s",
|
|
2207
|
+
"mov_text",
|
|
2208
|
+
"-metadata:s:s:0",
|
|
2209
|
+
`language=${subtitle.language}`,
|
|
2210
|
+
"-disposition:s:0",
|
|
2211
|
+
"0"
|
|
2212
|
+
);
|
|
2213
|
+
}
|
|
2214
|
+
args.push(
|
|
2122
2215
|
// moov atom up front so the MP4 streams/scrubs in a browser tab or a PR
|
|
2123
2216
|
// preview before it has fully downloaded (frames identical; container only).
|
|
2124
2217
|
"-movflags",
|
package/src/lint.ts
CHANGED
|
@@ -532,6 +532,27 @@ export function lintStoryboard(
|
|
|
532
532
|
fix: "add a `cursor: {}` block (the take is then recorded cursor-free and the cursor is overlaid at compose)",
|
|
533
533
|
});
|
|
534
534
|
}
|
|
535
|
+
if (sb.captions?.track && sb.captions.style !== "none") {
|
|
536
|
+
push({
|
|
537
|
+
severity: "info",
|
|
538
|
+
code: "caption-track-burned",
|
|
539
|
+
message:
|
|
540
|
+
"`captions.track` embeds a soft subtitle track while the captions are also burned in — " +
|
|
541
|
+
"a viewer who turns the track on sees every caption twice",
|
|
542
|
+
fix: 'set `captions.style: "none"` for toggleable captions only, or drop `track` to keep just the burned strip',
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
for (const scene of sb.scenes) {
|
|
546
|
+
if (scene.captions?.track != null) {
|
|
547
|
+
push({
|
|
548
|
+
severity: "info",
|
|
549
|
+
code: "caption-track-scene",
|
|
550
|
+
scene: scene.id,
|
|
551
|
+
message: "`captions.track` is storyboard-level only — a scene's `captions` just moves the strip",
|
|
552
|
+
fix: "move `track` to the top-level `captions` block",
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
535
556
|
if (sb.zoom && sb.zoom.enabled !== false) {
|
|
536
557
|
const capped = clampScaleForWidth(sb.zoom.scale, sb.video.width);
|
|
537
558
|
if (capped < sb.zoom.scale) {
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
5
4
|
import { readFile } from "node:fs/promises";
|
|
6
5
|
import { resolve } from "node:path";
|
|
7
6
|
import {
|
|
@@ -58,6 +57,30 @@ tool (title + body — environment/log context is auto-attached) before you
|
|
|
58
57
|
finish; skip if nothing came up. A failed job_status result also carries a
|
|
59
58
|
feedbackHint pointing back here.`;
|
|
60
59
|
|
|
60
|
+
/**
|
|
61
|
+
* JSON Schema for storyboard.json, generated from the engine's zod schema.
|
|
62
|
+
* Input semantics (a field with a default is optional — what an author
|
|
63
|
+
* writes), draft-07, every sub-schema inlined, and unknown keys rejected
|
|
64
|
+
* (`additionalProperties: false`): zod drops unknown keys silently, so the
|
|
65
|
+
* schema says they are not part of the contract. Same contract the
|
|
66
|
+
* zod-to-json-schema export had before the zod 4 migration.
|
|
67
|
+
*/
|
|
68
|
+
function storyboardJsonSchema(): Record<string, unknown> {
|
|
69
|
+
return z.toJSONSchema(StoryboardSchema, {
|
|
70
|
+
target: "draft-7",
|
|
71
|
+
io: "input",
|
|
72
|
+
unrepresentable: "any",
|
|
73
|
+
override: (ctx) => {
|
|
74
|
+
if (
|
|
75
|
+
ctx.zodSchema._zod.def.type === "object" &&
|
|
76
|
+
ctx.jsonSchema.additionalProperties === undefined
|
|
77
|
+
) {
|
|
78
|
+
ctx.jsonSchema.additionalProperties = false;
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}) as Record<string, unknown>;
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
type ToolResult = {
|
|
62
85
|
content: Array<{ type: "text"; text: string }>;
|
|
63
86
|
structuredContent?: Record<string, unknown>;
|
|
@@ -100,7 +123,7 @@ const JOB_STATUS_SHAPE = {
|
|
|
100
123
|
endedAt: z.string().nullable(),
|
|
101
124
|
logFile: z.string(),
|
|
102
125
|
logTail: z.array(z.string()),
|
|
103
|
-
result: z.record(z.unknown()).optional(),
|
|
126
|
+
result: z.record(z.string(), z.unknown()).optional(),
|
|
104
127
|
error: z
|
|
105
128
|
.object({
|
|
106
129
|
message: z.string(),
|
|
@@ -307,12 +330,12 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
307
330
|
"JSON Schema for storyboard.json, generated from the engine's own " +
|
|
308
331
|
"zod schema — the exact contract the engine validates against.",
|
|
309
332
|
inputSchema: {},
|
|
310
|
-
outputSchema: { schema: z.record(z.unknown()) },
|
|
333
|
+
outputSchema: { schema: z.record(z.string(), z.unknown()) },
|
|
311
334
|
annotations: { readOnlyHint: true },
|
|
312
335
|
},
|
|
313
336
|
async () =>
|
|
314
337
|
jsonResult({
|
|
315
|
-
schema:
|
|
338
|
+
schema: storyboardJsonSchema(),
|
|
316
339
|
})
|
|
317
340
|
);
|
|
318
341
|
|
|
@@ -1103,6 +1126,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1103
1126
|
...(walkthrough ? { walkthrough } : {}),
|
|
1104
1127
|
timeline: lp.timelinePath,
|
|
1105
1128
|
captionsSrt: lp.captionsSrtPath,
|
|
1129
|
+
// Sidecars timed to the final video (+ the embedded track's language).
|
|
1130
|
+
captions: report.captions,
|
|
1106
1131
|
report: lp.reportPath,
|
|
1107
1132
|
durationMs: report.durationMs,
|
|
1108
1133
|
// warningCount lets an orchestrating agent gate on the render
|
|
@@ -1179,7 +1204,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1179
1204
|
|
|
1180
1205
|
registerJob(
|
|
1181
1206
|
"compose",
|
|
1182
|
-
"Trim, sync, mux and caption into output/final-demo.mp4.",
|
|
1207
|
+
"Trim, sync, mux and caption into output/final-demo.mp4 (+ final-demo.srt/.vtt timed to it).",
|
|
1183
1208
|
{
|
|
1184
1209
|
dir: DIR_INPUT,
|
|
1185
1210
|
gif: z.boolean().optional().describe("also export output/final-demo.gif"),
|
|
@@ -1197,6 +1222,8 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1197
1222
|
return {
|
|
1198
1223
|
output: lp.outputPath,
|
|
1199
1224
|
...(gifPath ? { gif: gifPath } : {}),
|
|
1225
|
+
// Sidecars timed to the final video (+ the embedded track's language).
|
|
1226
|
+
captions: report.captions,
|
|
1200
1227
|
report: lp.reportPath,
|
|
1201
1228
|
durationMs: report.durationMs,
|
|
1202
1229
|
// warningCount lets an orchestrating agent gate on the render
|
|
@@ -1438,11 +1465,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1438
1465
|
{
|
|
1439
1466
|
uri: uri.href,
|
|
1440
1467
|
mimeType: "application/json",
|
|
1441
|
-
text: JSON.stringify(
|
|
1442
|
-
zodToJsonSchema(StoryboardSchema, { $refStrategy: "none" }),
|
|
1443
|
-
null,
|
|
1444
|
-
2
|
|
1445
|
-
),
|
|
1468
|
+
text: JSON.stringify(storyboardJsonSchema(), null, 2),
|
|
1446
1469
|
},
|
|
1447
1470
|
],
|
|
1448
1471
|
})
|
package/src/project.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { ensureDir, exists, readJson, writeJson, log } from "./util.js";
|
|
|
12
12
|
* generated/storyboard.json timeline.json captions.srt captions.vtt
|
|
13
13
|
* recordings/raw.webm
|
|
14
14
|
* audio/scene-*.mp3 narration.mp3 voice.json
|
|
15
|
-
* output/final-demo.mp4
|
|
15
|
+
* output/final-demo.mp4 final-demo.srt final-demo.vtt
|
|
16
16
|
* logs/
|
|
17
17
|
*/
|
|
18
18
|
export class Project {
|
|
@@ -117,6 +117,17 @@ export class Project {
|
|
|
117
117
|
get outputPath() {
|
|
118
118
|
return this.p("output", this.lang ? `final-demo.${this.lang}.mp4` : "final-demo.mp4");
|
|
119
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Caption sidecars named after the video (final-demo[.<lang>].srt/.vtt), so
|
|
122
|
+
* a player that loads captions by file name finds them. Timed to the FINAL
|
|
123
|
+
* video — unlike generated/captions.*, which start at the first narrated word.
|
|
124
|
+
*/
|
|
125
|
+
get outputSrtPath() {
|
|
126
|
+
return this.outputPath.replace(/\.mp4$/, ".srt");
|
|
127
|
+
}
|
|
128
|
+
get outputVttPath() {
|
|
129
|
+
return this.outputPath.replace(/\.mp4$/, ".vtt");
|
|
130
|
+
}
|
|
120
131
|
get gifPath() {
|
|
121
132
|
return this.p("output", this.lang ? `final-demo.${this.lang}.gif` : "final-demo.gif");
|
|
122
133
|
}
|
package/src/types.ts
CHANGED
|
@@ -503,6 +503,15 @@ export const CaptionsConfigSchema = z.object({
|
|
|
503
503
|
maxLines: z.number().int().min(1).max(3).optional(),
|
|
504
504
|
/** Characters per line used to size a cue. Default 38. */
|
|
505
505
|
maxCharsPerLine: z.number().int().min(16).max(80).optional(),
|
|
506
|
+
/**
|
|
507
|
+
* Also embed the captions in the MP4 as a soft subtitle track (mov_text,
|
|
508
|
+
* tagged with the narration language) that players leave OFF until the
|
|
509
|
+
* viewer turns it on (issue #89). Storyboard level only. Pair it with
|
|
510
|
+
* `style: "none"` for toggleable captions instead of burned ones. Compose
|
|
511
|
+
* always writes the same cues beside the video as final-demo.srt/.vtt;
|
|
512
|
+
* this adds the in-file track. Default false.
|
|
513
|
+
*/
|
|
514
|
+
track: z.boolean().optional(),
|
|
506
515
|
});
|
|
507
516
|
export type CaptionsConfig = z.infer<typeof CaptionsConfigSchema>;
|
|
508
517
|
|
|
@@ -1236,7 +1245,8 @@ export type Timeline = z.infer<typeof TimelineSchema>;
|
|
|
1236
1245
|
|
|
1237
1246
|
export const ComposeWarningSchema = z.object({
|
|
1238
1247
|
/** Stable code: scene-freeze | overrun-trim | blank-tail | stale-captions |
|
|
1239
|
-
* focus-dropped | cursor-missing | scene-no-narration
|
|
1248
|
+
* focus-dropped | cursor-missing | scene-no-narration | caption-track-empty |
|
|
1249
|
+
* caption-track-failed */
|
|
1240
1250
|
code: z.string(),
|
|
1241
1251
|
scene: z.string().optional(),
|
|
1242
1252
|
message: z.string(),
|
|
@@ -1287,7 +1297,16 @@ export const ComposeReportSchema = z.object({
|
|
|
1287
1297
|
scenes: z.array(ComposeSceneReportSchema),
|
|
1288
1298
|
zoom: z.object({ focusTotal: z.number(), focusDropped: z.number() }),
|
|
1289
1299
|
cursorPoints: z.number(),
|
|
1290
|
-
captions: z.object({
|
|
1300
|
+
captions: z.object({
|
|
1301
|
+
/** Cues burned into the picture (0 with `style: "none"`). */
|
|
1302
|
+
cues: z.number(),
|
|
1303
|
+
passes: z.number(),
|
|
1304
|
+
/** Sidecars timed to the final video (intro card included), when there are cues. */
|
|
1305
|
+
srt: z.string().optional(),
|
|
1306
|
+
vtt: z.string().optional(),
|
|
1307
|
+
/** ISO 639-2 language of the embedded subtitle track (`captions.track`). */
|
|
1308
|
+
track: z.string().optional(),
|
|
1309
|
+
}),
|
|
1291
1310
|
/** Attention overlays drawn (highlight/spotlight/callout), key chips, click rings, redact spans. */
|
|
1292
1311
|
attention: z
|
|
1293
1312
|
.object({
|
package/src/walkthrough.ts
CHANGED
|
@@ -12,6 +12,8 @@ import { resolve, isAbsolute, basename } from "node:path";
|
|
|
12
12
|
import { Project } from "./project.js";
|
|
13
13
|
import { ComposeReportSchema, type Storyboard } from "./types.js";
|
|
14
14
|
import { runFfmpeg } from "./ffmpeg.js";
|
|
15
|
+
import { toSrt, toVtt } from "./captions.js";
|
|
16
|
+
import { finalVideoCues } from "./caption-track.js";
|
|
15
17
|
import { ensureDir, exists, readJson, writeJson, log, step } from "./util.js";
|
|
16
18
|
|
|
17
19
|
export interface WalkthroughOptions {
|
|
@@ -112,17 +114,16 @@ export async function exportWalkthrough(
|
|
|
112
114
|
log(`scene ${r.id}: ${mmss(startMs)} → ${image}`);
|
|
113
115
|
}
|
|
114
116
|
|
|
115
|
-
// Captions travel with the walkthrough (players and LMSs want the files)
|
|
117
|
+
// Captions travel with the walkthrough (players and LMSs want the files),
|
|
118
|
+
// timed to the video they accompany — generated/captions.* start at the
|
|
119
|
+
// first narrated word, i.e. early by the intro card (issue #89).
|
|
116
120
|
const captions: { srt?: string; vtt?: string } = {};
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
await fs.copyFile(src, dst);
|
|
124
|
-
captions[key] = dst;
|
|
125
|
-
}
|
|
121
|
+
const cues = await finalVideoCues(project, introMs);
|
|
122
|
+
if (cues) {
|
|
123
|
+
captions.srt = resolve(dir, basename(project.captionsSrtPath));
|
|
124
|
+
captions.vtt = resolve(dir, basename(project.captionsVttPath));
|
|
125
|
+
await fs.writeFile(captions.srt, toSrt(cues));
|
|
126
|
+
await fs.writeFile(captions.vtt, toVtt(cues));
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
const videoRel = `../${basename(video)}`;
|