@sprid/cli 0.1.2

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +14 -0
  3. package/README-post.md +615 -0
  4. package/README.md +249 -0
  5. package/SECURITY.md +18 -0
  6. package/bin.mjs +12 -0
  7. package/package.json +47 -0
  8. package/src/args.mjs +134 -0
  9. package/src/browser.mjs +28 -0
  10. package/src/cli.mjs +257 -0
  11. package/src/commands/auth.mjs +192 -0
  12. package/src/commands/completion.mjs +90 -0
  13. package/src/commands/connect.mjs +586 -0
  14. package/src/commands/docs.mjs +53 -0
  15. package/src/commands/family.mjs +96 -0
  16. package/src/commands/init.mjs +137 -0
  17. package/src/commands/local-tools.mjs +31 -0
  18. package/src/commands/marketing-review.mjs +106 -0
  19. package/src/commands/mcp.mjs +53 -0
  20. package/src/commands/misc.mjs +94 -0
  21. package/src/commands/pinterest.mjs +120 -0
  22. package/src/commands/plan.mjs +230 -0
  23. package/src/commands/post.mjs +41 -0
  24. package/src/commands/reviews.mjs +149 -0
  25. package/src/commands/setup.mjs +220 -0
  26. package/src/commands/status.mjs +217 -0
  27. package/src/commands/studio.mjs +69 -0
  28. package/src/commands/update.mjs +69 -0
  29. package/src/creds.mjs +126 -0
  30. package/src/docs/commands.mjs +152 -0
  31. package/src/docs/guides.generated.mjs +1178 -0
  32. package/src/docs/help.mjs +77 -0
  33. package/src/docs/index.d.mts +18 -0
  34. package/src/docs/index.mjs +45 -0
  35. package/src/docs/queries.d.mts +11 -0
  36. package/src/docs/queries.mjs +77 -0
  37. package/src/endpoint.mjs +17 -0
  38. package/src/evidence.mjs +15 -0
  39. package/src/format.mjs +71 -0
  40. package/src/http.mjs +117 -0
  41. package/src/pending.mjs +27 -0
  42. package/src/post/cli.mjs +2285 -0
  43. package/src/post/json-worker.mjs +12 -0
  44. package/src/post/preview-server.mjs +58 -0
  45. package/src/post/preview.mjs +660 -0
  46. package/src/post/recipes/screen.mjs +290 -0
  47. package/src/post/recipes/stills.mjs +146 -0
  48. package/src/post/rules/platform-rules.d.mts +27 -0
  49. package/src/post/rules/platform-rules.mjs +164 -0
  50. package/src/post/screen/captions.mjs +131 -0
  51. package/src/post/screen/compose.mjs +284 -0
  52. package/src/post/screen/input.mjs +163 -0
  53. package/src/post/screen/sim.mjs +443 -0
  54. package/src/post/screen/simkit.swift +328 -0
  55. package/src/profiles.mjs +61 -0
  56. package/src/release.ts +2 -0
  57. package/src/screenshots.ts +1 -0
  58. package/src/updates.mjs +152 -0
