@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/src/mcp/jobs.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createWriteStream, promises as fs } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Project } from "../project.js";
|
|
5
|
+
import { withLogSinks, teeStageLog, CanceledError, type LogSink } from "../util.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* In-memory job manager for the MCP server. Pipeline operations (probe,
|
|
9
|
+
* record, render, voice, captions, compose, gif) run for seconds to minutes —
|
|
10
|
+
* far past typical MCP client tool timeouts — so each runs as a background
|
|
11
|
+
* job whose id is returned immediately; agents poll job_status.
|
|
12
|
+
*
|
|
13
|
+
* One long job runs at a time (reject-when-busy, no queue): record-class jobs
|
|
14
|
+
* are physically exclusive anyway (one Chrome profile lock, recordings-dir
|
|
15
|
+
* cleanup at start), and a single slot also serializes same-demo stage
|
|
16
|
+
* collisions predictably. Job history lives in server memory only — after a
|
|
17
|
+
* server restart, artifacts remain under <demoDir>/logs/.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type JobKind =
|
|
21
|
+
| "probe"
|
|
22
|
+
| "record"
|
|
23
|
+
| "render"
|
|
24
|
+
| "voice"
|
|
25
|
+
| "captions"
|
|
26
|
+
| "compose"
|
|
27
|
+
| "gif"
|
|
28
|
+
| "stills";
|
|
29
|
+
|
|
30
|
+
export type JobStatus = "running" | "succeeded" | "failed" | "canceled";
|
|
31
|
+
|
|
32
|
+
export interface JobError {
|
|
33
|
+
message: string;
|
|
34
|
+
stage: string;
|
|
35
|
+
failureArtifacts: string[];
|
|
36
|
+
logFile: string;
|
|
37
|
+
/** Nudge toward filing an engine-defect report — the moment of failure is
|
|
38
|
+
* maximum context. Points at the MCP `feedback` tool / `aidemo feedback`.
|
|
39
|
+
* Present on genuine failures only (not a deliberate job_cancel). */
|
|
40
|
+
feedbackHint?: string;
|
|
41
|
+
salvaged?: { scenes: number; timeline: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Points a failed job's caller at the feedback tool while context is fresh. */
|
|
45
|
+
export const FEEDBACK_HINT =
|
|
46
|
+
"if this looks like an engine defect, file it now while the context is " +
|
|
47
|
+
"fresh: the MCP `feedback` tool (title, body, dir) or `aidemo feedback " +
|
|
48
|
+
"<dir>` on the CLI.";
|
|
49
|
+
|
|
50
|
+
export interface Job {
|
|
51
|
+
jobId: string;
|
|
52
|
+
kind: JobKind;
|
|
53
|
+
demoDir: string;
|
|
54
|
+
status: JobStatus;
|
|
55
|
+
/** Current pipeline stage (render advances voice→record→captions→compose). */
|
|
56
|
+
stage: string;
|
|
57
|
+
scenesTotal: number | null;
|
|
58
|
+
scenesDone: number;
|
|
59
|
+
currentScene: string | null;
|
|
60
|
+
startedAt: Date;
|
|
61
|
+
endedAt: Date | null;
|
|
62
|
+
logFile: string;
|
|
63
|
+
/** Last RING_MAX ANSI-stripped log lines. */
|
|
64
|
+
ring: string[];
|
|
65
|
+
result: unknown;
|
|
66
|
+
error: JobError | null;
|
|
67
|
+
controller: AbortController;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class JobBusyError extends Error {
|
|
71
|
+
constructor(
|
|
72
|
+
public readonly runningJobId: string,
|
|
73
|
+
message: string
|
|
74
|
+
) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = "JobBusyError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const RING_MAX = 200;
|
|
81
|
+
|
|
82
|
+
export class JobManager {
|
|
83
|
+
private jobs = new Map<string, Job>();
|
|
84
|
+
private activeId: string | null = null;
|
|
85
|
+
|
|
86
|
+
/** Best-effort per-line forwarding (e.g. MCP logging notifications). */
|
|
87
|
+
onLine?: (job: Job, cleanChunk: string) => void;
|
|
88
|
+
|
|
89
|
+
/** Start a job or throw JobBusyError if one is already running. */
|
|
90
|
+
startJob(
|
|
91
|
+
kind: JobKind,
|
|
92
|
+
project: Project,
|
|
93
|
+
runner: (job: Job) => Promise<unknown>
|
|
94
|
+
): Job {
|
|
95
|
+
const running = this.running();
|
|
96
|
+
if (running) {
|
|
97
|
+
throw new JobBusyError(
|
|
98
|
+
running.jobId,
|
|
99
|
+
`job ${running.jobId} (${running.kind} ${running.demoDir}) is still ` +
|
|
100
|
+
`running — poll job_status or job_cancel it`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const job: Job = {
|
|
104
|
+
jobId: randomUUID(),
|
|
105
|
+
kind,
|
|
106
|
+
demoDir: project.dir,
|
|
107
|
+
status: "running",
|
|
108
|
+
stage: kind,
|
|
109
|
+
scenesTotal: null,
|
|
110
|
+
scenesDone: 0,
|
|
111
|
+
currentScene: null,
|
|
112
|
+
startedAt: new Date(),
|
|
113
|
+
endedAt: null,
|
|
114
|
+
logFile: join(project.dir, "logs", `${kind}.log`),
|
|
115
|
+
ring: [],
|
|
116
|
+
result: null,
|
|
117
|
+
error: null,
|
|
118
|
+
controller: new AbortController(),
|
|
119
|
+
};
|
|
120
|
+
this.jobs.set(job.jobId, job);
|
|
121
|
+
this.activeId = job.jobId;
|
|
122
|
+
void this.execute(job, runner);
|
|
123
|
+
return job;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get(jobId: string): Job | undefined {
|
|
127
|
+
return this.jobs.get(jobId);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
list(): Job[] {
|
|
131
|
+
return [...this.jobs.values()];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
running(): Job | null {
|
|
135
|
+
const active = this.activeId ? this.jobs.get(this.activeId) : undefined;
|
|
136
|
+
return active && active.status === "running" ? active : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Request cancellation; the job settles asynchronously (best-effort). */
|
|
140
|
+
cancel(jobId: string): Job | undefined {
|
|
141
|
+
const job = this.jobs.get(jobId);
|
|
142
|
+
if (job && job.status === "running") job.controller.abort();
|
|
143
|
+
return job;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Abort the active job (server shutdown). Returns true if one was running. */
|
|
147
|
+
abortActive(): boolean {
|
|
148
|
+
const running = this.running();
|
|
149
|
+
if (!running) return false;
|
|
150
|
+
running.controller.abort();
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Wait until no job is running, up to timeoutMs (shutdown grace). */
|
|
155
|
+
async waitForIdle(timeoutMs: number): Promise<void> {
|
|
156
|
+
const deadline = Date.now() + timeoutMs;
|
|
157
|
+
while (this.running() && Date.now() < deadline) {
|
|
158
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Run one pipeline stage of a job: names the stage, tees its log output to
|
|
164
|
+
* <demoDir>/logs/<stage>.log (same names the CLI uses, so `aidemo feedback`
|
|
165
|
+
* log-tailing and humans keep working) and into the job's ring buffer.
|
|
166
|
+
*/
|
|
167
|
+
async runStage<T>(job: Job, stage: string, fn: () => Promise<T>): Promise<T> {
|
|
168
|
+
job.stage = stage;
|
|
169
|
+
job.logFile = join(job.demoDir, "logs", `${stage}.log`);
|
|
170
|
+
const stream = createWriteStream(job.logFile, { flags: "w" });
|
|
171
|
+
stream.on("error", () => {});
|
|
172
|
+
const sinks: LogSink[] = [
|
|
173
|
+
(s) => void stream.write(s),
|
|
174
|
+
(s) => this.pushRing(job, s),
|
|
175
|
+
];
|
|
176
|
+
try {
|
|
177
|
+
return await withLogSinks(sinks, fn);
|
|
178
|
+
} finally {
|
|
179
|
+
await new Promise<void>((res) => stream.end(res));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Like runStage, but for a stage nested inside an ALREADY-RUNNING outer
|
|
185
|
+
* stage — the `render` job's voice/record/captions/compose/gif/stills
|
|
186
|
+
* sub-stages, wrapped in an outer `runStage(job, "render", …)`. Reuses the
|
|
187
|
+
* outer stage's ring sink (withLogSinks composes additively, so no
|
|
188
|
+
* duplicate log-tail lines) while still refreshing THIS sub-stage's own
|
|
189
|
+
* stable logs/<stage>.log and updating job.stage/logFile/scene-progress so
|
|
190
|
+
* job_status reflects whichever sub-stage is currently running. Without
|
|
191
|
+
* this, `render` only ever wrote logs/render.log — logs/voice.log,
|
|
192
|
+
* logs/captions.log, logs/compose.log stayed stale from whatever standalone
|
|
193
|
+
* run last touched them (the DX papercut behind aidemo#17).
|
|
194
|
+
*/
|
|
195
|
+
async runSubStage<T>(job: Job, stage: string, fn: () => Promise<T>): Promise<T> {
|
|
196
|
+
job.stage = stage;
|
|
197
|
+
job.logFile = join(job.demoDir, "logs", `${stage}.log`);
|
|
198
|
+
job.scenesTotal = null;
|
|
199
|
+
job.scenesDone = 0;
|
|
200
|
+
job.currentScene = null;
|
|
201
|
+
return teeStageLog(job.logFile, fn);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Public status shape returned by the job_status/job_list tools. */
|
|
205
|
+
statusOf(job: Job, tailLines = 40): Record<string, unknown> {
|
|
206
|
+
return {
|
|
207
|
+
jobId: job.jobId,
|
|
208
|
+
kind: job.kind,
|
|
209
|
+
demoDir: job.demoDir,
|
|
210
|
+
status: job.status,
|
|
211
|
+
stage: job.stage,
|
|
212
|
+
scenesTotal: job.scenesTotal,
|
|
213
|
+
scenesDone: job.scenesDone,
|
|
214
|
+
currentScene: job.currentScene,
|
|
215
|
+
startedAt: job.startedAt.toISOString(),
|
|
216
|
+
endedAt: job.endedAt ? job.endedAt.toISOString() : null,
|
|
217
|
+
logFile: job.logFile,
|
|
218
|
+
logTail: job.ring.slice(-Math.max(0, tailLines)),
|
|
219
|
+
...(job.result != null ? { result: job.result } : {}),
|
|
220
|
+
...(job.error ? { error: job.error } : {}),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async execute(job: Job, runner: (job: Job) => Promise<unknown>) {
|
|
225
|
+
try {
|
|
226
|
+
job.result = await runner(job);
|
|
227
|
+
job.status = "succeeded";
|
|
228
|
+
} catch (err) {
|
|
229
|
+
job.status =
|
|
230
|
+
err instanceof CanceledError || job.controller.signal.aborted
|
|
231
|
+
? "canceled"
|
|
232
|
+
: "failed";
|
|
233
|
+
job.error = await this.describeFailure(job, err as Error);
|
|
234
|
+
} finally {
|
|
235
|
+
job.endedAt = new Date();
|
|
236
|
+
if (this.activeId === job.jobId) this.activeId = null;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async describeFailure(job: Job, err: Error): Promise<JobError> {
|
|
241
|
+
// Failure artifacts this run produced (player writes fail-<scene>-<n>.*);
|
|
242
|
+
// the mtime filter excludes stale artifacts from earlier runs.
|
|
243
|
+
const logsDir = join(job.demoDir, "logs");
|
|
244
|
+
const failureArtifacts: string[] = [];
|
|
245
|
+
try {
|
|
246
|
+
for (const f of await fs.readdir(logsDir)) {
|
|
247
|
+
if (!/^fail-.*\.(png|json)$/.test(f)) continue;
|
|
248
|
+
const p = join(logsDir, f);
|
|
249
|
+
const st = await fs.stat(p).catch(() => null);
|
|
250
|
+
if (st && st.mtimeMs >= job.startedAt.getTime()) failureArtifacts.push(p);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// no logs dir — nothing to collect
|
|
254
|
+
}
|
|
255
|
+
let salvaged: JobError["salvaged"];
|
|
256
|
+
if (job.kind === "probe" || job.kind === "record" || job.kind === "render") {
|
|
257
|
+
const timeline = join(job.demoDir, "generated", "timeline.json");
|
|
258
|
+
const st = await fs.stat(timeline).catch(() => null);
|
|
259
|
+
if (st && st.mtimeMs >= job.startedAt.getTime() && job.scenesDone > 0) {
|
|
260
|
+
salvaged = { scenes: job.scenesDone, timeline };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
message: err.message,
|
|
265
|
+
stage: job.stage,
|
|
266
|
+
failureArtifacts,
|
|
267
|
+
logFile: job.logFile,
|
|
268
|
+
// A deliberate job_cancel isn't an engine defect — only nudge on a
|
|
269
|
+
// genuine failure.
|
|
270
|
+
...(job.status === "failed" ? { feedbackHint: FEEDBACK_HINT } : {}),
|
|
271
|
+
...(salvaged ? { salvaged } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private pushRing(job: Job, chunk: string) {
|
|
276
|
+
for (const raw of chunk.split("\n")) {
|
|
277
|
+
const line = raw.trimEnd();
|
|
278
|
+
if (line.trim()) job.ring.push(line);
|
|
279
|
+
}
|
|
280
|
+
if (job.ring.length > RING_MAX) {
|
|
281
|
+
job.ring.splice(0, job.ring.length - RING_MAX);
|
|
282
|
+
}
|
|
283
|
+
this.onLine?.(job, chunk);
|
|
284
|
+
}
|
|
285
|
+
}
|