@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/config.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { promises as fs, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Engine install root = parent of src/. When the engine is run from its own
|
|
7
|
+
* repo this is the repo root; when it's fetched via `npx github:.../aidemo`
|
|
8
|
+
* it's the temp package dir. Either way, package.json / bin / .claude live here.
|
|
9
|
+
*/
|
|
10
|
+
export const ENGINE_ROOT = resolve(new URL("..", import.meta.url).pathname);
|
|
11
|
+
/** Back-compat alias — historically the engine only ran from its own checkout. */
|
|
12
|
+
export const REPO_ROOT = ENGINE_ROOT;
|
|
13
|
+
|
|
14
|
+
/** The engine's own version, read from its bundled package.json (single source). */
|
|
15
|
+
export function engineVersion(): string {
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(readFileSync(resolve(ENGINE_ROOT, "package.json"), "utf8"));
|
|
18
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
19
|
+
} catch {
|
|
20
|
+
return "0.0.0";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Parse one KEY=VALUE .env file into a plain object. No process.env side effects here —
|
|
25
|
+
* see loadEnv() below for why the two files are read fully before anything is assigned. */
|
|
26
|
+
async function readEnvFile(envPath: string): Promise<Record<string, string>> {
|
|
27
|
+
const values: Record<string, string> = {};
|
|
28
|
+
let raw: string;
|
|
29
|
+
try {
|
|
30
|
+
raw = await fs.readFile(envPath, "utf8");
|
|
31
|
+
} catch {
|
|
32
|
+
return values; // no file — nothing to load
|
|
33
|
+
}
|
|
34
|
+
for (const line of raw.split("\n")) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
37
|
+
const eq = trimmed.indexOf("=");
|
|
38
|
+
if (eq === -1) continue;
|
|
39
|
+
const key = trimmed.slice(0, eq).trim();
|
|
40
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
41
|
+
if (
|
|
42
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
43
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
44
|
+
) {
|
|
45
|
+
val = val.slice(1, -1);
|
|
46
|
+
}
|
|
47
|
+
values[key] = val;
|
|
48
|
+
}
|
|
49
|
+
return values;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Load .env for the CLI. Reads the *consumer's* cwd/.env (so a repo that installed
|
|
54
|
+
* the skill can keep its own OpenAI key next to its demos) and the engine's own
|
|
55
|
+
* .env, then merges with cwd winning over the engine dir for any key both define.
|
|
56
|
+
*
|
|
57
|
+
* CRITICAL: a value found in EITHER .env file always OVERRIDES whatever is already
|
|
58
|
+
* in process.env — e.g. an OPENAI_API_KEY exported globally in a shell profile
|
|
59
|
+
* (~/.zshrc), possibly shared across several unrelated repos/projects. This is
|
|
60
|
+
* deliberate. The previous version of this function only assigned a var when it
|
|
61
|
+
* was not yet present in process.env at all, which is the exact "ambient wins"
|
|
62
|
+
* default that Node's `dotenv.config()` and Python's `load_dotenv()` also ship
|
|
63
|
+
* with — it LOOKS like a safe merge but actually means a repo's own .env is
|
|
64
|
+
* silently ignored whenever the key already happens to be set in the process
|
|
65
|
+
* environment, and the spend goes to whatever key that ambient value belongs to
|
|
66
|
+
* instead. See scripts/guard-openai-key.ts, which fails the build if this
|
|
67
|
+
* skip-if-already-set shape ever comes back. Ambient only wins here when
|
|
68
|
+
* NEITHER .env file defines the key at all (e.g. a deliberate one-off invocation
|
|
69
|
+
* like `OPENAI_API_KEY=sk-... aidemo record ...`). Called once at CLI startup.
|
|
70
|
+
*/
|
|
71
|
+
export async function loadEnv(): Promise<void> {
|
|
72
|
+
const cwdEnv = resolve(process.cwd(), ".env");
|
|
73
|
+
const engineEnv = resolve(ENGINE_ROOT, ".env");
|
|
74
|
+
const engineValues = await readEnvFile(engineEnv);
|
|
75
|
+
const cwdValues = cwdEnv === engineEnv ? {} : await readEnvFile(cwdEnv);
|
|
76
|
+
const merged = { ...engineValues, ...cwdValues }; // cwd wins per-key over the engine dir
|
|
77
|
+
for (const [key, val] of Object.entries(merged)) {
|
|
78
|
+
process.env[key] = val;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Custom endpoint for any OpenAI-compatible server (LocalAI, speaches,
|
|
84
|
+
* Kokoro-FastAPI, …). AIDEMO_OPENAI_BASE_URL wins over the SDK-standard
|
|
85
|
+
* OPENAI_BASE_URL; unset → api.openai.com.
|
|
86
|
+
*/
|
|
87
|
+
export const openAiBaseUrl = () =>
|
|
88
|
+
process.env.AIDEMO_OPENAI_BASE_URL || process.env.OPENAI_BASE_URL || undefined;
|
|
89
|
+
|
|
90
|
+
export function requireOpenAiKey(): string {
|
|
91
|
+
const key = process.env.OPENAI_API_KEY;
|
|
92
|
+
if (key) return key;
|
|
93
|
+
// Local OpenAI-compatible servers rarely check auth; a placeholder key keeps
|
|
94
|
+
// the SDK happy without demanding a real one.
|
|
95
|
+
if (openAiBaseUrl()) return "sk-aidemo-local";
|
|
96
|
+
throw new Error(
|
|
97
|
+
"OPENAI_API_KEY is not set. Copy .env.example to .env and add your key, " +
|
|
98
|
+
"or point OPENAI_BASE_URL at a local OpenAI-compatible server."
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const TTS_MODEL = () => process.env.AIDEMO_TTS_MODEL || "gpt-4o-mini-tts";
|
|
103
|
+
export const STT_MODEL = () => process.env.AIDEMO_STT_MODEL || "whisper-1";
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* TTS backend for `aidemo voice`. Unset → OpenAI (or whatever OPENAI_BASE_URL
|
|
107
|
+
* points at), exactly as before this knob existed. Captions/STT are unaffected —
|
|
108
|
+
* they always use the OpenAI-compatible endpoint (but see captionsAutoOffline).
|
|
109
|
+
*/
|
|
110
|
+
export const ttsProvider = (): "openai" | "elevenlabs" | "local" => {
|
|
111
|
+
const p = process.env.AIDEMO_TTS_PROVIDER || "openai";
|
|
112
|
+
if (p !== "openai" && p !== "elevenlabs" && p !== "local") {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`AIDEMO_TTS_PROVIDER="${p}" is not a known provider — use "openai" (default), "elevenlabs" or "local".`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return p;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The one model the in-process local provider runs — same weights speaches serves. */
|
|
121
|
+
export const KOKORO_MODEL_ID = "onnx-community/Kokoro-82M-v1.0-ONNX";
|
|
122
|
+
|
|
123
|
+
const KOKORO_DTYPES = ["fp32", "fp16", "q8", "q4", "q4f16"] as const;
|
|
124
|
+
export type KokoroDtype = (typeof KOKORO_DTYPES)[number];
|
|
125
|
+
/** Quantization for the local model: q8 ≈ 90 MB (default), fp32 ≈ 330 MB (best). */
|
|
126
|
+
export const kokoroDtype = (): KokoroDtype => {
|
|
127
|
+
const d = process.env.AIDEMO_TTS_MODEL_DTYPE || "q8";
|
|
128
|
+
if (!KOKORO_DTYPES.includes(d as KokoroDtype)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`AIDEMO_TTS_MODEL_DTYPE="${d}" is not a Kokoro quantization — use one of: ${KOKORO_DTYPES.join(", ")}.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return d as KokoroDtype;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Voice used when the storyboard keeps the schema default ("marin", an OpenAI
|
|
138
|
+
* name Kokoro doesn't know). Any explicitly authored voiceId is passed to the
|
|
139
|
+
* local model verbatim. Same convention as ELEVENLABS_VOICE.
|
|
140
|
+
*/
|
|
141
|
+
export const KOKORO_VOICE = () => process.env.AIDEMO_KOKORO_VOICE || "af_heart";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Where the local provider caches the downloaded model. Defaults inside the
|
|
145
|
+
* HuggingFace home (HF_HOME or ~/.cache/huggingface) so it survives
|
|
146
|
+
* node_modules reinstalls and a pre-seeded copy makes installs air-gapped.
|
|
147
|
+
*/
|
|
148
|
+
export const ttsModelCacheDir = () =>
|
|
149
|
+
process.env.AIDEMO_TTS_MODEL_CACHE ||
|
|
150
|
+
resolve(
|
|
151
|
+
process.env.HF_HOME || resolve(homedir(), ".cache", "huggingface"),
|
|
152
|
+
"transformersjs"
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `render` auto-routes captions through the offline (script-timed) derivation
|
|
157
|
+
* when the voice is generated locally and the STT call would fail anyway —
|
|
158
|
+
* local voice shouldn't demand a cloud key just for caption timing.
|
|
159
|
+
*/
|
|
160
|
+
export const captionsAutoOffline = () =>
|
|
161
|
+
ttsProvider() === "local" && !openAiBaseUrl() && !process.env.OPENAI_API_KEY;
|
|
162
|
+
|
|
163
|
+
export function requireElevenLabsKey(): string {
|
|
164
|
+
const key = process.env.ELEVENLABS_API_KEY;
|
|
165
|
+
if (key) return key;
|
|
166
|
+
throw new Error(
|
|
167
|
+
"ELEVENLABS_API_KEY is not set but AIDEMO_TTS_PROVIDER=elevenlabs. Add the key " +
|
|
168
|
+
"to .env, or unset AIDEMO_TTS_PROVIDER to use the default OpenAI TTS."
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const ELEVENLABS_MODEL = () =>
|
|
173
|
+
process.env.AIDEMO_ELEVENLABS_MODEL || "eleven_multilingual_v2";
|
|
174
|
+
/**
|
|
175
|
+
* Voice used when the storyboard keeps the schema default ("marin", an OpenAI
|
|
176
|
+
* name that means nothing to ElevenLabs). Any explicitly authored voiceId is
|
|
177
|
+
* passed to ElevenLabs verbatim instead. Default: Rachel.
|
|
178
|
+
*/
|
|
179
|
+
export const ELEVENLABS_VOICE = () =>
|
|
180
|
+
process.env.AIDEMO_ELEVENLABS_VOICE || "21m00Tcm4TlvDq8ikWAM";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* LLM-only servers (Ollama, plain llama.cpp/vLLM) speak the OpenAI chat
|
|
184
|
+
* protocol but 404 the audio routes — the only routes this engine calls.
|
|
185
|
+
* Rethrow that specific failure with what's wrong and what to run instead.
|
|
186
|
+
*/
|
|
187
|
+
export function explainAudioEndpointError(err: unknown, kind: "tts" | "stt"): never {
|
|
188
|
+
const base = openAiBaseUrl();
|
|
189
|
+
const status = (err as { status?: number } | null)?.status;
|
|
190
|
+
if (base && (status === 404 || status === 405)) {
|
|
191
|
+
const endpoint =
|
|
192
|
+
kind === "tts" ? "POST /v1/audio/speech (TTS)" : "POST /v1/audio/transcriptions (STT)";
|
|
193
|
+
throw new Error(
|
|
194
|
+
`${base} doesn't implement ${endpoint}. LLM-only servers like Ollama or plain ` +
|
|
195
|
+
`llama.cpp/vLLM have no OpenAI audio endpoints — point OPENAI_BASE_URL at a ` +
|
|
196
|
+
`speech server such as speaches instead (README → "Local models & offline")` +
|
|
197
|
+
(kind === "stt" ? `, or skip transcription: aidemo captions <dir> --offline` : "") +
|
|
198
|
+
`. Original error: ${(err as Error).message}`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Dedicated Chrome profile handed to Playwright (logged into ChatGPT). */
|
|
205
|
+
export const chromeProfileDir = () =>
|
|
206
|
+
process.env.AIDEMO_CHROME_PROFILE || resolve(REPO_ROOT, "chrome-profile");
|
|
207
|
+
|
|
208
|
+
/** Default capture path: playwright | native | obs (see src/capture.ts). */
|
|
209
|
+
export const defaultCaptureMode = () => process.env.AIDEMO_CAPTURE || "playwright";
|
|
210
|
+
/** avfoundation input name for native capture. List devices with:
|
|
211
|
+
* ffmpeg -f avfoundation -list_devices true -i "" */
|
|
212
|
+
export const captureDevice = () =>
|
|
213
|
+
process.env.AIDEMO_CAPTURE_DEVICE || "Capture screen 0";
|
|
214
|
+
export const obsUrl = () => process.env.AIDEMO_OBS_URL || "ws://127.0.0.1:4455";
|
|
215
|
+
export const obsPassword = () => process.env.AIDEMO_OBS_PASSWORD;
|
|
216
|
+
/**
|
|
217
|
+
* Escape hatch for the native/OBS capture crop sanity gate: "1" downgrades
|
|
218
|
+
* the abort-on-geometry-mismatch to a loud warning. A wrong crop can ship the
|
|
219
|
+
* whole desktop into the video — frame-review any take recorded with this set.
|
|
220
|
+
*/
|
|
221
|
+
export const nativeCropUnsafe = () =>
|
|
222
|
+
process.env.AIDEMO_NATIVE_CROP_UNSAFE === "1";
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose-time cursor overlay. The default pipeline BAKES the cursor into the
|
|
3
|
+
* recording (src/cursor.ts). When a storyboard opts into `cursor` control, the
|
|
4
|
+
* take is recorded cursor-free and the player logs the cursor path
|
|
5
|
+
* (timeline `cursorSamples`); compose then draws the cursor as an overlay along
|
|
6
|
+
* that path so hide/resize is a recompose, never a re-record.
|
|
7
|
+
*
|
|
8
|
+
* The path is replayed with the same piecewise-expression trick as the auto-zoom
|
|
9
|
+
* (src/zoom.ts): overlay the cursor PNG at x='X(t)':y='Y(t)', where X/Y are
|
|
10
|
+
* piecewise-LINEAR over the recorded samples (the samples are already eased, so
|
|
11
|
+
* linear interpolation between them preserves the glide). Portable — `overlay`
|
|
12
|
+
* is the same baseline filter the caption strip uses. Applied to the content
|
|
13
|
+
* BEFORE the zoom pass, so the cursor zooms and pans with the frame, exactly
|
|
14
|
+
* like the baked one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A cursor sample already mapped into final content time + output pixels. */
|
|
18
|
+
export interface CursorPoint {
|
|
19
|
+
/** Content-time seconds. */
|
|
20
|
+
t: number;
|
|
21
|
+
/** Output pixels (viewport CSS px * pxScale). */
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A content-time span (seconds) during which the cursor is hidden. */
|
|
27
|
+
export interface HideWindow {
|
|
28
|
+
a: number;
|
|
29
|
+
b: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-axis key budget. ffmpeg's expression evaluator allocates an AST and blows
|
|
34
|
+
* up (ENOMEM) somewhere past ~100 flat terms — measured ~80 OK / 120 fail on a
|
|
35
|
+
* stock build. X(t) and Y(t) are INDEPENDENT overlay expressions, so this budget
|
|
36
|
+
* is per axis; keeping it well under the cliff (and small) also protects weaker
|
|
37
|
+
* ffmpeg builds. The path is simplified to fit (simplifyAxis) — cheap, because
|
|
38
|
+
* the player's glides are eased S-curves that RDP collapses to a few keys each.
|
|
39
|
+
*/
|
|
40
|
+
const KEY_BUDGET = 48;
|
|
41
|
+
|
|
42
|
+
interface Key {
|
|
43
|
+
t: number;
|
|
44
|
+
v: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function num(n: number): string {
|
|
48
|
+
const s = n.toFixed(3).replace(/\.?0+$/, "");
|
|
49
|
+
return s === "" || s === "-" ? "0" : s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Linear-interpolation (vertical) error at `p` against the a→b chord. */
|
|
53
|
+
function chordError(a: Key, b: Key, p: Key): number {
|
|
54
|
+
const dt = b.t - a.t;
|
|
55
|
+
const vAt = dt < 1e-9 ? a.v : a.v + ((b.v - a.v) * (p.t - a.t)) / dt;
|
|
56
|
+
return Math.abs(p.v - vAt);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ramer–Douglas–Peucker on a value-over-time polyline, using vertical (linear-
|
|
61
|
+
* interpolation) error in the value's own units (pixels) — so `eps` is exactly
|
|
62
|
+
* "max pixels the drawn cursor may drift from the recorded path". Keeps the
|
|
63
|
+
* points where the curve actually bends; a straight or held stretch collapses to
|
|
64
|
+
* its endpoints. Iterative to avoid deep recursion on long inputs.
|
|
65
|
+
*/
|
|
66
|
+
function rdp(keys: Key[], eps: number): Key[] {
|
|
67
|
+
const n = keys.length;
|
|
68
|
+
if (n <= 2) return keys.slice();
|
|
69
|
+
const keep = new Uint8Array(n);
|
|
70
|
+
keep[0] = 1;
|
|
71
|
+
keep[n - 1] = 1;
|
|
72
|
+
const stack: Array<[number, number]> = [[0, n - 1]];
|
|
73
|
+
while (stack.length) {
|
|
74
|
+
const [lo, hi] = stack.pop()!;
|
|
75
|
+
if (hi - lo < 2) continue;
|
|
76
|
+
let maxErr = -1;
|
|
77
|
+
let idx = -1;
|
|
78
|
+
for (let i = lo + 1; i < hi; i++) {
|
|
79
|
+
const e = chordError(keys[lo], keys[hi], keys[i]);
|
|
80
|
+
if (e > maxErr) {
|
|
81
|
+
maxErr = e;
|
|
82
|
+
idx = i;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (maxErr > eps && idx >= 0) {
|
|
86
|
+
keep[idx] = 1;
|
|
87
|
+
stack.push([lo, idx], [idx, hi]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const out: Key[] = [];
|
|
91
|
+
for (let i = 0; i < n; i++) if (keep[i]) out.push(keys[i]);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Simplify one axis to at most KEY_BUDGET keys: start at a sub-pixel epsilon and
|
|
97
|
+
* grow it until the point count fits, so a busy path just drifts a pixel or two
|
|
98
|
+
* more rather than blowing the expression budget.
|
|
99
|
+
*/
|
|
100
|
+
function simplifyAxis(keys: Key[]): Key[] {
|
|
101
|
+
if (keys.length <= KEY_BUDGET) {
|
|
102
|
+
const r = rdp(keys, 1.2);
|
|
103
|
+
if (r.length <= KEY_BUDGET) return r;
|
|
104
|
+
}
|
|
105
|
+
let eps = 1.2;
|
|
106
|
+
for (let i = 0; i < 24; i++) {
|
|
107
|
+
const r = rdp(keys, eps);
|
|
108
|
+
if (r.length <= KEY_BUDGET) return r;
|
|
109
|
+
eps *= 1.6;
|
|
110
|
+
}
|
|
111
|
+
// Backstop: uniform thin (should never be reached for a real cursor path).
|
|
112
|
+
const step = Math.ceil(keys.length / KEY_BUDGET);
|
|
113
|
+
const out = keys.filter((_, i) => i % step === 0);
|
|
114
|
+
if (out[out.length - 1] !== keys[keys.length - 1]) out.push(keys[keys.length - 1]);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Piecewise-linear expression over overlay's `t` (seconds): holds the first
|
|
120
|
+
* value before the first key, linearly interpolates between keys, holds the last
|
|
121
|
+
* value after the last.
|
|
122
|
+
*
|
|
123
|
+
* Emitted as a FLAT sum of half-open gated segments — NOT nested `if`s. A path
|
|
124
|
+
* has dozens–hundreds of keys, and ffmpeg's expression parser blows its nesting
|
|
125
|
+
* limit ("Missing ')' or too many args") a hundred `if`s deep (zoom.ts gets away
|
|
126
|
+
* with nested `if` only because it has a handful of keys). Each segment
|
|
127
|
+
* [t_i, t_{i+1}) contributes `gte(t,t_i)*lt(t,t_{i+1})*(v_i + slope*(t-t_i))`;
|
|
128
|
+
* the gates are disjoint half-open intervals so exactly one term is live at a
|
|
129
|
+
* time. Depth stays ~3 regardless of key count.
|
|
130
|
+
*/
|
|
131
|
+
function piecewiseLinear(keys: Key[]): string {
|
|
132
|
+
if (keys.length === 1) return num(keys[0].v);
|
|
133
|
+
const first = keys[0];
|
|
134
|
+
const last = keys[keys.length - 1];
|
|
135
|
+
const terms: string[] = [`lt(t,${num(first.t)})*${num(first.v)}`];
|
|
136
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
137
|
+
const a = keys[i];
|
|
138
|
+
const b = keys[i + 1];
|
|
139
|
+
const dt = b.t - a.t;
|
|
140
|
+
const gate = `gte(t,${num(a.t)})*lt(t,${num(b.t)})`;
|
|
141
|
+
if (a.v === b.v || dt < 1e-6) {
|
|
142
|
+
terms.push(`${gate}*${num(a.v)}`);
|
|
143
|
+
} else {
|
|
144
|
+
const slope = (b.v - a.v) / dt;
|
|
145
|
+
terms.push(`${gate}*(${num(a.v)}+(${num(slope)})*(t-${num(a.t)}))`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
terms.push(`gte(t,${num(last.t)})*${num(last.v)}`);
|
|
149
|
+
return terms.join("+");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the `-filter_complex` string overlaying the cursor PNG (input 1) onto
|
|
154
|
+
* the content (input 0) along `rawPts`, hidden during `hideWindows`. Returns
|
|
155
|
+
* null when there's nothing to draw (no samples). The PNG's top-left tracks the
|
|
156
|
+
* point — the same anchor the baked cursor used (translate(x,y)), so the arrow
|
|
157
|
+
* tip lands identically.
|
|
158
|
+
*/
|
|
159
|
+
export function buildCursorFilter(
|
|
160
|
+
rawPts: CursorPoint[],
|
|
161
|
+
hideWindows: HideWindow[] = []
|
|
162
|
+
): string | null {
|
|
163
|
+
if (rawPts.length === 0) return null;
|
|
164
|
+
const sorted = [...rawPts].sort((p, q) => p.t - q.t);
|
|
165
|
+
// Strictly-increasing time so each segment has a positive span.
|
|
166
|
+
const mono: CursorPoint[] = [];
|
|
167
|
+
for (const p of sorted) {
|
|
168
|
+
const last = mono[mono.length - 1];
|
|
169
|
+
mono.push(last && p.t <= last.t ? { ...p, t: last.t + 0.001 } : p);
|
|
170
|
+
}
|
|
171
|
+
// Simplify each axis independently (X and Y are separate expressions) so each
|
|
172
|
+
// stays under the ffmpeg expression budget.
|
|
173
|
+
const X = piecewiseLinear(simplifyAxis(mono.map((p) => ({ t: p.t, v: p.x }))));
|
|
174
|
+
const Y = piecewiseLinear(simplifyAxis(mono.map((p) => ({ t: p.t, v: p.y }))));
|
|
175
|
+
|
|
176
|
+
// enable is single-quoted so the commas inside between()/not() are literal,
|
|
177
|
+
// not filtergraph separators (same reason zoom single-quotes its expressions).
|
|
178
|
+
let enable = "";
|
|
179
|
+
if (hideWindows.length) {
|
|
180
|
+
const terms = hideWindows
|
|
181
|
+
.map((w) => `between(t,${num(w.a)},${num(w.b)})`)
|
|
182
|
+
.join("+");
|
|
183
|
+
enable = `:enable='not(${terms})'`;
|
|
184
|
+
}
|
|
185
|
+
return `[0:v][1:v]overlay=x='${X}':y='${Y}':eval=frame${enable}[vout]`;
|
|
186
|
+
}
|
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The classic arrow pointer, shared by both cursor render paths so they look
|
|
5
|
+
* identical: the record-time BAKED cursor (cursorInitScript, injected into the
|
|
6
|
+
* page) and the compose-time OVERLAY cursor (renderCursorPng, rasterized and
|
|
7
|
+
* overlaid by compose when a storyboard opts into `cursor` control). 24x24
|
|
8
|
+
* viewBox; the visual tip sits near (5,2). Fill white, dark stroke, soft shadow.
|
|
9
|
+
*/
|
|
10
|
+
export const CURSOR_ARROW_PATH = "M5 2 L5 20 L10 15 L13 22 L16 21 L13 14 L20 14 Z";
|
|
11
|
+
const CURSOR_FILL = "#ffffff";
|
|
12
|
+
const CURSOR_STROKE = "#111111";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Rasterize the arrow pointer to a transparent PNG for the compose-time cursor
|
|
16
|
+
* overlay (see src/cursor-overlay.ts). Same trick as the card/caption renderers:
|
|
17
|
+
* headless Chrome screenshots an SVG so the overlay pointer matches the baked
|
|
18
|
+
* one. The arrow is drawn in the top-left of a padded canvas (room for the drop
|
|
19
|
+
* shadow) with its tip at ~(5,2) — the same anchor as the baked cursor, so the
|
|
20
|
+
* overlay lands where the baked cursor would have. `sizePx` is the rendered
|
|
21
|
+
* width/height in output pixels (24 logical units * pxScale * cursor scale).
|
|
22
|
+
*/
|
|
23
|
+
export async function renderCursorPng(
|
|
24
|
+
outPath: string,
|
|
25
|
+
sizePx: number
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
// Logical canvas is 32 units (24 arrow + shadow headroom); scale to sizePx.
|
|
28
|
+
const LOGICAL = 32;
|
|
29
|
+
const dsf = Math.max(1, sizePx / LOGICAL);
|
|
30
|
+
const browser = await chromium.launch({ channel: "chrome", headless: true });
|
|
31
|
+
const context = await browser.newContext({
|
|
32
|
+
viewport: { width: LOGICAL, height: LOGICAL },
|
|
33
|
+
deviceScaleFactor: dsf,
|
|
34
|
+
});
|
|
35
|
+
const page = await context.newPage();
|
|
36
|
+
try {
|
|
37
|
+
await page.setContent(
|
|
38
|
+
`<!doctype html><html><head><meta charset="utf-8"><style>
|
|
39
|
+
html,body{margin:0;padding:0;background:transparent;}
|
|
40
|
+
svg{position:absolute;left:0;top:0;
|
|
41
|
+
filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
|
|
42
|
+
</style></head><body>
|
|
43
|
+
<svg width="${LOGICAL}" height="${LOGICAL}" viewBox="0 0 ${LOGICAL} ${LOGICAL}"
|
|
44
|
+
xmlns="http://www.w3.org/2000/svg">
|
|
45
|
+
<path d="${CURSOR_ARROW_PATH}" fill="${CURSOR_FILL}"
|
|
46
|
+
stroke="${CURSOR_STROKE}" stroke-width="1.3" stroke-linejoin="round"/>
|
|
47
|
+
</svg>
|
|
48
|
+
</body></html>`,
|
|
49
|
+
{ waitUntil: "load" }
|
|
50
|
+
);
|
|
51
|
+
await page.screenshot({ path: outPath, omitBackground: true });
|
|
52
|
+
} finally {
|
|
53
|
+
await browser.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Injected into every frame (main page + widget iframes) via addInitScript.
|
|
59
|
+
* Playwright's built-in video does not capture the OS cursor and its mouse moves
|
|
60
|
+
* teleport, so we render our own cursor: an SVG pointer that follows mousemove
|
|
61
|
+
* events (which our eased player emits as many small steps) and pulses a ripple
|
|
62
|
+
* on mousedown. Each frame renders its own cursor; only the one under the
|
|
63
|
+
* pointer is visible at a time, so there is no double-cursor.
|
|
64
|
+
*
|
|
65
|
+
* This is the DEFAULT (baked) path. A storyboard with a `cursor` block skips
|
|
66
|
+
* this injection and draws the cursor at compose time instead (renderCursorPng
|
|
67
|
+
* + src/cursor-overlay.ts), so hide/resize is a recompose, not a re-record.
|
|
68
|
+
*/
|
|
69
|
+
export function cursorInitScript(): string {
|
|
70
|
+
// Serialized as a string so it runs in the page context with no closure deps.
|
|
71
|
+
return `(() => {
|
|
72
|
+
if (window.__aidemoCursor) return;
|
|
73
|
+
window.__aidemoCursor = true;
|
|
74
|
+
|
|
75
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
76
|
+
const style = document.createElement('style');
|
|
77
|
+
style.textContent = \`
|
|
78
|
+
.__aidemo-cursor{position:fixed;left:0;top:0;width:24px;height:24px;
|
|
79
|
+
z-index:2147483647;pointer-events:none;opacity:0;
|
|
80
|
+
transition:opacity .15s ease;will-change:transform;
|
|
81
|
+
filter:drop-shadow(0 1px 2px rgba(0,0,0,.45));}
|
|
82
|
+
.__aidemo-ripple{position:fixed;left:0;top:0;width:14px;height:14px;
|
|
83
|
+
margin:-7px 0 0 -7px;border-radius:50%;
|
|
84
|
+
background:rgba(56,132,255,.45);z-index:2147483646;pointer-events:none;
|
|
85
|
+
transform:scale(.2);opacity:.9;}
|
|
86
|
+
@keyframes __aidemo-ripple-anim{
|
|
87
|
+
from{transform:scale(.2);opacity:.9}
|
|
88
|
+
to{transform:scale(2.6);opacity:0}}
|
|
89
|
+
\`;
|
|
90
|
+
const mount = () => {
|
|
91
|
+
if (!document.documentElement) return;
|
|
92
|
+
document.documentElement.appendChild(style);
|
|
93
|
+
|
|
94
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
95
|
+
svg.setAttribute('class', '__aidemo-cursor');
|
|
96
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
97
|
+
const path = document.createElementNS(NS, 'path');
|
|
98
|
+
// Classic arrow pointer (shared with the compose-time overlay).
|
|
99
|
+
path.setAttribute('d', '${CURSOR_ARROW_PATH}');
|
|
100
|
+
path.setAttribute('fill', '${CURSOR_FILL}');
|
|
101
|
+
path.setAttribute('stroke', '${CURSOR_STROKE}');
|
|
102
|
+
path.setAttribute('stroke-width', '1.3');
|
|
103
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
104
|
+
svg.appendChild(path);
|
|
105
|
+
document.documentElement.appendChild(svg);
|
|
106
|
+
|
|
107
|
+
let x = window.innerWidth / 2, y = window.innerHeight / 2;
|
|
108
|
+
const place = () => { svg.style.transform = 'translate(' + x + 'px,' + y + 'px)'; };
|
|
109
|
+
place();
|
|
110
|
+
|
|
111
|
+
window.addEventListener('mousemove', (e) => {
|
|
112
|
+
x = e.clientX; y = e.clientY;
|
|
113
|
+
svg.style.opacity = '1';
|
|
114
|
+
place();
|
|
115
|
+
}, true);
|
|
116
|
+
|
|
117
|
+
window.addEventListener('mousedown', (e) => {
|
|
118
|
+
const r = document.createElement('div');
|
|
119
|
+
r.className = '__aidemo-ripple';
|
|
120
|
+
r.style.transform = 'translate(' + e.clientX + 'px,' + e.clientY + 'px) scale(.2)';
|
|
121
|
+
r.style.animation = '__aidemo-ripple-anim .5s ease-out forwards';
|
|
122
|
+
// account for the -7px margin by translating to the point
|
|
123
|
+
r.style.left = e.clientX + 'px';
|
|
124
|
+
r.style.top = e.clientY + 'px';
|
|
125
|
+
r.style.transform = 'scale(.2)';
|
|
126
|
+
document.documentElement.appendChild(r);
|
|
127
|
+
setTimeout(() => r.remove(), 550);
|
|
128
|
+
}, true);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (document.documentElement) mount();
|
|
132
|
+
else document.addEventListener('DOMContentLoaded', mount);
|
|
133
|
+
})();`;
|
|
134
|
+
}
|