@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +14 -0
- package/README-post.md +615 -0
- package/README.md +249 -0
- package/SECURITY.md +18 -0
- package/bin.mjs +12 -0
- package/package.json +47 -0
- package/src/args.mjs +134 -0
- package/src/browser.mjs +28 -0
- package/src/cli.mjs +257 -0
- package/src/commands/auth.mjs +192 -0
- package/src/commands/completion.mjs +90 -0
- package/src/commands/connect.mjs +586 -0
- package/src/commands/docs.mjs +53 -0
- package/src/commands/family.mjs +96 -0
- package/src/commands/init.mjs +137 -0
- package/src/commands/local-tools.mjs +31 -0
- package/src/commands/marketing-review.mjs +106 -0
- package/src/commands/mcp.mjs +53 -0
- package/src/commands/misc.mjs +94 -0
- package/src/commands/pinterest.mjs +120 -0
- package/src/commands/plan.mjs +230 -0
- package/src/commands/post.mjs +41 -0
- package/src/commands/reviews.mjs +149 -0
- package/src/commands/setup.mjs +220 -0
- package/src/commands/status.mjs +217 -0
- package/src/commands/studio.mjs +69 -0
- package/src/commands/update.mjs +69 -0
- package/src/creds.mjs +126 -0
- package/src/docs/commands.mjs +152 -0
- package/src/docs/guides.generated.mjs +1178 -0
- package/src/docs/help.mjs +77 -0
- package/src/docs/index.d.mts +18 -0
- package/src/docs/index.mjs +45 -0
- package/src/docs/queries.d.mts +11 -0
- package/src/docs/queries.mjs +77 -0
- package/src/endpoint.mjs +17 -0
- package/src/evidence.mjs +15 -0
- package/src/format.mjs +71 -0
- package/src/http.mjs +117 -0
- package/src/pending.mjs +27 -0
- package/src/post/cli.mjs +2285 -0
- package/src/post/json-worker.mjs +12 -0
- package/src/post/preview-server.mjs +58 -0
- package/src/post/preview.mjs +660 -0
- package/src/post/recipes/screen.mjs +290 -0
- package/src/post/recipes/stills.mjs +146 -0
- package/src/post/rules/platform-rules.d.mts +27 -0
- package/src/post/rules/platform-rules.mjs +164 -0
- package/src/post/screen/captions.mjs +131 -0
- package/src/post/screen/compose.mjs +284 -0
- package/src/post/screen/input.mjs +163 -0
- package/src/post/screen/sim.mjs +443 -0
- package/src/post/screen/simkit.swift +328 -0
- package/src/profiles.mjs +61 -0
- package/src/release.ts +2 -0
- package/src/screenshots.ts +1 -0
- 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
|
+
}
|