@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
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { ENGINE_ROOT, engineVersion, captionsAutoOffline } from "../config.js";
|
|
8
|
+
import { Project, parseStoryboard } from "../project.js";
|
|
9
|
+
import { StoryboardSchema } from "../types.js";
|
|
10
|
+
import { generateVoice } from "../voice.js";
|
|
11
|
+
import { record, type RecordOptions } from "../recorder.js";
|
|
12
|
+
import {
|
|
13
|
+
buildProbeGolden,
|
|
14
|
+
diffGolden,
|
|
15
|
+
readProbeGolden,
|
|
16
|
+
writeProbeGolden,
|
|
17
|
+
} from "../golden.js";
|
|
18
|
+
import type { ProbeGoldenScene } from "../types.js";
|
|
19
|
+
import { generateCaptions, generateCaptionsOffline } from "../captions.js";
|
|
20
|
+
import { compose } from "../compose.js";
|
|
21
|
+
import { exportGif } from "../gif.js";
|
|
22
|
+
import { buildEmbed } from "../embed.js";
|
|
23
|
+
import { extractStills, storyboardHasStills } from "../stills.js";
|
|
24
|
+
import { localizeStoryboard } from "../i18n.js";
|
|
25
|
+
import { scaffoldDemo, doctorReport, buildFeedback, fileFeedback } from "../distribute.js";
|
|
26
|
+
import { readJson, log, CanceledError, type SceneProgress } from "../util.js";
|
|
27
|
+
import { JobManager, JobBusyError, type Job, type JobKind } from "./jobs.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `aidemo mcp` — the agent interface. A stdio MCP server wrapping the same
|
|
31
|
+
* stage functions the CLI uses (stdout is reserved for JSON-RPC; all engine
|
|
32
|
+
* logging already goes to stderr). No network listener — stdio only.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const INSTRUCTIONS = `aidemo renders narrated, captioned product-demo videos from a storyboard.json.
|
|
36
|
+
Start by calling get_authoring_guide and follow it. Validate storyboard edits
|
|
37
|
+
with validate_storyboard (cheap) before rendering. Pipeline operations (probe,
|
|
38
|
+
record, render, voice, captions, compose, gif) return a jobId immediately —
|
|
39
|
+
poll job_status for progress, per-scene counters, results, and failure
|
|
40
|
+
artifacts; only one job runs at a time. Always pass absolute demo directories.
|
|
41
|
+
Hit an engine bug, surprise, or workaround this session? Call the feedback
|
|
42
|
+
tool (title + body — environment/log context is auto-attached) before you
|
|
43
|
+
finish; skip if nothing came up. A failed job_status result also carries a
|
|
44
|
+
feedbackHint pointing back here.`;
|
|
45
|
+
|
|
46
|
+
type ToolResult = {
|
|
47
|
+
content: Array<{ type: "text"; text: string }>;
|
|
48
|
+
structuredContent?: Record<string, unknown>;
|
|
49
|
+
isError?: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function jsonResult(obj: Record<string, unknown>): ToolResult {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
55
|
+
structuredContent: obj,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Error result: output-schema validation is skipped for isError results. */
|
|
60
|
+
function errorResult(obj: Record<string, unknown> & { message: string }): ToolResult {
|
|
61
|
+
return {
|
|
62
|
+
isError: true,
|
|
63
|
+
content: [{ type: "text", text: JSON.stringify(obj, null, 2) }],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const JOB_START_SHAPE = {
|
|
68
|
+
jobId: z.string(),
|
|
69
|
+
kind: z.string(),
|
|
70
|
+
demoDir: z.string().describe("resolved absolute demo directory"),
|
|
71
|
+
status: z.string(),
|
|
72
|
+
logFile: z.string(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const JOB_STATUS_SHAPE = {
|
|
76
|
+
jobId: z.string(),
|
|
77
|
+
kind: z.string(),
|
|
78
|
+
demoDir: z.string(),
|
|
79
|
+
status: z.enum(["running", "succeeded", "failed", "canceled"]),
|
|
80
|
+
stage: z.string(),
|
|
81
|
+
scenesTotal: z.number().nullable(),
|
|
82
|
+
scenesDone: z.number(),
|
|
83
|
+
currentScene: z.string().nullable(),
|
|
84
|
+
startedAt: z.string(),
|
|
85
|
+
endedAt: z.string().nullable(),
|
|
86
|
+
logFile: z.string(),
|
|
87
|
+
logTail: z.array(z.string()),
|
|
88
|
+
result: z.record(z.unknown()).optional(),
|
|
89
|
+
error: z
|
|
90
|
+
.object({
|
|
91
|
+
message: z.string(),
|
|
92
|
+
stage: z.string(),
|
|
93
|
+
failureArtifacts: z.array(z.string()),
|
|
94
|
+
logFile: z.string(),
|
|
95
|
+
feedbackHint: z
|
|
96
|
+
.string()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe(
|
|
99
|
+
"present on a genuine failure (not a cancel) — nudges toward the " +
|
|
100
|
+
"feedback tool while context is fresh"
|
|
101
|
+
),
|
|
102
|
+
salvaged: z
|
|
103
|
+
.object({ scenes: z.number(), timeline: z.string() })
|
|
104
|
+
.optional(),
|
|
105
|
+
})
|
|
106
|
+
.optional(),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const DIR_INPUT = z
|
|
110
|
+
.string()
|
|
111
|
+
.describe("demo project directory — pass an absolute path");
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Template params for a parameterized storyboard: name → value. Each key must
|
|
115
|
+
* be declared in the storyboard's `params` block (typo-guarded); `{{name}}`
|
|
116
|
+
* placeholders resolve to these values (else the declared default) across all
|
|
117
|
+
* stages of the run.
|
|
118
|
+
*/
|
|
119
|
+
const PARAMS_INPUT = z
|
|
120
|
+
.record(z.string(), z.string())
|
|
121
|
+
.optional()
|
|
122
|
+
.describe(
|
|
123
|
+
"storyboard template params (name → value); each must be declared in the " +
|
|
124
|
+
"storyboard's params block. Substitutes {{name}} across all stages."
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const LANG_INPUT = z
|
|
128
|
+
.string()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe(
|
|
131
|
+
"render a language variant from scene.narrations[<code>] over the shared " +
|
|
132
|
+
"take (artifacts namespaced: audio/<code>/, captions.<code>.*, " +
|
|
133
|
+
"output/final-demo.<code>.mp4). Omit for the default single-language render."
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/** A language-scoped Project for a variant render (same dir, per-lang artifacts). */
|
|
137
|
+
function langProject(project: Project, lang?: string): Project {
|
|
138
|
+
return lang ? new Project(project.dir, lang) : project;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const RECORD_INPUT_SHAPE = {
|
|
142
|
+
dir: DIR_INPUT,
|
|
143
|
+
headless: z
|
|
144
|
+
.boolean()
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("run Chrome headless (default false — headed, like the CLI)"),
|
|
147
|
+
profile: z
|
|
148
|
+
.string()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe("Chrome user-data dir (logged-in profile)"),
|
|
151
|
+
capture: z.enum(["playwright", "native", "obs"]).optional(),
|
|
152
|
+
params: PARAMS_INPUT,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
function throwIfAborted(signal: AbortSignal): void {
|
|
156
|
+
if (signal.aborted) throw new CanceledError("canceled between stages");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
160
|
+
const jobs = new JobManager();
|
|
161
|
+
const server = new McpServer(
|
|
162
|
+
{ name: "aidemo", version: engineVersion() },
|
|
163
|
+
{ instructions: INSTRUCTIONS, capabilities: { logging: {} } }
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Best-effort log forwarding (throttled); job_status polling is the source
|
|
167
|
+
// of truth — many clients don't surface logging notifications.
|
|
168
|
+
const lastSent = new Map<string, number>();
|
|
169
|
+
jobs.onLine = (job, chunk) => {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
if (now - (lastSent.get(job.jobId) ?? 0) < 1000) return;
|
|
172
|
+
lastSent.set(job.jobId, now);
|
|
173
|
+
void server
|
|
174
|
+
.sendLoggingMessage({
|
|
175
|
+
level: "info",
|
|
176
|
+
logger: "aidemo",
|
|
177
|
+
data: { jobId: job.jobId, stage: job.stage, line: chunk.trim() },
|
|
178
|
+
})
|
|
179
|
+
.catch(() => {});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const guidePath = resolve(ENGINE_ROOT, "docs", "AUTHORING.md");
|
|
183
|
+
|
|
184
|
+
server.registerTool(
|
|
185
|
+
"get_authoring_guide",
|
|
186
|
+
{
|
|
187
|
+
title: "Get the aidemo authoring guide",
|
|
188
|
+
description:
|
|
189
|
+
"The canonical guide to authoring demos: storyboard schema, action " +
|
|
190
|
+
"vocabulary, demo-director principles, ChatGPT-app recording facts. " +
|
|
191
|
+
"Call this FIRST before authoring or editing a storyboard.",
|
|
192
|
+
inputSchema: {},
|
|
193
|
+
outputSchema: { guide: z.string(), engineVersion: z.string() },
|
|
194
|
+
annotations: { readOnlyHint: true },
|
|
195
|
+
},
|
|
196
|
+
async () =>
|
|
197
|
+
jsonResult({
|
|
198
|
+
guide: await readFile(guidePath, "utf8"),
|
|
199
|
+
engineVersion: engineVersion(),
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
server.registerTool(
|
|
204
|
+
"get_storyboard_schema",
|
|
205
|
+
{
|
|
206
|
+
title: "Get the storyboard JSON Schema",
|
|
207
|
+
description:
|
|
208
|
+
"JSON Schema for storyboard.json, generated from the engine's own " +
|
|
209
|
+
"zod schema — the exact contract the engine validates against.",
|
|
210
|
+
inputSchema: {},
|
|
211
|
+
outputSchema: { schema: z.record(z.unknown()) },
|
|
212
|
+
annotations: { readOnlyHint: true },
|
|
213
|
+
},
|
|
214
|
+
async () =>
|
|
215
|
+
jsonResult({
|
|
216
|
+
schema: zodToJsonSchema(StoryboardSchema, { $refStrategy: "none" }),
|
|
217
|
+
})
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
server.registerTool(
|
|
221
|
+
"validate_storyboard",
|
|
222
|
+
{
|
|
223
|
+
title: "Validate a storyboard",
|
|
224
|
+
description:
|
|
225
|
+
"Validate a storyboard against the engine schema without running " +
|
|
226
|
+
"anything. Pass exactly one of: dir (uses generated/storyboard.json), " +
|
|
227
|
+
"path (a storyboard file), or json (storyboard JSON as a string). " +
|
|
228
|
+
"relaxed makes narration optional (probe semantics).",
|
|
229
|
+
inputSchema: {
|
|
230
|
+
dir: z.string().optional().describe("demo dir → generated/storyboard.json"),
|
|
231
|
+
path: z.string().optional().describe("path to a storyboard .json file"),
|
|
232
|
+
json: z.string().optional().describe("storyboard JSON as a string"),
|
|
233
|
+
relaxed: z.boolean().optional(),
|
|
234
|
+
params: PARAMS_INPUT,
|
|
235
|
+
},
|
|
236
|
+
outputSchema: {
|
|
237
|
+
valid: z.boolean(),
|
|
238
|
+
storyboardPath: z.string().optional(),
|
|
239
|
+
title: z.string().optional(),
|
|
240
|
+
sceneCount: z.number().optional(),
|
|
241
|
+
issues: z.array(
|
|
242
|
+
z.object({ path: z.string(), message: z.string(), code: z.string() })
|
|
243
|
+
),
|
|
244
|
+
warnings: z.array(z.string()),
|
|
245
|
+
},
|
|
246
|
+
annotations: { readOnlyHint: true },
|
|
247
|
+
},
|
|
248
|
+
async (args) => {
|
|
249
|
+
const sources = [args.dir, args.path, args.json].filter(
|
|
250
|
+
(s) => s != null
|
|
251
|
+
).length;
|
|
252
|
+
if (sources !== 1) {
|
|
253
|
+
return errorResult({
|
|
254
|
+
message: "pass exactly one of: dir, path, json",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
let raw: unknown;
|
|
258
|
+
let storyboardPath: string | undefined;
|
|
259
|
+
try {
|
|
260
|
+
if (args.json != null) {
|
|
261
|
+
raw = JSON.parse(args.json);
|
|
262
|
+
} else {
|
|
263
|
+
storyboardPath =
|
|
264
|
+
args.path != null
|
|
265
|
+
? resolve(args.path)
|
|
266
|
+
: new Project(args.dir as string).storyboardPath;
|
|
267
|
+
raw = await readJson<unknown>(storyboardPath);
|
|
268
|
+
}
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return jsonResult({
|
|
271
|
+
valid: false,
|
|
272
|
+
...(storyboardPath ? { storyboardPath } : {}),
|
|
273
|
+
issues: [
|
|
274
|
+
{
|
|
275
|
+
path: "",
|
|
276
|
+
message: (err as Error).message,
|
|
277
|
+
code: "unreadable",
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
warnings: [],
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
const parsed = parseStoryboard(raw, {
|
|
284
|
+
relaxed: args.relaxed,
|
|
285
|
+
params: args.params,
|
|
286
|
+
strict: args.params != null,
|
|
287
|
+
});
|
|
288
|
+
if (!parsed.ok) {
|
|
289
|
+
return jsonResult({
|
|
290
|
+
valid: false,
|
|
291
|
+
...(storyboardPath ? { storyboardPath } : {}),
|
|
292
|
+
issues: parsed.issues,
|
|
293
|
+
warnings: [],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return jsonResult({
|
|
297
|
+
valid: true,
|
|
298
|
+
...(storyboardPath ? { storyboardPath } : {}),
|
|
299
|
+
title: parsed.storyboard.title,
|
|
300
|
+
sceneCount: parsed.storyboard.scenes.length,
|
|
301
|
+
issues: [],
|
|
302
|
+
warnings: parsed.warnings,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
server.registerTool(
|
|
308
|
+
"init_demo",
|
|
309
|
+
{
|
|
310
|
+
title: "Scaffold a new demo",
|
|
311
|
+
description:
|
|
312
|
+
"Create demos/<name>/ with a starter brief + storyboard. dir is the " +
|
|
313
|
+
"repo to scaffold into (absolute path recommended; default: server cwd).",
|
|
314
|
+
inputSchema: {
|
|
315
|
+
name: z.string().describe("demo name (creates demos/<name>/)"),
|
|
316
|
+
dir: z.string().optional(),
|
|
317
|
+
force: z.boolean().optional(),
|
|
318
|
+
},
|
|
319
|
+
outputSchema: {
|
|
320
|
+
demoDir: z.string(),
|
|
321
|
+
storyboardPath: z.string(),
|
|
322
|
+
briefPath: z.string(),
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
async (args) => {
|
|
326
|
+
const demoDir = await scaffoldDemo(
|
|
327
|
+
args.dir ? resolve(args.dir) : process.cwd(),
|
|
328
|
+
args.name,
|
|
329
|
+
{ force: args.force }
|
|
330
|
+
);
|
|
331
|
+
const project = new Project(demoDir);
|
|
332
|
+
return jsonResult({
|
|
333
|
+
demoDir,
|
|
334
|
+
storyboardPath: project.storyboardPath,
|
|
335
|
+
briefPath: project.p("input", "brief.md"),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
server.registerTool(
|
|
341
|
+
"doctor",
|
|
342
|
+
{
|
|
343
|
+
title: "Environment preflight",
|
|
344
|
+
description:
|
|
345
|
+
"Check prereqs: node, ffmpeg, Chrome, TTS/STT endpoint (flags " +
|
|
346
|
+
"LLM-only servers like Ollama), API key, playwright, installed skill.",
|
|
347
|
+
inputSchema: {
|
|
348
|
+
dir: z.string().optional().describe("repo to check for an installed skill"),
|
|
349
|
+
},
|
|
350
|
+
outputSchema: {
|
|
351
|
+
engineVersion: z.string(),
|
|
352
|
+
node: z.string(),
|
|
353
|
+
ffmpeg: z.string().nullable(),
|
|
354
|
+
chrome: z.string().nullable(),
|
|
355
|
+
gh: z.string().nullable(),
|
|
356
|
+
endpoint: z.object({
|
|
357
|
+
url: z.string(),
|
|
358
|
+
custom: z.boolean(),
|
|
359
|
+
warning: z.string().optional(),
|
|
360
|
+
}),
|
|
361
|
+
apiKey: z.enum(["set", "not-required", "missing"]),
|
|
362
|
+
tts: z.object({
|
|
363
|
+
provider: z.string(),
|
|
364
|
+
elevenLabsKey: z.enum(["set", "missing"]).optional(),
|
|
365
|
+
}),
|
|
366
|
+
playwright: z.boolean(),
|
|
367
|
+
skill: z.object({
|
|
368
|
+
target: z.string(),
|
|
369
|
+
installed: z
|
|
370
|
+
.object({
|
|
371
|
+
version: z.string(),
|
|
372
|
+
stableAvailable: z.string().nullable(),
|
|
373
|
+
})
|
|
374
|
+
.nullable(),
|
|
375
|
+
}),
|
|
376
|
+
ok: z.boolean(),
|
|
377
|
+
},
|
|
378
|
+
annotations: { readOnlyHint: true },
|
|
379
|
+
},
|
|
380
|
+
async (args) =>
|
|
381
|
+
jsonResult(
|
|
382
|
+
(await doctorReport(args.dir)) as unknown as Record<string, unknown>
|
|
383
|
+
)
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
server.registerTool(
|
|
387
|
+
"embed",
|
|
388
|
+
{
|
|
389
|
+
title: "Always-fresh embed snippets",
|
|
390
|
+
description:
|
|
391
|
+
"Ready-to-paste embed snippets (markdown GIF, markdown still, HTML " +
|
|
392
|
+
"<video>) for a demo, using stable raw.githubusercontent URLs on the " +
|
|
393
|
+
"'demo-media' branch. Pure string generation — no network, no render. " +
|
|
394
|
+
"Owner/repo come from the repo's origin remote; the demo name from the " +
|
|
395
|
+
"directory basename. Pair with the demo-publish workflow so CI keeps " +
|
|
396
|
+
"the URLs fresh. See docs/EMBEDS.md.",
|
|
397
|
+
inputSchema: {
|
|
398
|
+
dir: DIR_INPUT,
|
|
399
|
+
repo: z
|
|
400
|
+
.string()
|
|
401
|
+
.optional()
|
|
402
|
+
.describe("consuming repo to detect owner/repo from (default: server cwd)"),
|
|
403
|
+
still: z
|
|
404
|
+
.string()
|
|
405
|
+
.optional()
|
|
406
|
+
.describe("still-frame basename under stills/ (default: poster)"),
|
|
407
|
+
},
|
|
408
|
+
outputSchema: {
|
|
409
|
+
owner: z.string(),
|
|
410
|
+
repo: z.string(),
|
|
411
|
+
demo: z.string(),
|
|
412
|
+
branch: z.string(),
|
|
413
|
+
still: z.string(),
|
|
414
|
+
urls: z.object({
|
|
415
|
+
gif: z.string(),
|
|
416
|
+
mp4: z.string(),
|
|
417
|
+
still: z.string(),
|
|
418
|
+
pagesMp4: z.string(),
|
|
419
|
+
pagesBase: z.string(),
|
|
420
|
+
}),
|
|
421
|
+
snippets: z.object({
|
|
422
|
+
markdownGif: z.string(),
|
|
423
|
+
markdownStill: z.string(),
|
|
424
|
+
htmlVideo: z.string(),
|
|
425
|
+
}),
|
|
426
|
+
workflow: z.object({
|
|
427
|
+
path: z.string(),
|
|
428
|
+
present: z.boolean(),
|
|
429
|
+
template: z.string(),
|
|
430
|
+
}),
|
|
431
|
+
},
|
|
432
|
+
annotations: { readOnlyHint: true },
|
|
433
|
+
},
|
|
434
|
+
async (args) => {
|
|
435
|
+
try {
|
|
436
|
+
const result = await buildEmbed(args.dir, {
|
|
437
|
+
repoDir: args.repo,
|
|
438
|
+
still: args.still,
|
|
439
|
+
});
|
|
440
|
+
return jsonResult(result as unknown as Record<string, unknown>);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return errorResult({ message: (err as Error).message });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
server.registerTool(
|
|
448
|
+
"feedback",
|
|
449
|
+
{
|
|
450
|
+
title: "File engine feedback",
|
|
451
|
+
description:
|
|
452
|
+
"File a bug, surprise, or workaround you hit this session as a GitHub " +
|
|
453
|
+
"issue on the aidemo engine repo (github.com/tandryukha/aidemo) — the " +
|
|
454
|
+
"in-band equivalent of `aidemo feedback` on the CLI. Environment " +
|
|
455
|
+
"(engine version, OS, node, ffmpeg) and a recent-log tail from `dir` " +
|
|
456
|
+
"are auto-attached, same as the CLI. Uses `gh issue create` when " +
|
|
457
|
+
"available; falls back to a local docs/feedback-*.md plus a prefilled " +
|
|
458
|
+
"github.com New Issue URL when gh is missing or offline — no other " +
|
|
459
|
+
"network call. Call this whenever something felt broken, surprising, " +
|
|
460
|
+
"or needed a workaround; skip it if the session was clean. Set dryRun " +
|
|
461
|
+
"to preview the assembled title/body without filing anything.",
|
|
462
|
+
inputSchema: {
|
|
463
|
+
title: z.string().describe("short issue title"),
|
|
464
|
+
body: z
|
|
465
|
+
.string()
|
|
466
|
+
.describe(
|
|
467
|
+
"what happened / suggestion — the actual finding (broken selector, " +
|
|
468
|
+
"bad timing, confusing behavior, an idea). Environment/log context " +
|
|
469
|
+
"is appended automatically; don't duplicate it here."
|
|
470
|
+
),
|
|
471
|
+
dir: z
|
|
472
|
+
.string()
|
|
473
|
+
.optional()
|
|
474
|
+
.describe("demo dir to pull storyboard/log context from (recommended)"),
|
|
475
|
+
web: z
|
|
476
|
+
.boolean()
|
|
477
|
+
.optional()
|
|
478
|
+
.describe("prefer a prefilled browser New Issue URL over filing via gh"),
|
|
479
|
+
dryRun: z
|
|
480
|
+
.boolean()
|
|
481
|
+
.optional()
|
|
482
|
+
.describe("assemble the title/body but don't file anything — preview only"),
|
|
483
|
+
},
|
|
484
|
+
outputSchema: {
|
|
485
|
+
filed: z.boolean(),
|
|
486
|
+
title: z.string(),
|
|
487
|
+
body: z.string(),
|
|
488
|
+
url: z.string().optional(),
|
|
489
|
+
localPath: z.string().optional(),
|
|
490
|
+
message: z.string(),
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
async (args) => {
|
|
494
|
+
const demoDir = args.dir ? resolve(args.dir) : null;
|
|
495
|
+
const ctx = await buildFeedback(demoDir, { title: args.title, description: args.body });
|
|
496
|
+
const result = await fileFeedback(ctx, {
|
|
497
|
+
web: args.web,
|
|
498
|
+
dryRun: args.dryRun,
|
|
499
|
+
cwd: demoDir ?? undefined,
|
|
500
|
+
});
|
|
501
|
+
return jsonResult({ ...result, title: ctx.title, body: ctx.body });
|
|
502
|
+
}
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
server.registerTool(
|
|
506
|
+
"job_status",
|
|
507
|
+
{
|
|
508
|
+
title: "Poll a job",
|
|
509
|
+
description:
|
|
510
|
+
"Status, stage, per-scene progress (scenesTotal/scenesDone/" +
|
|
511
|
+
"currentScene — populated for probe/record/render/voice/captions/" +
|
|
512
|
+
"compose), log tail, and final result or error (with failure-artifact " +
|
|
513
|
+
"paths and, on a genuine failure, a feedbackHint pointing at the " +
|
|
514
|
+
"feedback tool) of a pipeline job.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
jobId: z.string(),
|
|
517
|
+
tailLines: z.number().optional().describe("log lines to return (default 40)"),
|
|
518
|
+
},
|
|
519
|
+
outputSchema: JOB_STATUS_SHAPE,
|
|
520
|
+
annotations: { readOnlyHint: true },
|
|
521
|
+
},
|
|
522
|
+
async (args) => {
|
|
523
|
+
const job = jobs.get(args.jobId);
|
|
524
|
+
if (!job) {
|
|
525
|
+
return errorResult({
|
|
526
|
+
message:
|
|
527
|
+
`unknown job ${args.jobId} — job history lives in server memory ` +
|
|
528
|
+
`(a restart forgets ids). Artifacts persist under <demoDir>/logs/.`,
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return jsonResult(jobs.statusOf(job, args.tailLines ?? 40));
|
|
532
|
+
}
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
server.registerTool(
|
|
536
|
+
"job_list",
|
|
537
|
+
{
|
|
538
|
+
title: "List jobs",
|
|
539
|
+
description: "All jobs this server session, newest last.",
|
|
540
|
+
inputSchema: {},
|
|
541
|
+
outputSchema: {
|
|
542
|
+
jobs: z.array(
|
|
543
|
+
z.object({
|
|
544
|
+
jobId: z.string(),
|
|
545
|
+
kind: z.string(),
|
|
546
|
+
demoDir: z.string(),
|
|
547
|
+
status: z.string(),
|
|
548
|
+
stage: z.string(),
|
|
549
|
+
startedAt: z.string(),
|
|
550
|
+
endedAt: z.string().nullable(),
|
|
551
|
+
})
|
|
552
|
+
),
|
|
553
|
+
},
|
|
554
|
+
annotations: { readOnlyHint: true },
|
|
555
|
+
},
|
|
556
|
+
async () =>
|
|
557
|
+
jsonResult({
|
|
558
|
+
jobs: jobs.list().map((j) => ({
|
|
559
|
+
jobId: j.jobId,
|
|
560
|
+
kind: j.kind,
|
|
561
|
+
demoDir: j.demoDir,
|
|
562
|
+
status: j.status,
|
|
563
|
+
stage: j.stage,
|
|
564
|
+
startedAt: j.startedAt.toISOString(),
|
|
565
|
+
endedAt: j.endedAt ? j.endedAt.toISOString() : null,
|
|
566
|
+
})),
|
|
567
|
+
})
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
server.registerTool(
|
|
571
|
+
"job_cancel",
|
|
572
|
+
{
|
|
573
|
+
title: "Cancel a job",
|
|
574
|
+
description:
|
|
575
|
+
"Best-effort cancel (checked between actions/scenes/stages). A " +
|
|
576
|
+
"mid-record cancel salvages the partial timeline + footage. The job " +
|
|
577
|
+
"settles asynchronously — poll job_status for the final state.",
|
|
578
|
+
inputSchema: { jobId: z.string() },
|
|
579
|
+
outputSchema: JOB_STATUS_SHAPE,
|
|
580
|
+
},
|
|
581
|
+
async (args) => {
|
|
582
|
+
const job = jobs.cancel(args.jobId);
|
|
583
|
+
if (!job) return errorResult({ message: `unknown job ${args.jobId}` });
|
|
584
|
+
return jsonResult(jobs.statusOf(job));
|
|
585
|
+
}
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
/** Register a pipeline tool that starts a job and returns immediately. */
|
|
589
|
+
function registerJob<Shape extends z.ZodRawShape>(
|
|
590
|
+
kind: JobKind,
|
|
591
|
+
description: string,
|
|
592
|
+
inputShape: Shape,
|
|
593
|
+
makeRunner: (
|
|
594
|
+
project: Project,
|
|
595
|
+
args: z.infer<z.ZodObject<Shape>>
|
|
596
|
+
) => (job: Job) => Promise<unknown>
|
|
597
|
+
): void {
|
|
598
|
+
server.registerTool(
|
|
599
|
+
kind,
|
|
600
|
+
{
|
|
601
|
+
description:
|
|
602
|
+
`${description} Returns a jobId immediately — poll job_status. ` +
|
|
603
|
+
`Rejects if another job is running.`,
|
|
604
|
+
inputSchema: inputShape,
|
|
605
|
+
outputSchema: JOB_START_SHAPE,
|
|
606
|
+
},
|
|
607
|
+
(async (args: z.infer<z.ZodObject<Shape>>) => {
|
|
608
|
+
const project = new Project((args as { dir: string }).dir);
|
|
609
|
+
await project.ensureDirs();
|
|
610
|
+
try {
|
|
611
|
+
const job = jobs.startJob(kind, project, makeRunner(project, args));
|
|
612
|
+
return jsonResult({
|
|
613
|
+
jobId: job.jobId,
|
|
614
|
+
kind: job.kind,
|
|
615
|
+
demoDir: job.demoDir,
|
|
616
|
+
status: job.status,
|
|
617
|
+
logFile: job.logFile,
|
|
618
|
+
});
|
|
619
|
+
} catch (err) {
|
|
620
|
+
if (err instanceof JobBusyError) {
|
|
621
|
+
return errorResult({
|
|
622
|
+
message: err.message,
|
|
623
|
+
runningJobId: err.runningJobId,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
throw err;
|
|
627
|
+
}
|
|
628
|
+
}) as never
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Record options wired to a job's progress fields + abort signal. */
|
|
633
|
+
function recordOpts(
|
|
634
|
+
args: { headless?: boolean; profile?: string; capture?: "playwright" | "native" | "obs" },
|
|
635
|
+
job: Job
|
|
636
|
+
): RecordOptions {
|
|
637
|
+
return {
|
|
638
|
+
profileDir: args.profile,
|
|
639
|
+
headed: !args.headless,
|
|
640
|
+
capture: args.capture,
|
|
641
|
+
signal: job.controller.signal,
|
|
642
|
+
onSceneStart: (sceneId, index, total) => {
|
|
643
|
+
job.currentScene = sceneId;
|
|
644
|
+
job.scenesTotal = total;
|
|
645
|
+
},
|
|
646
|
+
onSceneComplete: (_scene, index, total) => {
|
|
647
|
+
job.scenesDone = index + 1;
|
|
648
|
+
job.scenesTotal = total;
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Per-scene progress hooks wired to a job's currentScene/scenesTotal/
|
|
655
|
+
* scenesDone fields — the voice/captions/compose equivalent of recordOpts
|
|
656
|
+
* above. voice.ts, captions.ts and compose.ts each loop per scene; this is
|
|
657
|
+
* what makes that loop visible to job_status pollers instead of only the
|
|
658
|
+
* log tail (aidemo#17).
|
|
659
|
+
*/
|
|
660
|
+
function sceneProgress(job: Job): Required<SceneProgress> {
|
|
661
|
+
return {
|
|
662
|
+
onSceneStart: (sceneId, _index, total) => {
|
|
663
|
+
job.currentScene = sceneId;
|
|
664
|
+
job.scenesTotal = total;
|
|
665
|
+
},
|
|
666
|
+
onSceneComplete: (_sceneId, index, total) => {
|
|
667
|
+
job.scenesDone = index + 1;
|
|
668
|
+
job.scenesTotal = total;
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
registerJob(
|
|
674
|
+
"probe",
|
|
675
|
+
"Record-only dry run to verify selectors/timing (narration optional). " +
|
|
676
|
+
"With updateGolden, writes golden/probe.json (the regression baseline); " +
|
|
677
|
+
"with golden, deep-compares against it and returns match + field-level diffs.",
|
|
678
|
+
{
|
|
679
|
+
...RECORD_INPUT_SHAPE,
|
|
680
|
+
updateGolden: z
|
|
681
|
+
.boolean()
|
|
682
|
+
.optional()
|
|
683
|
+
.describe("write golden/probe.json from this probe (commit it as the baseline)"),
|
|
684
|
+
golden: z
|
|
685
|
+
.boolean()
|
|
686
|
+
.optional()
|
|
687
|
+
.describe("compare against golden/probe.json; returns match + diffs (CI guard)"),
|
|
688
|
+
},
|
|
689
|
+
(project, args) => async (job) =>
|
|
690
|
+
jobs.runStage(job, "probe", async () => {
|
|
691
|
+
const storyboard = await project.loadStoryboard({
|
|
692
|
+
relaxed: true,
|
|
693
|
+
params: args.params,
|
|
694
|
+
});
|
|
695
|
+
const goldenMode = !!(args.golden || args.updateGolden);
|
|
696
|
+
const probeScenes: ProbeGoldenScene[] = [];
|
|
697
|
+
const timeline = await record(project, storyboard, {
|
|
698
|
+
...recordOpts(args, job),
|
|
699
|
+
...(goldenMode ? { probe: probeScenes } : {}),
|
|
700
|
+
});
|
|
701
|
+
const base = {
|
|
702
|
+
rawVideo: await project.resolveRawVideo(),
|
|
703
|
+
timeline: project.timelinePath,
|
|
704
|
+
totalMs: timeline.totalMs,
|
|
705
|
+
scenes: timeline.scenes.length,
|
|
706
|
+
};
|
|
707
|
+
if (args.updateGolden) {
|
|
708
|
+
const golden = buildProbeGolden(storyboard, probeScenes);
|
|
709
|
+
await writeProbeGolden(project, golden);
|
|
710
|
+
return {
|
|
711
|
+
...base,
|
|
712
|
+
golden: {
|
|
713
|
+
path: project.goldenProbePath,
|
|
714
|
+
updated: true,
|
|
715
|
+
scenes: golden.scenes.length,
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (args.golden) {
|
|
720
|
+
const expected = await readProbeGolden(project);
|
|
721
|
+
if (!expected) {
|
|
722
|
+
return {
|
|
723
|
+
...base,
|
|
724
|
+
golden: {
|
|
725
|
+
path: project.goldenProbePath,
|
|
726
|
+
match: false,
|
|
727
|
+
error:
|
|
728
|
+
"no golden baseline — run probe with updateGolden:true first",
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
const diffs = diffGolden(expected, buildProbeGolden(storyboard, probeScenes));
|
|
733
|
+
return {
|
|
734
|
+
...base,
|
|
735
|
+
golden: {
|
|
736
|
+
path: project.goldenProbePath,
|
|
737
|
+
match: diffs.length === 0,
|
|
738
|
+
diffs,
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
return base;
|
|
743
|
+
})
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
registerJob(
|
|
747
|
+
"record",
|
|
748
|
+
"Drive the storyboard in Chrome and record raw video + timeline.json.",
|
|
749
|
+
RECORD_INPUT_SHAPE,
|
|
750
|
+
(project, args) => async (job) =>
|
|
751
|
+
jobs.runStage(job, "record", async () => {
|
|
752
|
+
const storyboard = await project.loadStoryboard({ params: args.params });
|
|
753
|
+
const timeline = await record(project, storyboard, recordOpts(args, job));
|
|
754
|
+
return {
|
|
755
|
+
rawVideo: await project.resolveRawVideo(),
|
|
756
|
+
timeline: project.timelinePath,
|
|
757
|
+
totalMs: timeline.totalMs,
|
|
758
|
+
scenes: timeline.scenes.length,
|
|
759
|
+
};
|
|
760
|
+
})
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
registerJob(
|
|
764
|
+
"render",
|
|
765
|
+
"Full pipeline: voice → record → captions → compose (the CLI `render`).",
|
|
766
|
+
{
|
|
767
|
+
...RECORD_INPUT_SHAPE,
|
|
768
|
+
forceVoice: z.boolean().optional(),
|
|
769
|
+
gif: z.boolean().optional().describe("also export output/final-demo.gif"),
|
|
770
|
+
lang: LANG_INPUT,
|
|
771
|
+
},
|
|
772
|
+
(project, args) => async (job) =>
|
|
773
|
+
jobs.runStage(job, "render", async () => {
|
|
774
|
+
const signal = job.controller.signal;
|
|
775
|
+
const storyboard = await project.loadStoryboard({ params: args.params });
|
|
776
|
+
// Language variant (if any): voice/captions/compose run on a lang-scoped
|
|
777
|
+
// project + localized storyboard; the take is recorded ONCE on the base.
|
|
778
|
+
const lp = langProject(project, args.lang);
|
|
779
|
+
const sb = args.lang ? localizeStoryboard(storyboard, args.lang) : storyboard;
|
|
780
|
+
// Each sub-stage below is wrapped in runSubStage: it refreshes its OWN
|
|
781
|
+
// stable logs/<stage>.log (not just logs/render.log) and updates
|
|
782
|
+
// job.stage/currentScene/scenesTotal/scenesDone — same fields a
|
|
783
|
+
// standalone voice/captions/compose job would set — so a poller sees
|
|
784
|
+
// real progress for whichever stage is currently running, and a
|
|
785
|
+
// log-watcher never sees a stale logs/compose.log from an earlier run.
|
|
786
|
+
await jobs.runSubStage(job, "voice", () =>
|
|
787
|
+
generateVoice(lp, sb, { force: args.forceVoice, signal, ...sceneProgress(job) })
|
|
788
|
+
);
|
|
789
|
+
throwIfAborted(signal);
|
|
790
|
+
await jobs.runSubStage(job, "record", () =>
|
|
791
|
+
record(project, storyboard, recordOpts(args, job))
|
|
792
|
+
);
|
|
793
|
+
throwIfAborted(signal);
|
|
794
|
+
await jobs.runSubStage(job, "captions", async () => {
|
|
795
|
+
if (captionsAutoOffline()) {
|
|
796
|
+
log("local TTS and no STT endpoint/key — deriving captions offline from the script");
|
|
797
|
+
await generateCaptionsOffline(lp, sb, sceneProgress(job));
|
|
798
|
+
} else {
|
|
799
|
+
await generateCaptions(lp, sb, sceneProgress(job));
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
throwIfAborted(signal);
|
|
803
|
+
await jobs.runSubStage(job, "compose", () => compose(lp, sb, sceneProgress(job)));
|
|
804
|
+
let gifPath: string | undefined;
|
|
805
|
+
if (args.gif) {
|
|
806
|
+
gifPath = await jobs.runSubStage(job, "gif", () => exportGif(lp));
|
|
807
|
+
}
|
|
808
|
+
// Screenshot mode: emit named stills from the clean take when the
|
|
809
|
+
// storyboard declares any `still` markers (a re-extract, not a re-take).
|
|
810
|
+
let stills: string[] | undefined;
|
|
811
|
+
if (storyboardHasStills(storyboard)) {
|
|
812
|
+
stills = await jobs.runSubStage(job, "stills", () => extractStills(project));
|
|
813
|
+
}
|
|
814
|
+
return {
|
|
815
|
+
output: lp.outputPath,
|
|
816
|
+
...(gifPath ? { gif: gifPath } : {}),
|
|
817
|
+
...(stills && stills.length ? { stills } : {}),
|
|
818
|
+
timeline: lp.timelinePath,
|
|
819
|
+
captionsSrt: lp.captionsSrtPath,
|
|
820
|
+
};
|
|
821
|
+
})
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
registerJob(
|
|
825
|
+
"voice",
|
|
826
|
+
"Generate per-scene TTS narration (skips unchanged scenes).",
|
|
827
|
+
{
|
|
828
|
+
dir: DIR_INPUT,
|
|
829
|
+
scene: z.string().optional().describe("regenerate only this scene id"),
|
|
830
|
+
force: z.boolean().optional(),
|
|
831
|
+
params: PARAMS_INPUT,
|
|
832
|
+
lang: LANG_INPUT,
|
|
833
|
+
},
|
|
834
|
+
(project, args) => async (job) =>
|
|
835
|
+
jobs.runStage(job, "voice", async () => {
|
|
836
|
+
const lp = langProject(project, args.lang);
|
|
837
|
+
const storyboard = await project.loadStoryboard({ params: args.params });
|
|
838
|
+
const sb = args.lang ? localizeStoryboard(storyboard, args.lang) : storyboard;
|
|
839
|
+
const manifest = await generateVoice(lp, sb, {
|
|
840
|
+
only: args.scene,
|
|
841
|
+
force: args.force,
|
|
842
|
+
signal: job.controller.signal,
|
|
843
|
+
...sceneProgress(job),
|
|
844
|
+
});
|
|
845
|
+
return {
|
|
846
|
+
narration: lp.narrationPath,
|
|
847
|
+
voiceManifest: lp.voiceManifestPath,
|
|
848
|
+
scenes: manifest.scenes.map((s) => ({
|
|
849
|
+
id: s.id,
|
|
850
|
+
durationMs: s.durationMs,
|
|
851
|
+
})),
|
|
852
|
+
};
|
|
853
|
+
})
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
registerJob(
|
|
857
|
+
"captions",
|
|
858
|
+
"Transcribe narration.mp3 to captions with word timing.",
|
|
859
|
+
{
|
|
860
|
+
dir: DIR_INPUT,
|
|
861
|
+
offline: z
|
|
862
|
+
.boolean()
|
|
863
|
+
.optional()
|
|
864
|
+
.describe("approximate captions from the script — no network/STT"),
|
|
865
|
+
params: PARAMS_INPUT,
|
|
866
|
+
lang: LANG_INPUT,
|
|
867
|
+
},
|
|
868
|
+
(project, args) => async (job) =>
|
|
869
|
+
jobs.runStage(job, "captions", async () => {
|
|
870
|
+
const lp = langProject(project, args.lang);
|
|
871
|
+
const storyboard = await project.loadStoryboard({ params: args.params });
|
|
872
|
+
const sb = args.lang ? localizeStoryboard(storyboard, args.lang) : storyboard;
|
|
873
|
+
if (args.offline) {
|
|
874
|
+
await generateCaptionsOffline(lp, sb, sceneProgress(job));
|
|
875
|
+
} else {
|
|
876
|
+
await generateCaptions(lp, sb, sceneProgress(job));
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
srt: lp.captionsSrtPath,
|
|
880
|
+
vtt: lp.captionsVttPath,
|
|
881
|
+
cues: lp.captionsCuesPath,
|
|
882
|
+
};
|
|
883
|
+
})
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
registerJob(
|
|
887
|
+
"compose",
|
|
888
|
+
"Trim, sync, mux and caption into output/final-demo.mp4.",
|
|
889
|
+
{
|
|
890
|
+
dir: DIR_INPUT,
|
|
891
|
+
gif: z.boolean().optional().describe("also export output/final-demo.gif"),
|
|
892
|
+
params: PARAMS_INPUT,
|
|
893
|
+
lang: LANG_INPUT,
|
|
894
|
+
},
|
|
895
|
+
(project, args) => async (job) =>
|
|
896
|
+
jobs.runStage(job, "compose", async () => {
|
|
897
|
+
const lp = langProject(project, args.lang);
|
|
898
|
+
const storyboard = await project.loadStoryboard({ params: args.params });
|
|
899
|
+
const sb = args.lang ? localizeStoryboard(storyboard, args.lang) : storyboard;
|
|
900
|
+
await compose(lp, sb, sceneProgress(job));
|
|
901
|
+
let gifPath: string | undefined;
|
|
902
|
+
if (args.gif) gifPath = await exportGif(lp);
|
|
903
|
+
return {
|
|
904
|
+
output: lp.outputPath,
|
|
905
|
+
...(gifPath ? { gif: gifPath } : {}),
|
|
906
|
+
};
|
|
907
|
+
})
|
|
908
|
+
);
|
|
909
|
+
|
|
910
|
+
registerJob(
|
|
911
|
+
"gif",
|
|
912
|
+
"Convert output/final-demo.mp4 to a README-ready GIF.",
|
|
913
|
+
{
|
|
914
|
+
dir: DIR_INPUT,
|
|
915
|
+
width: z.number().int().positive().optional(),
|
|
916
|
+
fps: z.number().int().positive().optional(),
|
|
917
|
+
out: z.string().optional(),
|
|
918
|
+
},
|
|
919
|
+
(project, args) => async (job) =>
|
|
920
|
+
jobs.runStage(job, "gif", async () => ({
|
|
921
|
+
gif: await exportGif(project, {
|
|
922
|
+
width: args.width,
|
|
923
|
+
fps: args.fps,
|
|
924
|
+
out: args.out,
|
|
925
|
+
}),
|
|
926
|
+
}))
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
registerJob(
|
|
930
|
+
"stills",
|
|
931
|
+
"Extract named stills (screenshot mode) from the recorded take into " +
|
|
932
|
+
"output/stills/ — one PNG per `still` marker, pulled from the CLEAN take " +
|
|
933
|
+
"(no captions/zoom). Needs only a recorded timeline (no key).",
|
|
934
|
+
{
|
|
935
|
+
dir: DIR_INPUT,
|
|
936
|
+
out: z
|
|
937
|
+
.string()
|
|
938
|
+
.optional()
|
|
939
|
+
.describe("output directory (default <dir>/output/stills)"),
|
|
940
|
+
},
|
|
941
|
+
(project, args) => async (job) =>
|
|
942
|
+
jobs.runStage(job, "stills", async () => ({
|
|
943
|
+
stills: await extractStills(project, { outDir: args.out }),
|
|
944
|
+
}))
|
|
945
|
+
);
|
|
946
|
+
|
|
947
|
+
server.registerResource(
|
|
948
|
+
"authoring-guide",
|
|
949
|
+
"aidemo://authoring-guide",
|
|
950
|
+
{
|
|
951
|
+
title: "aidemo authoring guide",
|
|
952
|
+
description: "Canonical agent-neutral demo-authoring guide",
|
|
953
|
+
mimeType: "text/markdown",
|
|
954
|
+
},
|
|
955
|
+
async (uri) => ({
|
|
956
|
+
contents: [
|
|
957
|
+
{
|
|
958
|
+
uri: uri.href,
|
|
959
|
+
mimeType: "text/markdown",
|
|
960
|
+
text: await readFile(guidePath, "utf8"),
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
})
|
|
964
|
+
);
|
|
965
|
+
|
|
966
|
+
server.registerResource(
|
|
967
|
+
"storyboard-schema",
|
|
968
|
+
"aidemo://storyboard-schema",
|
|
969
|
+
{
|
|
970
|
+
title: "storyboard.json JSON Schema",
|
|
971
|
+
description: "JSON Schema generated from the engine's zod schema",
|
|
972
|
+
mimeType: "application/json",
|
|
973
|
+
},
|
|
974
|
+
async (uri) => ({
|
|
975
|
+
contents: [
|
|
976
|
+
{
|
|
977
|
+
uri: uri.href,
|
|
978
|
+
mimeType: "application/json",
|
|
979
|
+
text: JSON.stringify(
|
|
980
|
+
zodToJsonSchema(StoryboardSchema, { $refStrategy: "none" }),
|
|
981
|
+
null,
|
|
982
|
+
2
|
|
983
|
+
),
|
|
984
|
+
},
|
|
985
|
+
],
|
|
986
|
+
})
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
return { server, jobs };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Run the stdio server until the client disconnects. Never resolves. */
|
|
993
|
+
export async function runMcpServer(): Promise<void> {
|
|
994
|
+
const { server, jobs } = buildMcpServer();
|
|
995
|
+
let shuttingDown = false;
|
|
996
|
+
const shutdown = () => {
|
|
997
|
+
if (shuttingDown) return;
|
|
998
|
+
shuttingDown = true;
|
|
999
|
+
// The launcher (bin/aidemo.mjs) spawnSyncs us with inherited stdio: when
|
|
1000
|
+
// a client kills only the launcher, stdin EOF is the reliable death
|
|
1001
|
+
// signal. Abort the active job (a mid-record abort rides the salvage
|
|
1002
|
+
// path) and give it a grace window so Chrome closes and the partial
|
|
1003
|
+
// timeline lands — an orphaned headed Chrome would hold the profile lock
|
|
1004
|
+
// and break the next record.
|
|
1005
|
+
void (async () => {
|
|
1006
|
+
if (jobs.abortActive()) await jobs.waitForIdle(10_000);
|
|
1007
|
+
process.exit(0);
|
|
1008
|
+
})();
|
|
1009
|
+
};
|
|
1010
|
+
process.stdin.on("end", shutdown);
|
|
1011
|
+
process.stdin.on("close", shutdown);
|
|
1012
|
+
server.server.onclose = shutdown;
|
|
1013
|
+
const transport = new StdioServerTransport();
|
|
1014
|
+
await server.connect(transport);
|
|
1015
|
+
await new Promise(() => {}); // lives until shutdown() exits the process
|
|
1016
|
+
}
|