@tandryukha/aidemo 0.8.0 → 0.14.1
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 +5 -2
- package/README.md +16 -4
- package/docs/AUTHORING.md +609 -45
- package/package.json +3 -2
- package/src/anchors.ts +128 -0
- package/src/attention.ts +365 -0
- package/src/caption-render.ts +17 -8
- package/src/captions.ts +70 -6
- package/src/capture.ts +113 -0
- package/src/cards.ts +15 -6
- package/src/cli.ts +580 -22
- package/src/compose.ts +1006 -93
- package/src/cursor-overlay.ts +7 -113
- package/src/cursor.ts +9 -3
- package/src/distribute.ts +37 -4
- package/src/expr.ts +130 -0
- package/src/ffmpeg.ts +156 -0
- package/src/frame.ts +205 -0
- package/src/frames.ts +159 -0
- package/src/golden.ts +27 -0
- package/src/guide.ts +102 -0
- package/src/i18n.ts +7 -4
- package/src/import-trace.ts +629 -0
- package/src/inspect.ts +307 -0
- package/src/lint.ts +601 -0
- package/src/login.ts +143 -0
- package/src/mcp/jobs.ts +4 -1
- package/src/mcp/server.ts +403 -26
- package/src/player.ts +975 -32
- package/src/profile.ts +128 -0
- package/src/project.ts +12 -2
- package/src/recorder.ts +252 -15
- package/src/setup.ts +248 -0
- package/src/starter.ts +244 -0
- package/src/stills.ts +15 -4
- package/src/types.ts +643 -5
- package/src/variants.ts +1 -1
- package/src/voice-local.ts +7 -1
- package/src/voice.ts +60 -1
- package/src/walkthrough.ts +250 -0
- package/src/zoom.ts +85 -39
package/src/frame.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Produced-look frame: padding, background, rounded corners, drop shadow and
|
|
3
|
+
* an optional browser/mac chrome bar around the content — the "Screen Studio"
|
|
4
|
+
* look, done with the same portable trick as cards and captions. Headless
|
|
5
|
+
* Chrome rasterizes ONE canvas-sized PNG whose video area is a transparent
|
|
6
|
+
* rounded hole (CSS mask); compose pads the content onto a solid canvas and
|
|
7
|
+
* overlays that PNG. Two baseline filters (`pad`, `overlay`), no drawbox.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chromium } from "playwright";
|
|
11
|
+
import type { Brand, Frame } from "./types.js";
|
|
12
|
+
|
|
13
|
+
export interface FrameLayout {
|
|
14
|
+
/** Canvas size in output px (even). */
|
|
15
|
+
canvasW: number;
|
|
16
|
+
canvasH: number;
|
|
17
|
+
/** Where the content lands on the canvas, output px. */
|
|
18
|
+
offsetX: number;
|
|
19
|
+
offsetY: number;
|
|
20
|
+
/** Solid fallback color used under the PNG (matches the background's base). */
|
|
21
|
+
padColor: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CHROME_H = 40;
|
|
25
|
+
/** Default `safeTop` strip: an iPhone status bar's worth of bezel. */
|
|
26
|
+
const SAFE_TOP = 46;
|
|
27
|
+
/** Device bezel thickness (logical px) for phone chrome. */
|
|
28
|
+
const BEZEL = 14;
|
|
29
|
+
const DEFAULT_BG =
|
|
30
|
+
"linear-gradient(135deg, #1b2440 0%, #0e1322 55%, #0a0d18 100%)";
|
|
31
|
+
|
|
32
|
+
export function isDeviceChrome(chrome: Frame["chrome"]): boolean {
|
|
33
|
+
return chrome === "iphone" || chrome === "android";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Status-bar strip reserved above the video for device chrome, logical px.
|
|
38
|
+
* Without it the island / punch-hole is drawn OVER the recording's top pixels
|
|
39
|
+
* (fine for a page that starts with whitespace, wrong for an app that paints a
|
|
40
|
+
* header there); with it the camera cut-out sits on bezel instead.
|
|
41
|
+
*/
|
|
42
|
+
function safeTopPx(frame: Frame): number {
|
|
43
|
+
if (!isDeviceChrome(frame.chrome) || frame.safeTop == null || frame.safeTop === false) return 0;
|
|
44
|
+
return frame.safeTop === true ? SAFE_TOP : Math.round(frame.safeTop);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function esc(text: string): string {
|
|
48
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Geometry only (no browser) — compose needs it before rendering. */
|
|
52
|
+
export function frameLayout(
|
|
53
|
+
frame: Frame,
|
|
54
|
+
videoW: number,
|
|
55
|
+
videoH: number,
|
|
56
|
+
pxScale: number,
|
|
57
|
+
aspect?: number
|
|
58
|
+
): FrameLayout {
|
|
59
|
+
const device = isDeviceChrome(frame.chrome);
|
|
60
|
+
// A bezel needs room; the default padding already has it.
|
|
61
|
+
const pad = Math.max(frame.padding ?? 48, device ? BEZEL + 8 : 0);
|
|
62
|
+
const chromeH = frame.chrome && frame.chrome !== "none" && !device ? CHROME_H : 0;
|
|
63
|
+
// The bar and the device status strip are mutually exclusive (one is browser
|
|
64
|
+
// chrome, the other device chrome) — both push the video down the same way.
|
|
65
|
+
const topInset = chromeH + safeTopPx(frame);
|
|
66
|
+
const even = (n: number) => Math.round(n) & ~1;
|
|
67
|
+
// Logical canvas; when the output has a target aspect (preset or explicit
|
|
68
|
+
// size) grow the padding on one axis so the canvas already matches it and
|
|
69
|
+
// the final resize is a pure scale — no letterbox bars around a frame.
|
|
70
|
+
let W = videoW + 2 * pad;
|
|
71
|
+
let H = videoH + 2 * pad + topInset;
|
|
72
|
+
if (aspect && aspect > 0) {
|
|
73
|
+
if (W / H < aspect) W = H * aspect;
|
|
74
|
+
else H = W / aspect;
|
|
75
|
+
}
|
|
76
|
+
const padX = (W - videoW) / 2;
|
|
77
|
+
const padY = (H - videoH - topInset) / 2;
|
|
78
|
+
return {
|
|
79
|
+
canvasW: even(W * pxScale),
|
|
80
|
+
canvasH: even(H * pxScale),
|
|
81
|
+
offsetX: Math.round(padX * pxScale),
|
|
82
|
+
offsetY: Math.round((padY + topInset) * pxScale),
|
|
83
|
+
padColor: "#0e1322",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Rasterize the frame PNG: canvas background + shadow + chrome, with a
|
|
89
|
+
* rounded transparent hole exactly where the video sits.
|
|
90
|
+
*/
|
|
91
|
+
export async function renderFramePng(
|
|
92
|
+
frame: Frame,
|
|
93
|
+
brand: Brand | undefined,
|
|
94
|
+
outPath: string,
|
|
95
|
+
videoW: number,
|
|
96
|
+
videoH: number,
|
|
97
|
+
pxScale: number,
|
|
98
|
+
aspect?: number
|
|
99
|
+
): Promise<FrameLayout> {
|
|
100
|
+
const layout = frameLayout(frame, videoW, videoH, pxScale, aspect);
|
|
101
|
+
const chrome = frame.chrome ?? "none";
|
|
102
|
+
const device = isDeviceChrome(chrome);
|
|
103
|
+
const radius = frame.radius ?? (device ? 40 : 14);
|
|
104
|
+
const chromeH = chrome !== "none" && !device ? CHROME_H : 0;
|
|
105
|
+
const strip = safeTopPx(frame);
|
|
106
|
+
const topInset = chromeH + strip;
|
|
107
|
+
const accent = brand?.accent ?? "#6c8cff";
|
|
108
|
+
const background =
|
|
109
|
+
frame.background ??
|
|
110
|
+
(brand?.accent
|
|
111
|
+
? `linear-gradient(135deg, ${accent} 0%, #0e1322 70%, #0a0d18 100%)`
|
|
112
|
+
: DEFAULT_BG);
|
|
113
|
+
const font = brand?.font ?? '-apple-system,"Helvetica Neue",Arial,sans-serif';
|
|
114
|
+
const W = Math.round(layout.canvasW / pxScale);
|
|
115
|
+
const H = Math.round(layout.canvasH / pxScale);
|
|
116
|
+
// Hole = the video rect; the chrome bar sits directly above it, sharing the
|
|
117
|
+
// window's rounded top corners (the video keeps the rounded bottom ones).
|
|
118
|
+
const winX = Math.round(layout.offsetX / pxScale);
|
|
119
|
+
const winY = Math.round(layout.offsetY / pxScale) - topInset;
|
|
120
|
+
const winW = videoW;
|
|
121
|
+
const winH = videoH + topInset;
|
|
122
|
+
const holeY = winY + topInset;
|
|
123
|
+
// A browser bar owns the window's top corners, so the video below it is
|
|
124
|
+
// square there. A device status strip is screen, not chrome: keep the corners
|
|
125
|
+
// rounded — the sliver outside them is bezel-black, exactly like the real
|
|
126
|
+
// rounded display corner.
|
|
127
|
+
const rTop = chromeH ? 0 : radius;
|
|
128
|
+
// SVG mask (alpha): opaque everywhere except the rounded video rect.
|
|
129
|
+
const hole =
|
|
130
|
+
`M${winX} ${holeY + rTop} ` +
|
|
131
|
+
`a${rTop} ${rTop} 0 0 1 ${rTop} -${rTop} h${winW - 2 * rTop} ` +
|
|
132
|
+
`a${rTop} ${rTop} 0 0 1 ${rTop} ${rTop} v${videoH - rTop - radius} ` +
|
|
133
|
+
`a${radius} ${radius} 0 0 1 -${radius} ${radius} h-${winW - 2 * radius} ` +
|
|
134
|
+
`a${radius} ${radius} 0 0 1 -${radius} -${radius} z`;
|
|
135
|
+
const mask =
|
|
136
|
+
`<svg xmlns='http://www.w3.org/2000/svg' width='${W}' height='${H}'>` +
|
|
137
|
+
`<path fill='white' fill-rule='evenodd' d='M0 0h${W}v${H}H0z ${hole}'/></svg>`;
|
|
138
|
+
const maskUrl = `url("data:image/svg+xml;utf8,${encodeURIComponent(mask)}")`;
|
|
139
|
+
const shadow =
|
|
140
|
+
frame.shadow === false ? "none" : "0 24px 60px rgba(0,0,0,.55), 0 2px 8px rgba(0,0,0,.35)";
|
|
141
|
+
let chromeHtml = "";
|
|
142
|
+
let deviceHtml = "";
|
|
143
|
+
let deviceCss = "";
|
|
144
|
+
if (device) {
|
|
145
|
+
// Bezel behind the hole (masked with the canvas) + an unmasked camera
|
|
146
|
+
// cut-out drawn over the video: a dynamic island or a punch-hole.
|
|
147
|
+
const bx = winX - BEZEL;
|
|
148
|
+
const by = winY - BEZEL;
|
|
149
|
+
deviceCss =
|
|
150
|
+
`.bezel{position:absolute;left:${bx}px;top:${by}px;width:${winW + 2 * BEZEL}px;height:${winH + 2 * BEZEL}px;` +
|
|
151
|
+
`border-radius:${radius + BEZEL}px;background:#0b0b0d;border:2px solid #2b2d33;box-shadow:${shadow};}` +
|
|
152
|
+
`.cam{position:absolute;background:#000;}` +
|
|
153
|
+
// With a safe strip the cut-out is centered IN the strip (over bezel);
|
|
154
|
+
// without one it keeps its old place, drawn over the video's top pixels.
|
|
155
|
+
(chrome === "iphone"
|
|
156
|
+
? `.island{left:${winX + winW / 2 - 55}px;top:${strip ? winY + Math.max(2, (strip - 32) / 2) : winY + 12}px;width:110px;height:32px;border-radius:16px;}`
|
|
157
|
+
: `.hole{left:${winX + winW / 2 - 7}px;top:${strip ? winY + Math.max(2, (strip - 14) / 2) : winY + 14}px;width:14px;height:14px;border-radius:50%;}`);
|
|
158
|
+
deviceHtml = `<div class="cam ${chrome === "iphone" ? "island" : "hole"}"></div>`;
|
|
159
|
+
}
|
|
160
|
+
if (chromeH) {
|
|
161
|
+
const lights =
|
|
162
|
+
chrome === "mac"
|
|
163
|
+
? `<span class="l" style="background:#ff5f57"></span><span class="l" style="background:#febc2e"></span><span class="l" style="background:#28c840"></span>`
|
|
164
|
+
: `<span class="l" style="background:#3a4157"></span><span class="l" style="background:#3a4157"></span><span class="l" style="background:#3a4157"></span>`;
|
|
165
|
+
const label = frame.url ?? frame.title ?? "";
|
|
166
|
+
chromeHtml =
|
|
167
|
+
`<div class="bar"><div class="lights">${lights}</div>` +
|
|
168
|
+
(label ? `<div class="pill">${esc(label)}</div>` : "") +
|
|
169
|
+
`</div>`;
|
|
170
|
+
}
|
|
171
|
+
const html = `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
172
|
+
html,body{margin:0;padding:0;background:transparent;width:${W}px;height:${H}px;overflow:hidden;}
|
|
173
|
+
*{box-sizing:border-box;font-family:${font};-webkit-font-smoothing:antialiased;}
|
|
174
|
+
.canvas{position:absolute;inset:0;background:${background};
|
|
175
|
+
-webkit-mask-image:${maskUrl};mask-image:${maskUrl};}
|
|
176
|
+
.win{position:absolute;left:${winX}px;top:${winY}px;width:${winW}px;height:${winH}px;
|
|
177
|
+
border-radius:${radius}px;box-shadow:${device ? "none" : shadow};background:${device ? "#0b0b0d" : "#0b0e18"};}
|
|
178
|
+
${deviceCss}
|
|
179
|
+
.bar{position:absolute;left:${winX}px;top:${winY}px;width:${winW}px;height:${chromeH}px;
|
|
180
|
+
border-radius:${radius}px ${radius}px 0 0;background:#1a1f2e;
|
|
181
|
+
display:flex;align-items:center;padding:0 14px;gap:14px;
|
|
182
|
+
border-bottom:1px solid rgba(255,255,255,.06);}
|
|
183
|
+
.lights{display:flex;gap:8px;}
|
|
184
|
+
.l{width:12px;height:12px;border-radius:50%;display:inline-block;}
|
|
185
|
+
.pill{flex:1;max-width:60%;margin:0 auto;height:24px;line-height:24px;border-radius:7px;
|
|
186
|
+
background:rgba(255,255,255,.07);color:rgba(230,234,245,.75);font-size:13px;
|
|
187
|
+
text-align:center;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;padding:0 12px;}
|
|
188
|
+
</style></head><body>
|
|
189
|
+
<div class="canvas">${device ? '<div class="bezel"></div>' : ""}<div class="win"></div>${chromeHtml}</div>${deviceHtml}
|
|
190
|
+
</body></html>`;
|
|
191
|
+
|
|
192
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
193
|
+
const context = await browser.newContext({
|
|
194
|
+
viewport: { width: W, height: H },
|
|
195
|
+
deviceScaleFactor: pxScale,
|
|
196
|
+
});
|
|
197
|
+
const page = await context.newPage();
|
|
198
|
+
try {
|
|
199
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
200
|
+
await page.screenshot({ path: outPath, omitBackground: true });
|
|
201
|
+
} finally {
|
|
202
|
+
await browser.close();
|
|
203
|
+
}
|
|
204
|
+
return layout;
|
|
205
|
+
}
|
package/src/frames.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aidemo frames <dir>` — dump evenly spaced PNG frames from a take or the
|
|
3
|
+
* final video for agent-side review (issue #44). Reviewing a render used to
|
|
4
|
+
* mean hand-typed `ffmpeg -ss` per frame; this writes `output/frames/<source>-
|
|
5
|
+
* <mm-ss>.png` at a fixed cadence, sized for a vision model, and prints the
|
|
6
|
+
* paths.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { promises as fs } from "node:fs";
|
|
10
|
+
import { join, resolve, relative } from "node:path";
|
|
11
|
+
import { runFfmpeg, probeDurationMs } from "./ffmpeg.js";
|
|
12
|
+
import type { Project } from "./project.js";
|
|
13
|
+
import { ensureDir, exists, readJson } from "./util.js";
|
|
14
|
+
import { TimelineSchema } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export interface FramesOptions {
|
|
17
|
+
/** Seconds between frames. Default 3. */
|
|
18
|
+
everySec?: number;
|
|
19
|
+
/**
|
|
20
|
+
* "final" (output/final-demo.mp4, default), "raw" (the latest raw file), or
|
|
21
|
+
* "take" (the whole recorded take — follows the timeline across the raw
|
|
22
|
+
* files a resumed take is spliced from, which `raw` cannot see).
|
|
23
|
+
*/
|
|
24
|
+
source?: "final" | "raw" | "take";
|
|
25
|
+
/** Output width in px (aspect kept). Default 640 — legible, cheap to look at. */
|
|
26
|
+
width?: number;
|
|
27
|
+
/** Output directory. Default <demo>/output/frames. */
|
|
28
|
+
outDir?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FramesResult {
|
|
32
|
+
source: string;
|
|
33
|
+
durationMs: number;
|
|
34
|
+
everySec: number;
|
|
35
|
+
files: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `mm-ss` (plus tenths when the cadence isn't whole seconds) — unique + sortable. */
|
|
39
|
+
function stamp(sec: number, whole: boolean): string {
|
|
40
|
+
const m = Math.floor(sec / 60);
|
|
41
|
+
const s = sec - m * 60;
|
|
42
|
+
const ss = whole
|
|
43
|
+
? String(Math.round(s)).padStart(2, "0")
|
|
44
|
+
: s.toFixed(1).padStart(4, "0");
|
|
45
|
+
return `${String(m).padStart(2, "0")}-${ss}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function extractFrames(
|
|
49
|
+
project: Project,
|
|
50
|
+
opts: FramesOptions = {}
|
|
51
|
+
): Promise<FramesResult> {
|
|
52
|
+
const everySec = Math.max(0.2, opts.everySec ?? 3);
|
|
53
|
+
const width = Math.max(64, Math.round(opts.width ?? 640));
|
|
54
|
+
const kind = opts.source ?? "final";
|
|
55
|
+
if (kind === "take") return takeFrames(project, everySec, width, opts.outDir);
|
|
56
|
+
const source = kind === "raw" ? await project.resolveRawVideo() : project.outputPath;
|
|
57
|
+
if (!(await exists(source))) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
kind === "raw"
|
|
60
|
+
? `no take at ${source} — run 'aidemo record <dir>' (or probe) first`
|
|
61
|
+
: `no video at ${source} — run 'aidemo render <dir>' (or compose) first`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
const outDir = opts.outDir ?? project.p("output", "frames");
|
|
65
|
+
await ensureDir(outDir);
|
|
66
|
+
// Clear this source's previous dump so stale frames don't mix in.
|
|
67
|
+
for (const f of await fs.readdir(outDir).catch(() => [] as string[])) {
|
|
68
|
+
if (f.startsWith(`${kind}-`) && f.endsWith(".png")) {
|
|
69
|
+
await fs.rm(join(outDir, f), { force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const durationMs = await probeDurationMs(source);
|
|
73
|
+
const pattern = join(outDir, `${kind}-%04d.png`);
|
|
74
|
+
await runFfmpeg([
|
|
75
|
+
"-i",
|
|
76
|
+
source,
|
|
77
|
+
"-vf",
|
|
78
|
+
`fps=1/${everySec},scale=${width}:-2`,
|
|
79
|
+
"-vsync",
|
|
80
|
+
"vfr",
|
|
81
|
+
pattern,
|
|
82
|
+
]);
|
|
83
|
+
// ffmpeg numbers frames 0001…; rename to the timestamp each one represents.
|
|
84
|
+
const produced = (await fs.readdir(outDir))
|
|
85
|
+
.filter((f) => new RegExp(`^${kind}-\\d{4}\\.png$`).test(f))
|
|
86
|
+
.sort();
|
|
87
|
+
const files: string[] = [];
|
|
88
|
+
const whole = Number.isInteger(everySec);
|
|
89
|
+
for (const [i, f] of produced.entries()) {
|
|
90
|
+
const at = i * everySec;
|
|
91
|
+
const name = `${kind}-${stamp(at, whole)}.png`;
|
|
92
|
+
await fs.rename(join(outDir, f), join(outDir, name));
|
|
93
|
+
files.push(join(outDir, name));
|
|
94
|
+
}
|
|
95
|
+
return { source, durationMs, everySec, files };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `--source take`: sample the RECORDED take end to end. A resumed take
|
|
100
|
+
* (`record --from-scene`) is spliced from more than one raw file, so `raw`
|
|
101
|
+
* shows only the newest tail; this walks the timeline scene by scene, seeking
|
|
102
|
+
* into whichever file each scene names, and stamps the frames with the scene
|
|
103
|
+
* they came from.
|
|
104
|
+
*/
|
|
105
|
+
async function takeFrames(
|
|
106
|
+
project: Project,
|
|
107
|
+
everySec: number,
|
|
108
|
+
width: number,
|
|
109
|
+
outDirOpt?: string
|
|
110
|
+
): Promise<FramesResult> {
|
|
111
|
+
const timeline = TimelineSchema.parse(await readJson(project.timelinePath));
|
|
112
|
+
const rawVideo = await project.resolveRawVideo();
|
|
113
|
+
const outDir = outDirOpt ?? project.p("output", "frames");
|
|
114
|
+
await ensureDir(outDir);
|
|
115
|
+
for (const f of await fs.readdir(outDir).catch(() => [] as string[])) {
|
|
116
|
+
if (f.startsWith("take-") && f.endsWith(".png")) await fs.rm(join(outDir, f), { force: true });
|
|
117
|
+
}
|
|
118
|
+
const files: string[] = [];
|
|
119
|
+
const whole = Number.isInteger(everySec);
|
|
120
|
+
const sources = new Set<string>();
|
|
121
|
+
let elapsedMs = 0; // content time across the whole take
|
|
122
|
+
let carryMs = 0; // ms owed to the next scene so the cadence never resets
|
|
123
|
+
for (const scene of timeline.scenes) {
|
|
124
|
+
const file = scene.source ? resolve(project.dir, scene.source) : rawVideo;
|
|
125
|
+
if (!(await exists(file))) {
|
|
126
|
+
throw new Error(`take frames: scene "${scene.id}" points at ${file}, which is gone`);
|
|
127
|
+
}
|
|
128
|
+
sources.add(file);
|
|
129
|
+
const leadInMs = scene.leadInMs ?? timeline.leadInMs;
|
|
130
|
+
const sceneMs = Math.max(0, scene.endMs - scene.startMs);
|
|
131
|
+
for (let at = carryMs; at < sceneMs; at += everySec * 1000) {
|
|
132
|
+
const seek = (scene.startMs + leadInMs + at) / 1000;
|
|
133
|
+
const name = `take-${stamp((elapsedMs + at) / 1000, whole)}-${scene.id}.png`;
|
|
134
|
+
await runFfmpeg([
|
|
135
|
+
"-ss",
|
|
136
|
+
seek.toFixed(3),
|
|
137
|
+
"-i",
|
|
138
|
+
file,
|
|
139
|
+
"-frames:v",
|
|
140
|
+
"1",
|
|
141
|
+
"-vf",
|
|
142
|
+
`scale=${width}:-2`,
|
|
143
|
+
join(outDir, name),
|
|
144
|
+
]);
|
|
145
|
+
files.push(join(outDir, name));
|
|
146
|
+
}
|
|
147
|
+
// Where the next sample falls inside the following scene.
|
|
148
|
+
const stepMs = everySec * 1000;
|
|
149
|
+
const shot = Math.max(0, Math.ceil((sceneMs - carryMs) / stepMs));
|
|
150
|
+
carryMs = Math.max(0, carryMs + shot * stepMs - sceneMs);
|
|
151
|
+
elapsedMs += sceneMs;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
source: [...sources].map((f) => relative(project.dir, f)).join(", "),
|
|
155
|
+
durationMs: elapsedMs,
|
|
156
|
+
everySec,
|
|
157
|
+
files,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/golden.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type Storyboard,
|
|
8
8
|
} from "./types.js";
|
|
9
9
|
import { writeJson, exists } from "./util.js";
|
|
10
|
+
import { join } from "node:path";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Golden probe/timeline diffing — a demo doubles as a regression test (vhs's
|
|
@@ -107,3 +108,29 @@ function valuesEqual(a: unknown, b: unknown): boolean {
|
|
|
107
108
|
function fmt(v: unknown): string {
|
|
108
109
|
return v === undefined ? "(absent)" : JSON.stringify(v);
|
|
109
110
|
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* For a golden diff, the drift-suggestion files the probe just wrote for the
|
|
114
|
+
* actions whose `ok`/`found` flipped (`logs/drift-<scene>-<n>.json`, see
|
|
115
|
+
* player diagnostics) — so the fix is one edit away. Only existing files.
|
|
116
|
+
*/
|
|
117
|
+
export async function driftFilesForDiff(
|
|
118
|
+
project: Project,
|
|
119
|
+
golden: ProbeGolden,
|
|
120
|
+
diffs: string[]
|
|
121
|
+
): Promise<Array<{ scene: string; action: number; file: string }>> {
|
|
122
|
+
const out: Array<{ scene: string; action: number; file: string }> = [];
|
|
123
|
+
const seen = new Set<string>();
|
|
124
|
+
for (const d of diffs) {
|
|
125
|
+
const m = /^\$\.scenes\[(\d+)\]\.actions\[(\d+)\]\.(ok|found):/.exec(d);
|
|
126
|
+
if (!m) continue;
|
|
127
|
+
const scene = golden.scenes[Number(m[1])];
|
|
128
|
+
if (!scene) continue;
|
|
129
|
+
const action = Number(m[2]);
|
|
130
|
+
const file = join(project.dir, "logs", `drift-${scene.id}-${action + 1}.json`);
|
|
131
|
+
if (seen.has(file) || !(await exists(file))) continue;
|
|
132
|
+
seen.add(file);
|
|
133
|
+
out.push({ scene: scene.id, action: action + 1, file });
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
package/src/guide.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Topic-scoped slices of docs/AUTHORING.md. The full guide is ~1000 lines;
|
|
3
|
+
* agents usually need one slice (schema, attention, ChatGPT-app facts…).
|
|
4
|
+
* Slicing is by H2 heading so the doc stays the single source of truth —
|
|
5
|
+
* topics are just named sets of H2 prefixes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { ENGINE_ROOT } from "./config.js";
|
|
11
|
+
|
|
12
|
+
export const GUIDE_TOPICS = {
|
|
13
|
+
core: [
|
|
14
|
+
"Interfaces",
|
|
15
|
+
"Pipeline",
|
|
16
|
+
"Steps",
|
|
17
|
+
"Demo-director principles",
|
|
18
|
+
"Storyboard schema",
|
|
19
|
+
"Action vocabulary",
|
|
20
|
+
"Verify before declaring done",
|
|
21
|
+
],
|
|
22
|
+
schema: ["Storyboard schema", "Action vocabulary", "Narration-anchored beats"],
|
|
23
|
+
polish: [
|
|
24
|
+
"Demo-director principles",
|
|
25
|
+
"Narration-anchored beats",
|
|
26
|
+
"Transitions, output sizing",
|
|
27
|
+
"Attention",
|
|
28
|
+
"Motion blur & cursor",
|
|
29
|
+
"Produced look",
|
|
30
|
+
],
|
|
31
|
+
attention: ["Attention", "Motion blur & cursor"],
|
|
32
|
+
"chatgpt-apps": ["ChatGPT Apps SDK recording"],
|
|
33
|
+
stills: ["Stills"],
|
|
34
|
+
variants: ["Parameterized storyboards", "Multi-language renders"],
|
|
35
|
+
captions: ["Captions:", "Multi-language renders"],
|
|
36
|
+
debug: ["Verify before declaring done", "Debugging", "Demo as regression test"],
|
|
37
|
+
ci: ["Demo as regression test", "Interfaces"],
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
export type GuideTopic = keyof typeof GUIDE_TOPICS;
|
|
41
|
+
export const GUIDE_TOPIC_NAMES = Object.keys(GUIDE_TOPICS) as GuideTopic[];
|
|
42
|
+
|
|
43
|
+
export const guidePath = (): string => resolve(ENGINE_ROOT, "docs", "AUTHORING.md");
|
|
44
|
+
|
|
45
|
+
export async function readGuide(): Promise<string> {
|
|
46
|
+
return readFile(guidePath(), "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface Section {
|
|
50
|
+
heading: string;
|
|
51
|
+
body: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Split the guide into its H2 sections (the preamble before the first H2 is heading ""). */
|
|
55
|
+
export function splitGuide(md: string): Section[] {
|
|
56
|
+
const out: Section[] = [];
|
|
57
|
+
let heading = "";
|
|
58
|
+
let buf: string[] = [];
|
|
59
|
+
let inFence = false;
|
|
60
|
+
for (const line of md.split("\n")) {
|
|
61
|
+
if (/^```/.test(line)) inFence = !inFence;
|
|
62
|
+
if (!inFence && /^## /.test(line)) {
|
|
63
|
+
out.push({ heading, body: buf.join("\n") });
|
|
64
|
+
heading = line.slice(3).trim();
|
|
65
|
+
buf = [line];
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
buf.push(line);
|
|
69
|
+
}
|
|
70
|
+
out.push({ heading, body: buf.join("\n") });
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Headings in the doc, in order (for `guide --list`, MCP hints). */
|
|
75
|
+
export function guideHeadings(md: string): string[] {
|
|
76
|
+
return splitGuide(md)
|
|
77
|
+
.map((s) => s.heading)
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Slice the guide to a topic (or to any H2 whose heading starts with the
|
|
83
|
+
* given text, case-insensitive). Returns the sections in document order
|
|
84
|
+
* plus a one-line pointer to the other topics; unknown topic → null.
|
|
85
|
+
*/
|
|
86
|
+
export function sliceGuide(md: string, topic: string): string | null {
|
|
87
|
+
const sections = splitGuide(md);
|
|
88
|
+
const key = topic.trim().toLowerCase();
|
|
89
|
+
const prefixes: readonly string[] =
|
|
90
|
+
(GUIDE_TOPICS as Record<string, readonly string[]>)[key] ?? [topic.trim()];
|
|
91
|
+
const picked = sections.filter(
|
|
92
|
+
(s) => s.heading && prefixes.some((p) => s.heading.toLowerCase().startsWith(p.toLowerCase()))
|
|
93
|
+
);
|
|
94
|
+
if (!picked.length) return null;
|
|
95
|
+
const others = GUIDE_TOPIC_NAMES.filter((t) => t !== key).join(", ");
|
|
96
|
+
return (
|
|
97
|
+
`<!-- aidemo authoring guide · topic "${topic}" (${picked.length} section${picked.length === 1 ? "" : "s"}). ` +
|
|
98
|
+
`Other topics: ${others}; omit topic for the full guide. -->\n\n` +
|
|
99
|
+
picked.map((s) => s.body.trimEnd()).join("\n\n") +
|
|
100
|
+
"\n"
|
|
101
|
+
);
|
|
102
|
+
}
|
package/src/i18n.ts
CHANGED
|
@@ -62,10 +62,13 @@ export function localizeStoryboard(sb: Storyboard, lang: string): Storyboard {
|
|
|
62
62
|
...sb,
|
|
63
63
|
intro: localizeCard(sb.intro, lang),
|
|
64
64
|
outro: localizeCard(sb.outro, lang),
|
|
65
|
-
scenes: sb.scenes.map((scene) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
scenes: sb.scenes.map((scene) => {
|
|
66
|
+
const translated = scene.narrations?.[lang];
|
|
67
|
+
if (translated == null) return scene;
|
|
68
|
+
const anchors = scene.narrationAnchors?.[lang];
|
|
69
|
+
const { anchors: _base, ...rest } = scene;
|
|
70
|
+
return { ...rest, narration: translated, ...(anchors ? { anchors } : {}) };
|
|
71
|
+
}),
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
|