@@ -0,0 +1,131 @@
1
+ // TikTok-style captions: two or three words at a time, on the rhythm of
2
+ // someone saying them.
3
+ //
4
+ // **Why not one long line held for four seconds.** A screen recording is
5
+ // already asking the eye to track a moving UI; a static paragraph over it gets
6
+ // read once, in the first 400ms, and then competes with the thing it is meant
7
+ // to explain. Chunks that change on the beat are read continuously and cost the
8
+ // UI nothing, which is why every account that does this well does it this way.
9
+ //
10
+ // The timing model is Sprid's own `reels/prosody.ts`, restated small and with
11
+ // no dependency: **weights, not milliseconds.** A word's share of its window
12
+ // tracks its syllables, and the pauses that carry a sentence's shape sit at its
13
+ // punctuation. Even spacing is what makes a word-by-word fill read as a machine
14
+ // effect - "you" and "apologised" get the same beat and a full stop passes
15
+ // without anyone drawing breath.
16
+
17
+ const SYLLABLE = 1;
18
+ const ONSET = 0.34;
19
+ const COMMA = 2.6; // a breath
20
+ const STOP = 5.6; // the sentence lands
21
+ const DASH = 3.8;
22
+
23
+ /** Roughly the word rate of a caption read aloud. Sprid's own figure. */
24
+ export const MS_PER_WORD = 330;
25
+
26
+ /**
27
+ * Syllables, estimated.
28
+ *
29
+ * The `syllable` package is a dictionary and this package has no dependencies,
30
+ * so this counts vowel groups - within about 8% on English and better than that
31
+ * on the Germanic locales, which is well inside what a 330ms beat can show.
32
+ * Japanese counts mora instead, because a kana IS the beat.
33
+ */
34
+ export function syllables(word, locale = "en") {
35
+ if (locale.startsWith("ja")) {
36
+ const kana = word.replace(/[^぀-ヿ一-鿿]/g, "");
37
+ // Small kana attach to the mora before them; a kanji averages two.
38
+ const small = (kana.match(/[ぁぃぅぇぉゃゅょっァィゥェォャュョッ]/g) ?? []).length;
39
+ const kanji = (kana.match(/[一-鿿]/g) ?? []).length;
40
+ return Math.max(1, kana.length - small + kanji);
41
+ }
42
+ const w = word.toLowerCase().replace(/[^a-zà-öø-ÿ']/g, "");
43
+ if (!w) return 1;
44
+ const groups = w.match(/[aeiouyà-öø-ÿ]+/g) ?? [];
45
+ let n = groups.length;
46
+ if (/[^aeiou]e$/.test(w) && n > 1) n -= 1; // silent final e
47
+ return Math.max(1, n);
48
+ }
49
+
50
+ function weightOf(word, locale) {
51
+ let w = ONSET + SYLLABLE * syllables(word, locale);
52
+ if (/[.!?]["”')]?$/.test(word)) w += STOP;
53
+ else if (/[,;:]["”')]?$/.test(word)) w += COMMA;
54
+ else if (/(-|–|…|\.\.\.)$/.test(word)) w += DASH;
55
+ return w;
56
+ }
57
+
58
+ /**
59
+ * Group a line into cards of at most `maxWords` words and `maxChars`
60
+ * characters, never carrying a clause boundary into the middle of a card.
61
+ *
62
+ * The break-after-punctuation rule is the one that matters: a card reading
63
+ * "yes. Then" makes the reader do the sentence boundary twice.
64
+ */
65
+ export function chunk(text, { maxWords = 3, maxChars = 22 } = {}) {
66
+ const words = text.trim().split(/\s+/).filter(Boolean);
67
+ const out = [];
68
+ let cur = [];
69
+ const flush = () => { if (cur.length) { out.push(cur); cur = []; } };
70
+ for (const w of words) {
71
+ const wouldBe = [...cur, w];
72
+ if (cur.length && (wouldBe.length > maxWords || wouldBe.join(" ").length > maxChars)) flush();
73
+ cur.push(w);
74
+ if (/[.!?,;:]["”')]?$/.test(w)) flush();
75
+ }
76
+ flush();
77
+ return out.map((c) => c.join(" "));
78
+ }
79
+
80
+ /**
81
+ * Lay the cards of one caption out across the window between its mark and the
82
+ * next one.
83
+ *
84
+ * **A window that is longer than the words need is left empty at the end, not
85
+ * stretched.** Stretching is the tempting version and it is wrong twice: it
86
+ * makes every card in a slow passage crawl, and it removes the only moment in
87
+ * the reel where the viewer is looking at the app with nothing written over it.
88
+ * That moment is the product demo.
89
+ */
90
+ export function layout(entries, marks, endMs, { locale = "en", msPerWord = MS_PER_WORD, minHoldMs = 520, maxHoldMs = 1500 } = {}) {
91
+ const anchored = entries.map((e) => {
92
+ const at = typeof e.at === "number" ? e.at : marks[e.at];
93
+ if (at == null) throw new Error(`Caption anchored to "${e.at}", which the script never marks.`);
94
+ return { ...e, at: at + (e.after ?? 0) };
95
+ }).sort((a, b) => a.at - b.at);
96
+
97
+ const cards = [];
98
+ const warnings = [];
99
+ anchored.forEach((e, i) => {
100
+ const winStart = e.at;
101
+ const winEnd = Math.min(e.until != null ? (typeof e.until === "number" ? e.until : marks[e.until]) : Infinity,
102
+ i + 1 < anchored.length ? anchored[i + 1].at : endMs, endMs);
103
+ const window = winEnd - winStart;
104
+ if (window <= 0) { warnings.push(`"${e.text.slice(0, 30)}…" has no room before the next caption.`); return; }
105
+
106
+ const texts = chunk(e.text, e.chunk ?? {});
107
+ const weights = texts.map((t) => t.split(/\s+/).reduce((s, w) => s + weightOf(w, locale), 0));
108
+ const totalWeight = weights.reduce((a, b) => a + b, 0);
109
+ const words = e.text.trim().split(/\s+/).length;
110
+ const needed = words * msPerWord;
111
+ const span = Math.min(window, Math.max(needed, texts.length * 420));
112
+ if (needed > window + 1)
113
+ warnings.push(`"${e.text.slice(0, 34)}…" needs ${(needed / 1000).toFixed(1)}s but its beat is ${(window / 1000).toFixed(1)}s - cut words, or move the mark.`);
114
+
115
+ // Prosody sets the SHAPE; the floor and the ceiling keep it readable. A
116
+ // three-word card at 550ms is gone before the eye has finished the second
117
+ // word, and prosody produces exactly that whenever a short card sits next
118
+ // to one carrying a full stop. Clamp, then rescale what is left so the
119
+ // beat still ends where it should.
120
+ let held = weights.map((w) => Math.min(maxHoldMs, Math.max(minHoldMs, (w / totalWeight) * span)));
121
+ const over = held.reduce((a, b) => a + b, 0);
122
+ if (over > window) held = held.map((d) => (d / over) * window);
123
+
124
+ let t = winStart;
125
+ texts.forEach((text, j) => {
126
+ cards.push({ text, start: t, end: Math.min(t + held[j], winEnd), line: e.line ?? null, style: e.style ?? null });
127
+ t += held[j];
128
+ });
129
+ });
130
+ return { cards, warnings };
131
+ }
@@ -0,0 +1,284 @@
1
+ // The screen recording becomes a reel.
2
+ //
3
+ // Everything here is one ffmpeg graph plus, when there is b-roll, a concat of
4
+ // two files encoded to identical parameters. The order is deliberate:
5
+ //
6
+ // VFR -> 30fps before anything measures time
7
+ // trim from the clapper, so the timeline's zero is the video's
8
+ // blurred bed the app is 9:19.6 and a reel is 9:16
9
+ // handheld drift a locked-off frame is the tell that nothing was filmed
10
+ // touch dots a screen recording has no finger in it
11
+ // captions two or three words on the beat
12
+ // music measured on the EXCERPT, limited at -3 dBFS
13
+ //
14
+ // Nothing in it is clever. The two places that cost a rebuild if you get them
15
+ // wrong are marked.
16
+
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, mkdirSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+
21
+ const W = 1080, H = 1920, FPS = 30;
22
+
23
+ const run = (args, opts = {}) => spawnSync("ffmpeg", ["-hide_banner", "-nostats", ...args], { encoding: "utf8", ...opts });
24
+
25
+ /** ffmpeg expressions read better with the numbers already rounded. */
26
+ const n = (v, d = 3) => Number(v).toFixed(d).replace(/\.?0+$/, "") || "0";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // the clapperboard, found
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Where in the video the UI went dark.
34
+ *
35
+ * `recordVideo` reports nothing about when it opened the stream, and the gap
36
+ * between spawn and first frame is neither constant nor small. So the run
37
+ * flashes the whole UI and this finds the flash: sample the luma of a 32-pixel
38
+ * thumbnail ten times a second and take the first sustained drop.
39
+ */
40
+ export function findClappers(video, { dropRatio = 0.55 } = {}) {
41
+ const r = run(["-i", video,
42
+ "-vf", "fps=10,scale=48:-1,signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-",
43
+ "-f", "null", "-"]);
44
+ const out = (r.stdout || "") + (r.stderr || "");
45
+ const samples = [];
46
+ const re = /pts_time:([\d.]+)[\s\S]*?lavfi\.signalstats\.YAVG=([\d.]+)/g;
47
+ let m;
48
+ while ((m = re.exec(out))) samples.push({ t: Number(m[1]), y: Number(m[2]) });
49
+ if (samples.length < 20) return [];
50
+
51
+ // The bar is set from the BRIGHT half of the run, not from the opening
52
+ // frames: the opening frames are the springboard on its way to dark.
53
+ const sorted = [...samples].map((s) => s.y).sort((a, b) => a - b);
54
+ const bright = sorted[Math.floor(sorted.length * 0.75)];
55
+
56
+ const plateaus = [];
57
+ let start = null;
58
+ for (const s of samples) {
59
+ if (s.y < bright * dropRatio) { if (start == null) start = s.t; }
60
+ else if (start != null) { if (s.t - start >= 0.3) plateaus.push({ start, end: s.t }); start = null; }
61
+ }
62
+ return plateaus;
63
+ }
64
+
65
+ /**
66
+ * **The recorder's clock is not the wall clock, so the run measures it.**
67
+ *
68
+ * A single anchor was the first design and it drifts: events at the end of a
69
+ * 75-second recording landed 4.5s from where the wall clock said they were,
70
+ * which is a RATE error, not an offset - one flash cannot see it and one flash
71
+ * is what the first version had. Two flashes, one before the app comes up and
72
+ * one after the script finishes, give both numbers:
73
+ *
74
+ * videoSeconds(wallMs) = v0 + k * wallMs / 1000
75
+ *
76
+ * `k` came out around 0.97 here, so a caption bound to the last mark of a long
77
+ * script was two seconds late on the screen it describes. Nothing about that is
78
+ * visible until you watch it, which is the argument for measuring rather than
79
+ * assuming.
80
+ */
81
+ export function solveClock(plateaus, wallStartMs, wallEndMs) {
82
+ if (plateaus.length < 2) return null;
83
+ const first = plateaus[0].start, last = plateaus[plateaus.length - 1].start;
84
+ const k = (last - first) / ((wallEndMs - wallStartMs) / 1000);
85
+ return { v0: first - k * wallStartMs / 1000, k, first, last };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // the caption cards
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export function renderCaptions(cards, { simkitBin, dir, fontFile, size = 82, width = 940, stroke = 5, pill = 0, color = "ffffff" }) {
93
+ mkdirSync(dir, { recursive: true });
94
+ return cards.map((c, i) => {
95
+ const out = join(dir, `cap-${String(i).padStart(3, "0")}.png`);
96
+ const args = ["text", "--out", out, "--w", String(width), "--size", String(size),
97
+ "--stroke", String(stroke), "--color", color, ...(pill ? ["--pill", String(pill)] : [])];
98
+ if (fontFile) args.push("--font-file", fontFile);
99
+ for (const line of wrap(c.text)) args.push("--line", line);
100
+ const r = spawnSync(simkitBin, args, { encoding: "utf8" });
101
+ if (r.status !== 0) throw new Error(`caption render failed: ${r.stderr}`);
102
+ const [w, h] = r.stdout.trim().split(/\s+/).map(Number);
103
+ return { ...c, png: out, w, h };
104
+ });
105
+ }
106
+
107
+ /** Two or three words never need more than two lines; this only saves the case
108
+ * where one of them is very long. */
109
+ function wrap(text, max = 22) {
110
+ const words = text.split(/\s+/);
111
+ const lines = [];
112
+ let cur = "";
113
+ for (const w of words) {
114
+ if (cur && (cur + " " + w).length > max) { lines.push(cur); cur = w; }
115
+ else cur = cur ? cur + " " + w : w;
116
+ }
117
+ if (cur) lines.push(cur);
118
+ return lines;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // the graph
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Optional handheld drift, **off by default**.
127
+ *
128
+ * Two sine pairs at incommensurable periods and no rotation - rotation costs a
129
+ * full resample per frame and reads as a wobble rather than a hand. The same
130
+ * expression drives the touch dots, or the finger detaches from the screen it
131
+ * is supposed to be touching.
132
+ *
133
+ * At amp 0 both expressions collapse to `0` and the frame is locked, which is
134
+ * the default and what a screen recording should be: the status bar is
135
+ * pin-sharp, so drifting the phone reads as an unstable video rather than as
136
+ * one that was filmed.
137
+ */
138
+ function driftExpr(amp) {
139
+ const a = amp;
140
+ if (!a) return { x: "0", y: "0" };
141
+ return {
142
+ x: `(${n(a * 1.0)}*sin(2*PI*t/7.3)+${n(a * 0.6)}*sin(2*PI*t/4.1+1.1))`,
143
+ y: `(${n(a * 0.8)}*sin(2*PI*t/5.9+0.4)+${n(a * 0.5)}*sin(2*PI*t/3.3))`,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * The finger.
149
+ *
150
+ * A tap is a dot that appears where the finger lands; a swipe is a dot that
151
+ * travels the same eased path simkit posted, so what the viewer sees moving and
152
+ * what the list is responding to are the same gesture. `pow(p,1.55)` is
153
+ * simkit's own flick curve - if the two ever disagree the dot arrives after the
154
+ * content it is supposed to be dragging.
155
+ */
156
+ function dotExprs(gestures, geom, drift, videoOffsetMs, dotSize) {
157
+ const { fx, fy, fw, fh } = geom;
158
+ const enable = [], xs = [], ys = [];
159
+ for (const g of gestures) {
160
+ const t0 = (g.t - videoOffsetMs) / 1000;
161
+ const t1 = t0 + g.durationMs / 1000;
162
+ if (t1 < 0) continue;
163
+ enable.push(`between(t,${n(Math.max(0, t0))},${n(t1)})`);
164
+ const [a, b] = g.points.length > 1 ? g.points : [g.points[0], g.points[0]];
165
+ const px = (p) => n(fx + p[0] * fw - dotSize / 2, 1);
166
+ const py = (p) => n(fy + p[1] * fh - dotSize / 2, 1);
167
+ if (g.kind === "tap") {
168
+ xs.push([`between(t,${n(Math.max(0, t0))},${n(t1)})`, px(a)]);
169
+ ys.push([`between(t,${n(Math.max(0, t0))},${n(t1)})`, py(a)]);
170
+ } else {
171
+ const p = `pow(clip((t-${n(t0)})/${n(g.durationMs / 1000 - 0.26)},0,1),1.55)`;
172
+ xs.push([`between(t,${n(Math.max(0, t0))},${n(t1)})`, `(${px(a)}+(${px(b)}-${px(a)})*${p})`]);
173
+ ys.push([`between(t,${n(Math.max(0, t0))},${n(t1)})`, `(${py(a)}+(${py(b)}-${py(a)})*${p})`]);
174
+ }
175
+ }
176
+ if (!enable.length) return null;
177
+ const chain = (pairs, fallback) => pairs.reduceRight((acc, [cond, val]) => `if(${cond},${val},${acc})`, fallback);
178
+ return {
179
+ enable: enable.join("+"),
180
+ x: `${chain(xs, "0")}+${drift.x}`,
181
+ y: `${chain(ys, "0")}+${drift.y}`,
182
+ };
183
+ }
184
+
185
+ export function buildGraph({ cards, gestures, geom, drift, videoOffsetMs, dotSize, capY, background, trimStart, duration, speed = 1 }) {
186
+ const parts = [];
187
+ const { fx, fy, fw, fh } = geom;
188
+
189
+ // **`fps` first, and on the source.** A `simctl` recording is variable frame
190
+ // rate; every `between(t,…)` below is in seconds, and a graph that resamples
191
+ // after the overlays drifts against them by whole seconds over 20s.
192
+ parts.push(`[0:v]fps=${FPS},trim=start=${n(trimStart, 4)}:duration=${n(duration, 4)},` +
193
+ `setpts=(PTS-STARTPTS)${speed === 1 ? "" : `/${n(speed)}`},split=2[src][bgsrc]`);
194
+
195
+ if (background !== "blur" && !/^(?:#[0-9a-f]{3,8}|0x[0-9a-f]{6,8}|[a-z][a-z0-9-]*)$/i.test(background)) {
196
+ throw new Error("render.background must be 'blur', a color name, or a hex color.");
197
+ }
198
+ if (background === "blur") {
199
+ // The bed. Scaled past the frame, blurred, and darkened, so the eye reads
200
+ // the phone as the subject rather than a picture pasted on a colour.
201
+ parts.push(`[bgsrc]scale=${W * 1.4}:-2,crop=${W}:${H},gblur=sigma=42,eq=brightness=-0.07:saturation=0.85,setsar=1[bg]`);
202
+ } else {
203
+ parts.push(`[bgsrc]drawbox=x=0:y=0:w=iw:h=ih:color=${background}:t=fill,scale=${W}:${H},setsar=1[bg]`);
204
+ }
205
+
206
+ parts.push(`[src]scale=${Math.round(fw)}:${Math.round(fh)}:flags=lanczos,setsar=1[fg]`);
207
+ parts.push(`[bg][fg]overlay=x='${n(fx, 1)}+${drift.x}':y='${n(fy, 1)}+${drift.y}'[v0]`);
208
+
209
+ let last = "v0";
210
+ let input = 1;
211
+
212
+ const dots = dotExprs(gestures, geom, drift, videoOffsetMs, dotSize);
213
+ if (dots) {
214
+ parts.push(`[${input}:v][${last}]scale2ref=w=iw:h=ih[dotref][keep]`); // no-op guard, replaced below
215
+ parts.pop();
216
+ parts.push(`[${last}][${input}:v]overlay=x='${dots.x}':y='${dots.y}':enable='${dots.enable}'[v${input}]`);
217
+ last = `v${input}`;
218
+ input += 1;
219
+ }
220
+
221
+ for (const c of cards) {
222
+ const t0 = (c.start - videoOffsetMs) / 1000;
223
+ const t1 = (c.end - videoOffsetMs) / 1000;
224
+ if (t1 <= 0) { input += 1; continue; }
225
+ // The pop: 14 pixels of rise over the first 90ms. A scale-up would be
226
+ // truer to the platform and costs a second scaled copy of every card.
227
+ const rise = `14*max(0,1-(t-${n(t0)})/0.09)`;
228
+ parts.push(`[${last}][${input}:v]overlay=x='(W-w)/2+${drift.x}':y='${n(capY - c.h, 1)}+${rise}+${drift.y}':enable='between(t,${n(Math.max(0, t0))},${n(t1)})'[v${input}]`);
229
+ last = `v${input}`;
230
+ input += 1;
231
+ }
232
+
233
+ parts.push(`[${last}]format=yuv420p[vout]`);
234
+ return parts.join(";");
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // audio
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * **Measure the excerpt, not the file.** A bed's integrated loudness is not its
243
+ * opening fifteen seconds', and getting this backwards is what put one of the
244
+ * pipelines this recipe was drawn from at 3.8 LUFS of spread across a batch -
245
+ * audible between two posts in a row.
246
+ */
247
+ export function measureLufs(path, seconds) {
248
+ const r = run(["-t", seconds.toFixed(3), "-i", path, "-filter:a", "loudnorm=print_format=json", "-f", "null", "-"]);
249
+ const m = /"input_i"\s*:\s*"(-?[\d.]+)"/.exec(r.stderr ?? "");
250
+ if (!m) throw new Error(`Could not measure loudness of ${path}.`);
251
+ return Number(m[1]);
252
+ }
253
+
254
+ export function audioChain(inputIndex, track, seconds, { targetLufs = -14, fadeOut = 1.2 } = {}) {
255
+ const gain = targetLufs - measureLufs(track, seconds);
256
+ return `[${inputIndex}:a]atrim=start=0:end=${seconds.toFixed(3)},asetpts=PTS-STARTPTS,` +
257
+ `volume=${gain.toFixed(2)}dB,` +
258
+ // -3 dBFS, not -1.5: the AAC encode overshoots the limiter and a -1.5
259
+ // ceiling came back out of the mp4 at 0.0 with clipped samples.
260
+ `alimiter=limit=0.708:level=disabled,` +
261
+ `afade=t=in:st=0:d=0.4,afade=t=out:st=${Math.max(0, seconds - fadeOut).toFixed(3)}:d=${fadeOut}[aout]`;
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // b-roll
266
+ // ---------------------------------------------------------------------------
267
+
268
+ /**
269
+ * The opener, cut hard into the screen recording.
270
+ *
271
+ * **A cut, never a dissolve.** A dissolve tells the viewer a scene is ending,
272
+ * which at second two is an invitation to leave; a cut on the same beat reads
273
+ * as the same person still talking.
274
+ */
275
+ export function encodeBroll(clip, { out, seconds, ss = 0, crf = 20 }) {
276
+ mkdirSync(dirname(out), { recursive: true });
277
+ const r = run(["-y", "-ss", String(ss), "-t", String(seconds), "-i", clip,
278
+ "-vf", `fps=${FPS},scale=${W}:${H}:force_original_aspect_ratio=increase,crop=${W}:${H},setsar=1,format=yuv420p`,
279
+ "-an", "-c:v", "libx264", "-crf", String(crf), "-preset", "medium", "-pix_fmt", "yuv420p", out]);
280
+ if (r.status !== 0) throw new Error(`b-roll encode failed:\n${r.stderr.slice(-1500)}`);
281
+ return out;
282
+ }
283
+
284
+ export { W, H, FPS, run, driftExpr };
@@ -0,0 +1,163 @@
1
+ // The finger.
2
+ //
3
+ // **Two backends, and the good one does not touch the machine.**
4
+ //
5
+ // `baguette` (Homebrew, Apple Silicon, Xcode 26) injects touches through
6
+ // SimulatorKit's private symbols with the iOS-26 calling conventions. Nothing
7
+ // is focused, the pointer never moves, and the Simulator does not come to the
8
+ // front - so a run costs the machine nothing and you can keep working through
9
+ // it. `baguette input` reads newline-delimited JSON gestures from stdin, which
10
+ // is the part that matters: **we keep our own velocity curve.** A canned
11
+ // `swipe --duration` would be a linear interpolation, and a linear
12
+ // interpolation is the single thing that makes a scripted scroll read as
13
+ // scripted.
14
+ //
15
+ // The CGEvent backend below is the fallback, and it is a real fallback rather
16
+ // than a preference: posting to the global HID tap is the ONLY way to reach the
17
+ // Simulator without baguette, and the global tap owns the pointer for the
18
+ // length of the run. Measured 2026-09-06: `CGEventPostToPid`, plain and with
19
+ // fields 91/92 set to the Simulator's window number, delivers without moving
20
+ // the cursor and is ignored. Do not spend another hour on it.
21
+ //
22
+ // Coordinates are normalised 0..1 in both backends. baguette scales x/y by the
23
+ // width/height you pass, so the device's pixel size goes straight in and there
24
+ // is no window geometry to calibrate at all - which also means resizing the
25
+ // Simulator window cannot break a run.
26
+
27
+ import { spawn, spawnSync } from "node:child_process";
28
+
29
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
+
31
+ // --- the curves -------------------------------------------------------------
32
+ // These are the canonical ones. `simkit.swift` mirrors them for the CGEvent
33
+ // backend; if you change one, change both.
34
+
35
+ /** A deliberate drag that arrives and stops - for reading, not for browsing. */
36
+ const easeInOut = (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2);
37
+ /**
38
+ * **A flick still moving at the moment of release.** iOS reads the last few
39
+ * milliseconds of travel as the throw velocity, so a curve that decelerates
40
+ * into its endpoint hands it zero and the list stops dead under the finger.
41
+ */
42
+ const easeInHold = (t) => Math.pow(t, 1.55);
43
+
44
+ const rand = (a, b) => a + Math.random() * (b - a);
45
+ const jit = (a) => (a <= 0 ? 0 : rand(-a, a));
46
+
47
+ /** 120Hz asked for; baguette streams ~110Hz through a pipe, which is plenty. */
48
+ const HZ = 120;
49
+
50
+ export function hasBaguette() {
51
+ return spawnSync("baguette", ["--version"], { encoding: "utf8" }).status === 0;
52
+ }
53
+
54
+ /**
55
+ * @param {object} o
56
+ * @param {string} o.udid
57
+ * @param {{w:number,h:number}} o.px the device screen in pixels
58
+ * @param {(args:string[])=>string} o.kit simkit, for the CGEvent fallback
59
+ * @param {{x,y,w,h}} [o.rect] the Simulator window's device rect, CGEvent only
60
+ * @param {"baguette"|"cgevent"} [o.backend]
61
+ */
62
+ export function openInput({ udid, px, kit, rect, backend, log = () => {} }) {
63
+ const chosen = backend ?? (hasBaguette() ? "baguette" : "cgevent");
64
+ if (chosen === "cgevent") {
65
+ log(" input CGEvent - this takes over the pointer and brings the Simulator forward.\n" +
66
+ " `brew install baguette` and it runs in the background instead.");
67
+ return cgEventInput(kit, rect);
68
+ }
69
+ log(" input baguette - background, the pointer stays yours");
70
+ return baguetteInput(udid, px);
71
+ }
72
+
73
+ // --- baguette ---------------------------------------------------------------
74
+
75
+ function baguetteInput(udid, px) {
76
+ const proc = spawn("baguette", ["input", "--udid", udid], { stdio: ["pipe", "pipe", "pipe"] });
77
+ let failed = null;
78
+ proc.stdout.on("data", (d) => {
79
+ for (const line of String(d).split("\n").filter(Boolean)) {
80
+ try { const r = JSON.parse(line); if (r.ok === false) failed = r.error; } catch { /* a log line */ }
81
+ }
82
+ });
83
+ proc.stderr.on("data", () => {});
84
+
85
+ // **Paced by the clock, not by the acknowledgements.** Awaiting each ack
86
+ // would make the sample rate a function of pipe latency, and the whole point
87
+ // of streaming rather than calling `baguette swipe` is that WE own the
88
+ // timing. Errors are read off stdout in the background and raised at the end
89
+ // of the gesture instead.
90
+ const send = (type, nx, ny) => {
91
+ proc.stdin.write(JSON.stringify({
92
+ type, x: Math.round(nx * px.w), y: Math.round(ny * px.h), width: px.w, height: px.h,
93
+ }) + "\n");
94
+ };
95
+ const check = () => { if (failed) throw new Error(`baguette rejected a gesture: ${failed}`); };
96
+
97
+ return {
98
+ backend: "baguette",
99
+ /**
100
+ * **A tap goes through baguette's one-shot path, not the stream.**
101
+ *
102
+ * The streaming `touch1-*` path is `IOHIDDigitizerDispatch`, built for
103
+ * continuous gestures - swipes, edge pulls, app-switcher drags - and it is
104
+ * the reason we can keep our own velocity curve. It is not equally good at
105
+ * a discrete tap: UIKit tab bars can ignore streamed taps that React Native
106
+ * views accept. Use the dedicated tap command for discrete input.
107
+ *
108
+ * A tap has no timing worth owning anyway, which is what makes this a free
109
+ * trade: one process spawn, about 100ms.
110
+ */
111
+ async tap(nx, ny, { holdMs = rand(55, 100) } = {}) {
112
+ const r = spawnSync("baguette", ["tap", "--udid", udid,
113
+ "--x", String(Math.round(nx * px.w)), "--y", String(Math.round(ny * px.h)),
114
+ "--width", String(px.w), "--height", String(px.h),
115
+ "--duration", (holdMs / 1000).toFixed(3)], { encoding: "utf8" });
116
+ if (r.status !== 0) throw new Error(`baguette tap failed: ${r.stderr}`);
117
+ await sleep(40);
118
+ },
119
+ async swipe(x1, y1, x2, y2, { durationMs = 280, flick = true, bow = 0.025, settleMs = 90 } = {}) {
120
+ const dx = x2 - x1, dy = y2 - y1;
121
+ const dist = Math.hypot(dx, dy);
122
+ // A perpendicular bow: nobody drags a straight line.
123
+ const nx = dist > 0 ? -dy / dist : 0, ny = dist > 0 ? dx / dist : 0;
124
+ const bowAmt = bow * dist * (Math.random() < 0.5 ? 1 : -1);
125
+
126
+ const steps = Math.max(4, Math.round(durationMs / (1000 / HZ)));
127
+ const t0 = Date.now();
128
+ send("touch1-down", x1, y1);
129
+ await sleep(rand(12, 28)); // press, then travel
130
+ for (let i = 1; i <= steps; i++) {
131
+ const e = flick ? easeInHold(i / steps) : easeInOut(i / steps);
132
+ const arc = Math.sin(e * Math.PI) * bowAmt;
133
+ send("touch1-move", x1 + dx * e + nx * arc + jit(0.0008), y1 + dy * e + ny * arc + jit(0.0008));
134
+ const wait = t0 + 20 + (i * durationMs) / steps - Date.now();
135
+ if (wait > 0) await sleep(wait);
136
+ }
137
+ if (!flick) await sleep(settleMs); // a precise drag stops, then lifts
138
+ send("touch1-up", x2, y2);
139
+ await sleep(40);
140
+ check();
141
+ },
142
+ close() { try { proc.stdin.end(); } catch { /* already gone */ } },
143
+ };
144
+ }
145
+
146
+ // --- CGEvent ----------------------------------------------------------------
147
+
148
+ function cgEventInput(kit, rect) {
149
+ const rectArg = ["--rect", `${rect.x},${rect.y},${rect.w},${rect.h}`];
150
+ return {
151
+ backend: "cgevent",
152
+ async tap(nx, ny, { holdMs } = {}) {
153
+ kit(["tap", "--x", String(nx), "--y", String(ny), ...rectArg,
154
+ ...(holdMs ? ["--hold", String(holdMs)] : [])]);
155
+ },
156
+ async swipe(x1, y1, x2, y2, { durationMs = 280, flick = true, bow } = {}) {
157
+ kit(["swipe", "--x1", String(x1), "--y1", String(y1), "--x2", String(x2), "--y2", String(y2),
158
+ "--duration", String(durationMs), ...(flick ? [] : ["--precise"]),
159
+ ...(bow != null ? ["--bow", String(bow)] : []), ...rectArg]);
160
+ },
161
+ close() {},
162
+ };
163
+ }