@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,290 @@
|
|
|
1
|
+
// The `screen` recipe: an app, driven, recorded, and cut into a reel.
|
|
2
|
+
//
|
|
3
|
+
// **The thing it exists for is the seven-language version.** A demo reel is
|
|
4
|
+
// expensive to make once and usually impossible to make again in another
|
|
5
|
+
// language, because the work is a person holding a phone. Here the work splits
|
|
6
|
+
// in two: a SCRIPT, which is the finger's path through the app and carries no
|
|
7
|
+
// words at all, and a SPEC, which carries the locale and the captions. Running
|
|
8
|
+
// the same script under a different locale is one command and about ninety
|
|
9
|
+
// seconds, so a language costs what its translation costs and nothing else.
|
|
10
|
+
//
|
|
11
|
+
// social/scripts/log-a-decision.json the path. one file, all languages.
|
|
12
|
+
// social/specs/log-a-decision-sv.json the locale, the captions, the music.
|
|
13
|
+
//
|
|
14
|
+
// Everything below is a default, and the recipe prints the ffmpeg command it
|
|
15
|
+
// ran. See `src/screen/` for the three halves: the simulator, the caption
|
|
16
|
+
// timing, the graph.
|
|
17
|
+
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import {
|
|
22
|
+
boot, clapper, clearStatusBar, clearStuckRecorder, deviceRect, deviceSize, dressStatusBar,
|
|
23
|
+
kit, readJson, resolveDevice, runScript, simkit, sleep, startRecording, writeJson,
|
|
24
|
+
} from "../screen/sim.mjs";
|
|
25
|
+
import { settle as settleFor } from "../screen/sim.mjs";
|
|
26
|
+
import { layout } from "../screen/captions.mjs";
|
|
27
|
+
import { hasBaguette, openInput } from "../screen/input.mjs";
|
|
28
|
+
import {
|
|
29
|
+
H, W, FPS, audioChain, buildGraph, driftExpr, encodeBroll, findClappers,
|
|
30
|
+
renderCaptions, run, solveClock,
|
|
31
|
+
} from "../screen/compose.mjs";
|
|
32
|
+
|
|
33
|
+
const FONT_CANDIDATES = [
|
|
34
|
+
`${process.env.HOME}/Library/Fonts/TikTokSans-Bold.ttf`,
|
|
35
|
+
`${process.env.HOME}/Library/Fonts/DMSans-Bold.ttf`,
|
|
36
|
+
"/System/Library/Fonts/SFNS.ttf",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `recut` re-cuts the recording already in `tmp` and touches no simulator at
|
|
41
|
+
* all. **It is the loop that matters.** Driving the app takes the machine's
|
|
42
|
+
* pointer for a minute and a half, and almost everything you iterate on after
|
|
43
|
+
* the first run - a caption, a beat, where the captions sit, the speed, the
|
|
44
|
+
* drift - does not need the app driven again. `cut.json` holds exactly what a
|
|
45
|
+
* re-cut needs: the timeline, the second clapper's wall time, and the device's
|
|
46
|
+
* pixel size.
|
|
47
|
+
*/
|
|
48
|
+
export async function buildScreen({ spec, script, root, out, tmp, recut = false, log = console.log }) {
|
|
49
|
+
const cfg = {
|
|
50
|
+
device: "iPhone 17",
|
|
51
|
+
fill: 1.0, // how much of the 1920 the phone takes
|
|
52
|
+
capY: 0.72, // the caption baseline, as a fraction of the frame
|
|
53
|
+
capSize: 82,
|
|
54
|
+
// **Off.** A screen recording holds still. An earlier default drifted the
|
|
55
|
+
// phone a few pixels on two sine pairs to imitate a hand, and it reads as
|
|
56
|
+
// the wrong thing entirely: nobody believes a hand is holding a frame whose
|
|
57
|
+
// status bar is pin-sharp, so the movement registers as the video being
|
|
58
|
+
// unstable rather than as being filmed. Set `render.drift` to a small
|
|
59
|
+
// number (4-6px) only if a specific cut actually wants it.
|
|
60
|
+
drift: 0,
|
|
61
|
+
dot: 108,
|
|
62
|
+
speed: 1,
|
|
63
|
+
background: "blur",
|
|
64
|
+
tailMs: 900,
|
|
65
|
+
leadMs: 250,
|
|
66
|
+
crf: 19,
|
|
67
|
+
...spec.render,
|
|
68
|
+
};
|
|
69
|
+
mkdirSync(tmp, { recursive: true });
|
|
70
|
+
const advice0 = [];
|
|
71
|
+
const bin = simkit();
|
|
72
|
+
const raw = join(tmp, "raw.mov");
|
|
73
|
+
const cutPath = join(tmp, "cut.json");
|
|
74
|
+
|
|
75
|
+
let timeline;
|
|
76
|
+
/** Wall ms from the first clapper flash to the second. */
|
|
77
|
+
let endClapAt;
|
|
78
|
+
/** The device screen in pixels; it sets the reel's geometry. */
|
|
79
|
+
let px;
|
|
80
|
+
|
|
81
|
+
if (recut) {
|
|
82
|
+
if (!existsSync(cutPath) || !existsSync(raw))
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Nothing to re-cut for ${spec.slug}: both cut.json and raw.mov have to be in ${tmp} from a ` +
|
|
85
|
+
"previous run. Build it once without --recut.",
|
|
86
|
+
);
|
|
87
|
+
const cut = JSON.parse(readFileSync(cutPath, "utf8"));
|
|
88
|
+
({ timeline, endClapAt, px } = cut);
|
|
89
|
+
log(` recut the recording from ${cut.at} - no simulator`);
|
|
90
|
+
} else {
|
|
91
|
+
// ---- the device -------------------------------------------------------
|
|
92
|
+
const udid = resolveDevice(spec.device ?? cfg.device);
|
|
93
|
+
log(` device ${spec.device ?? cfg.device} ${udid}`);
|
|
94
|
+
boot(udid);
|
|
95
|
+
dressStatusBar(udid, { time: spec.statusBarTime ?? "now", ...(spec.battery ? { battery: spec.battery } : {}) });
|
|
96
|
+
|
|
97
|
+
// ---- the repo's own preparation ---------------------------------------
|
|
98
|
+
// **This is the boundary.** Metro, the demo seed, the locale, the install:
|
|
99
|
+
// all of it is welded to the consuming repo and none of it belongs in a
|
|
100
|
+
// package. The recipe asks for a command and waits for it to exit 0.
|
|
101
|
+
if (spec.app?.prepare) {
|
|
102
|
+
const cmd = spec.app.prepare.replaceAll("<locale>", spec.locale ?? "en").replaceAll("<udid>", udid);
|
|
103
|
+
log(` prepare ${cmd}`);
|
|
104
|
+
const r = spawnSync("/bin/sh", ["-c", cmd], { cwd: root, stdio: "inherit" });
|
|
105
|
+
if (r.status !== 0) throw new Error("prepare failed");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
px = deviceSize(udid, tmp);
|
|
109
|
+
log(` screen ${px.w}x${px.h}px`);
|
|
110
|
+
// **The window geometry is only the CGEvent fallback's problem.** baguette
|
|
111
|
+
// scales by the width/height it is given, so with it there is nothing to
|
|
112
|
+
// calibrate and resizing the Simulator window cannot break a run.
|
|
113
|
+
const input = openInput({
|
|
114
|
+
udid, px, kit,
|
|
115
|
+
rect: hasBaguette() && spec.input !== "cgevent" ? undefined : deviceRect(px.w / px.h),
|
|
116
|
+
backend: spec.input, log,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ---- record -----------------------------------------------------------
|
|
120
|
+
let rec = startRecording(udid, raw);
|
|
121
|
+
try {
|
|
122
|
+
await rec.ready();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// An interrupted run poisons the next one, and it is not the next run's
|
|
125
|
+
// fault - recover once rather than making a person read the error.
|
|
126
|
+
if (!/already in progress/i.test(String(e))) throw e;
|
|
127
|
+
log(" record the last run left the recorder registered; cycling the device");
|
|
128
|
+
await rec.stop();
|
|
129
|
+
await clearStuckRecorder(udid);
|
|
130
|
+
dressStatusBar(udid, { time: spec.statusBarTime ?? "now", ...(spec.battery ? { battery: spec.battery } : {}) });
|
|
131
|
+
rec = startRecording(udid, raw);
|
|
132
|
+
await rec.ready();
|
|
133
|
+
}
|
|
134
|
+
const clapAt = await clapper(udid, { bundleId: spec.app?.bundleId }); // the timeline's zero
|
|
135
|
+
try {
|
|
136
|
+
// **The launch is the recipe's, not the repo's.** The anchor above has to
|
|
137
|
+
// happen on the springboard, so the app comes up after it - which means
|
|
138
|
+
// `prepare` seeds and configures and stops short of launching.
|
|
139
|
+
if (spec.app?.bundleId) {
|
|
140
|
+
spawnSync("xcrun", ["simctl", "launch", udid, spec.app.bundleId], { stdio: "ignore" });
|
|
141
|
+
await sleep(spec.app.launchMs ?? 18_000);
|
|
142
|
+
await settleFor(udid, tmp, { maxMs: 6000 });
|
|
143
|
+
}
|
|
144
|
+
timeline = await runScript(script, {
|
|
145
|
+
udid, tmp, input, t0: clapAt, root, log,
|
|
146
|
+
vars: { udid, locale: spec.locale ?? "en", bundleId: spec.app?.bundleId ?? "" },
|
|
147
|
+
});
|
|
148
|
+
await sleep(cfg.tailMs);
|
|
149
|
+
// The second anchor. It happens after everything the reel keeps, so the
|
|
150
|
+
// terminate it needs costs nothing.
|
|
151
|
+
endClapAt = (await clapper(udid, { bundleId: spec.app?.bundleId })) - clapAt;
|
|
152
|
+
} finally {
|
|
153
|
+
input.close();
|
|
154
|
+
await rec.stop();
|
|
155
|
+
clearStatusBar(udid);
|
|
156
|
+
}
|
|
157
|
+
writeJson(join(tmp, "timeline.json"), timeline);
|
|
158
|
+
// Everything a re-cut needs and nothing it does not. `endClapAt` above all:
|
|
159
|
+
// without the second clapper's wall time the recorder's clock cannot be
|
|
160
|
+
// solved from the video alone, and a re-cut would have to guess it.
|
|
161
|
+
writeJson(cutPath, {
|
|
162
|
+
at: new Date().toISOString(), device: spec.device ?? cfg.device, px, endClapAt, timeline,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- solve the recorder's clock -----------------------------------------
|
|
167
|
+
const plateaus = findClappers(raw);
|
|
168
|
+
const clock = solveClock(plateaus, 0, endClapAt);
|
|
169
|
+
if (!clock)
|
|
170
|
+
throw new Error(`Found ${plateaus.length} clapper flash(es) in the recording, needed 2. ` +
|
|
171
|
+
"The flash is the springboard going dark, so this means the app was still in front of it - " +
|
|
172
|
+
"check that `app.bundleId` is right and that `prepare` passes --no-launch.");
|
|
173
|
+
log(` clock flashes at ${clock.first.toFixed(2)}s and ${clock.last.toFixed(2)}s; ` +
|
|
174
|
+
`the recorder runs at ${clock.k.toFixed(4)}x wall`);
|
|
175
|
+
// **A low k is a spoiled recording, not a number to correct for.** The solve
|
|
176
|
+
// keeps the captions in sync, but the missing time cannot be put back: at
|
|
177
|
+
// 0.71 the app's own motion plays 1.4x fast on top of whatever `speed` asked
|
|
178
|
+
// for. It happens when the machine is loaded enough to starve the recorder.
|
|
179
|
+
if (clock.k < 0.9)
|
|
180
|
+
throw new Error(
|
|
181
|
+
`The recorder held only ${(clock.k * 100).toFixed(0)}% of the elapsed time, so the app's motion in this ` +
|
|
182
|
+
`take plays ${(1 / clock.k).toFixed(2)}x fast. That is a spoiled recording rather than something to ` +
|
|
183
|
+
"correct for - run it again on a quieter machine.",
|
|
184
|
+
);
|
|
185
|
+
if (clock.k > 1.1)
|
|
186
|
+
advice0.push(`The recording clock solved to ${clock.k.toFixed(3)}x wall, which is too far off to trust. Check the two flashes.`);
|
|
187
|
+
const toVideo = (ms) => clock.v0 + clock.k * ms / 1000;
|
|
188
|
+
|
|
189
|
+
// ---- when the reel starts and stops -------------------------------------
|
|
190
|
+
// **The reel starts at a mark, not at a step.** The first thing a viewer sees
|
|
191
|
+
// has to be the screen the hook is about, and that screen is on-camera for a
|
|
192
|
+
// beat before anything is tapped - so the in-point is the script's first mark
|
|
193
|
+
// (or whichever `startAt` names), never "the first gesture minus 250ms".
|
|
194
|
+
const firstMark = Object.entries(timeline.marks).sort((a, b) => a[1] - b[1])[0];
|
|
195
|
+
const firstStep = timeline.steps.find((s) => s.kind !== "wait") ?? timeline.steps[0];
|
|
196
|
+
const startMs = spec.startMs
|
|
197
|
+
?? (spec.startAt ? timeline.marks[spec.startAt] : firstMark?.[1])
|
|
198
|
+
?? Math.max(0, (firstStep?.t ?? 0) - cfg.leadMs);
|
|
199
|
+
const endMs = spec.endMs ?? timeline.endedAt + cfg.tailMs;
|
|
200
|
+
const durationMs = ((toVideo(endMs) - toVideo(startMs)) * 1000) / cfg.speed;
|
|
201
|
+
const seconds = durationMs / 1000;
|
|
202
|
+
log(` length ${seconds.toFixed(1)}s${cfg.speed !== 1 ? ` (at ${cfg.speed}x)` : ""}`);
|
|
203
|
+
|
|
204
|
+
const advice = [...advice0];
|
|
205
|
+
const brollSeconds = spec.broll ? (spec.broll.seconds ?? 2.4) : 0;
|
|
206
|
+
const total = seconds + brollSeconds;
|
|
207
|
+
if (total > 20) advice.push(`${total.toFixed(1)}s is long. 15-17s is the only length either account has a number behind it; trim the script or raise \`render.speed\`.`);
|
|
208
|
+
if (total < 8) advice.push(`${total.toFixed(1)}s is very short - under about 8s there is no watch time to earn.`);
|
|
209
|
+
|
|
210
|
+
// ---- captions -----------------------------------------------------------
|
|
211
|
+
// Wall milliseconds -> milliseconds in the finished reel. Both corrections
|
|
212
|
+
// in one line: the recorder's rate, then the speed-up.
|
|
213
|
+
const shift = (t) => ((toVideo(t) - toVideo(startMs)) * 1000) / cfg.speed;
|
|
214
|
+
const marks = Object.fromEntries(Object.entries(timeline.marks).map(([k, v]) => [k, shift(v)]));
|
|
215
|
+
const { cards, warnings } = layout(spec.captions ?? [], marks, durationMs, { locale: spec.locale ?? "en" });
|
|
216
|
+
advice.push(...warnings);
|
|
217
|
+
|
|
218
|
+
const fontFile = spec.render?.fontFile
|
|
219
|
+
? resolve(root, spec.render.fontFile)
|
|
220
|
+
: FONT_CANDIDATES.find(existsSync);
|
|
221
|
+
const rendered = renderCaptions(cards, {
|
|
222
|
+
simkitBin: bin, dir: join(tmp, "captions"), fontFile,
|
|
223
|
+
size: cfg.capSize, width: Math.round(W * 0.88), pill: cfg.pill ?? 0,
|
|
224
|
+
...(cfg.stroke != null ? { stroke: cfg.stroke } : {}),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const dotPng = join(tmp, "dot.png");
|
|
228
|
+
spawnSync(bin, ["dot", "--out", dotPng, "--r", String(cfg.dot / 2)]);
|
|
229
|
+
|
|
230
|
+
// ---- geometry -----------------------------------------------------------
|
|
231
|
+
const fh = H * cfg.fill;
|
|
232
|
+
const fw = fh * (px.w / px.h);
|
|
233
|
+
const geom = { fx: (W - fw) / 2, fy: (H - fh) / 2, fw, fh };
|
|
234
|
+
const drift = driftExpr(cfg.drift);
|
|
235
|
+
|
|
236
|
+
const gestures = timeline.gestures.map((g) => ({ ...g, t: shift(g.t), durationMs: (g.durationMs * clock.k) / cfg.speed }));
|
|
237
|
+
|
|
238
|
+
const graph = buildGraph({
|
|
239
|
+
cards: rendered, gestures, geom, drift, videoOffsetMs: 0,
|
|
240
|
+
dotSize: cfg.dot, capY: H * cfg.capY, background: cfg.background,
|
|
241
|
+
trimStart: toVideo(startMs), duration: toVideo(endMs) - toVideo(startMs), speed: cfg.speed,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ---- the graph ----------------------------------------------------------
|
|
245
|
+
const screenOnly = join(tmp, "screen.mp4");
|
|
246
|
+
const args = ["-y", "-i", raw, "-i", dotPng, ...rendered.flatMap((c) => ["-i", c.png])];
|
|
247
|
+
let filter = graph;
|
|
248
|
+
const trackPath = spec.track ? resolve(root, spec.track) : null;
|
|
249
|
+
|
|
250
|
+
// Music covers the b-roll too, so it is laid over the concatenated file when
|
|
251
|
+
// there is b-roll and over this one when there is not.
|
|
252
|
+
if (trackPath && !spec.broll) {
|
|
253
|
+
args.push("-i", trackPath);
|
|
254
|
+
filter += ";" + audioChain(2 + rendered.length, trackPath, seconds);
|
|
255
|
+
}
|
|
256
|
+
args.push("-filter_complex", filter, "-map", "[vout]");
|
|
257
|
+
if (trackPath && !spec.broll) args.push("-map", "[aout]", "-c:a", "aac", "-b:a", "192k");
|
|
258
|
+
else args.push("-an");
|
|
259
|
+
args.push("-r", String(FPS), "-c:v", "libx264", "-crf", String(cfg.crf), "-preset", "medium",
|
|
260
|
+
"-pix_fmt", "yuv420p", "-movflags", "+faststart", screenOnly);
|
|
261
|
+
|
|
262
|
+
const printed = `ffmpeg ${args.map((a) => (/[ ;'"\[\]]/.test(a) ? `'${a.replaceAll("'", "'\\''")}'` : a)).join(" ")}`;
|
|
263
|
+
const r = run(args);
|
|
264
|
+
if (r.status !== 0) throw new Error(`ffmpeg failed:\n${r.stderr.slice(-2500)}`);
|
|
265
|
+
|
|
266
|
+
// ---- b-roll -------------------------------------------------------------
|
|
267
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
268
|
+
if (!spec.broll) {
|
|
269
|
+
spawnSync("/bin/cp", [screenOnly, out]);
|
|
270
|
+
} else {
|
|
271
|
+
const head = encodeBroll(resolve(root, spec.broll.clip), {
|
|
272
|
+
out: join(tmp, "broll.mp4"), seconds: brollSeconds, ss: spec.broll.ss ?? 0, crf: cfg.crf,
|
|
273
|
+
});
|
|
274
|
+
const silent = join(tmp, "joined.mp4");
|
|
275
|
+
const listFile = join(tmp, "concat.txt");
|
|
276
|
+
writeFileSync(listFile, `file '${head}'\nfile '${screenOnly}'\n`);
|
|
277
|
+
const j = run(["-y", "-f", "concat", "-safe", "0", "-i", listFile, "-c", "copy", "-movflags", "+faststart", silent]);
|
|
278
|
+
if (j.status !== 0) throw new Error(`concat failed:\n${j.stderr.slice(-1500)}`);
|
|
279
|
+
if (trackPath) {
|
|
280
|
+
const a = run(["-y", "-i", silent, "-i", trackPath, "-filter_complex", audioChain(1, trackPath, total),
|
|
281
|
+
"-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
|
|
282
|
+
"-movflags", "+faststart", out]);
|
|
283
|
+
if (a.status !== 0) throw new Error(`music failed:\n${a.stderr.slice(-1500)}`);
|
|
284
|
+
} else spawnSync("/bin/cp", [silent, out]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const commandPath = join(tmp, "ffmpeg-command.txt");
|
|
288
|
+
writeFileSync(commandPath, printed + "\n");
|
|
289
|
+
return { command: printed, commandPath, advice, timeline, cards: rendered, seconds: total };
|
|
290
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The one built-in recipe: photographs into a moving reel.
|
|
2
|
+
//
|
|
3
|
+
// **It answers the thing nothing else does** - material that is neither text
|
|
4
|
+
// nor already a video. Text reels are composed in Sprid, where the templates
|
|
5
|
+
// live; a file you already have goes straight into the media picker. What has
|
|
6
|
+
// no home is a folder of pictures.
|
|
7
|
+
//
|
|
8
|
+
// It is a RECIPE and not a framework, which means one thing in practice: it
|
|
9
|
+
// prints the ffmpeg command it ran. The output is a starting point somebody
|
|
10
|
+
// edits, not a black box. Everything below is a default, and every default is
|
|
11
|
+
// either measured or copied from a pipeline that measured it.
|
|
12
|
+
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import { existsSync, statSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
/** 9:16, which is the only shape a reel is. */
|
|
17
|
+
const W = 1080;
|
|
18
|
+
const H = 1920;
|
|
19
|
+
const FPS = 30;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Loudness of the EXCERPT, not the file.
|
|
23
|
+
*
|
|
24
|
+
* A bed's integrated loudness is not its opening fifteen seconds', and mixing
|
|
25
|
+
* those two up is the single most expensive audio mistake in either pipeline
|
|
26
|
+
* this recipe was drawn from: measuring the master put one lane's batch at 3.8
|
|
27
|
+
* LUFS spread - audible between two posts in a row - while the lane that
|
|
28
|
+
* measured its window sat at 1.5.
|
|
29
|
+
*/
|
|
30
|
+
function measureLufs(path, seconds) {
|
|
31
|
+
const r = spawnSync(
|
|
32
|
+
"ffmpeg",
|
|
33
|
+
["-hide_banner", "-nostats", "-t", seconds.toFixed(3), "-i", path, "-filter:a", "loudnorm=print_format=json", "-f", "null", "-"],
|
|
34
|
+
{ encoding: "utf8" },
|
|
35
|
+
);
|
|
36
|
+
const m = /"input_i"\s*:\s*"(-?[\d.]+)"/.exec(r.stderr ?? "");
|
|
37
|
+
if (!m) throw new Error(`Could not measure loudness of ${path} - is it an audio file?`);
|
|
38
|
+
return Number(m[1]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The moving image.
|
|
43
|
+
*
|
|
44
|
+
* **Scaled up before the push, then back down.** `zoompan` positions on whole
|
|
45
|
+
* source pixels, so pushing a 1080-wide image directly makes the frame step
|
|
46
|
+
* rather than glide - the same judder that makes a slow pan look like a
|
|
47
|
+
* slideshow. Working at 2x and landing at 1080 puts the step under half an
|
|
48
|
+
* output pixel.
|
|
49
|
+
*
|
|
50
|
+
* The push is small on purpose. 6% over three seconds reads as the camera
|
|
51
|
+
* breathing; anything you can see moving is a zoom, and a zoom draws attention
|
|
52
|
+
* to the tool rather than the picture.
|
|
53
|
+
*/
|
|
54
|
+
function imageChain(i, holdMs, push) {
|
|
55
|
+
const frames = Math.round((holdMs / 1000) * FPS);
|
|
56
|
+
const per = (push - 1) / frames;
|
|
57
|
+
return (
|
|
58
|
+
`[${i}:v]scale=${W * 2}:${H * 2}:force_original_aspect_ratio=increase,` +
|
|
59
|
+
`crop=${W * 2}:${H * 2},` +
|
|
60
|
+
`zoompan=z='min(1+${per.toFixed(6)}*on,${push})':d=${frames}` +
|
|
61
|
+
`:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${W}x${H}:fps=${FPS},` +
|
|
62
|
+
`setsar=1,format=yuv420p[v${i}]`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildStills({
|
|
67
|
+
images,
|
|
68
|
+
track,
|
|
69
|
+
out,
|
|
70
|
+
holdMs = 3000,
|
|
71
|
+
xfadeMs = 600,
|
|
72
|
+
push = 1.06,
|
|
73
|
+
targetLufs = -14,
|
|
74
|
+
crf = 20,
|
|
75
|
+
}) {
|
|
76
|
+
if (!images?.length) throw new Error("stills needs at least one image (`--in <folder>` or a spec with `images`).");
|
|
77
|
+
for (const f of images) if (!existsSync(f)) throw new Error(`No such image: ${f}`);
|
|
78
|
+
if (track && !existsSync(track)) throw new Error(`No such track: ${track}`);
|
|
79
|
+
|
|
80
|
+
// Each card overlaps the one before it by the fade, so the reel is shorter
|
|
81
|
+
// than the sum of its holds. Getting this wrong is what makes a reel end on
|
|
82
|
+
// a frozen frame.
|
|
83
|
+
const totalMs = images.length * holdMs - (images.length - 1) * xfadeMs;
|
|
84
|
+
const total = totalMs / 1000;
|
|
85
|
+
|
|
86
|
+
// **15-17 seconds.** The one number worth being opinionated about, because it
|
|
87
|
+
// is the only one with a measurement behind it: a batch cut to 15.5s drew
|
|
88
|
+
// 113-162 views against 6-22 for the same account's 29-32s cuts. Said once,
|
|
89
|
+
// as advice, and then the render proceeds - it is the customer's reel.
|
|
90
|
+
const advice = [];
|
|
91
|
+
if (total > 20) advice.push(`${total.toFixed(1)}s is long for a reel. 15-17s is the only length with a number behind it; try --hold ${Math.floor((17000 + (images.length - 1) * xfadeMs) / images.length / 100) * 100}.`);
|
|
92
|
+
if (total < 8) advice.push(`${total.toFixed(1)}s is very short. Under about 8s there is no watch time to earn.`);
|
|
93
|
+
|
|
94
|
+
const parts = images.map((_, i) => imageChain(i, holdMs, push));
|
|
95
|
+
|
|
96
|
+
// xfade chains pairwise, and each offset is measured from the start of the
|
|
97
|
+
// CHAIN so far, not from the clip - the classic way to get a reel that
|
|
98
|
+
// dissolves correctly for two cards and then drifts.
|
|
99
|
+
let last = "v0";
|
|
100
|
+
for (let i = 1; i < images.length; i++) {
|
|
101
|
+
const offset = (holdMs * i - xfadeMs * i) / 1000;
|
|
102
|
+
const label = i === images.length - 1 ? "vout" : `x${i}`;
|
|
103
|
+
parts.push(`[${last}][v${i}]xfade=transition=fade:duration=${(xfadeMs / 1000).toFixed(3)}:offset=${offset.toFixed(3)}[${label}]`);
|
|
104
|
+
last = label;
|
|
105
|
+
}
|
|
106
|
+
if (images.length === 1) parts.push(`[v0]copy[vout]`);
|
|
107
|
+
|
|
108
|
+
const args = ["-y"];
|
|
109
|
+
for (const f of images) args.push("-loop", "1", "-t", (holdMs / 1000).toFixed(3), "-i", f);
|
|
110
|
+
|
|
111
|
+
if (track) {
|
|
112
|
+
args.push("-i", track);
|
|
113
|
+
const gain = targetLufs - measureLufs(track, total);
|
|
114
|
+
parts.push(
|
|
115
|
+
`[${images.length}:a]atrim=start=0:end=${total.toFixed(3)},asetpts=PTS-STARTPTS,` +
|
|
116
|
+
`volume=${gain.toFixed(2)}dB,` +
|
|
117
|
+
// -3 dBFS, not -1.5. The AAC encode overshoots the limiter: a -1.5
|
|
118
|
+
// ceiling came back out of the mp4 at 0.0 dB with clipped samples.
|
|
119
|
+
`alimiter=limit=0.708:level=disabled,` +
|
|
120
|
+
`afade=t=in:st=0:d=0.4,afade=t=out:st=${Math.max(0, total - 1.2).toFixed(3)}:d=1.2[aout]`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
args.push("-filter_complex", parts.join(";"), "-map", "[vout]");
|
|
125
|
+
if (track) args.push("-map", "[aout]", "-c:a", "aac", "-b:a", "192k");
|
|
126
|
+
args.push(
|
|
127
|
+
"-t", total.toFixed(3),
|
|
128
|
+
"-r", String(FPS),
|
|
129
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", String(crf),
|
|
130
|
+
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
|
131
|
+
out,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const r = spawnSync("ffmpeg", ["-loglevel", "error", ...args], { encoding: "utf8" });
|
|
135
|
+
if (r.status !== 0) throw new Error(`ffmpeg failed:\n${(r.stderr ?? "").split("\n").slice(-6).join("\n")}`);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
out,
|
|
139
|
+
durationMs: totalMs,
|
|
140
|
+
sizeBytes: statSync(out).size,
|
|
141
|
+
advice,
|
|
142
|
+
// Printed by the caller. This is the whole difference between a recipe and
|
|
143
|
+
// a framework: what it did is a command you can take and change.
|
|
144
|
+
command: `ffmpeg ${args.map((a) => (/[\s;'"\[\]]/.test(a) ? `'${a}'` : a)).join(" ")}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Types for the dependency-free publishing projection shared by CLI and API. */
|
|
2
|
+
export interface PostProjectionInput {
|
|
3
|
+
internalName?: string | null;
|
|
4
|
+
title?: string | null;
|
|
5
|
+
captionInstagram?: string | null;
|
|
6
|
+
captionTiktok?: string | null;
|
|
7
|
+
instagramLocationId?: string | null;
|
|
8
|
+
platforms?: readonly string[];
|
|
9
|
+
pinterestOptions?: { boardId?: string; title?: string; description?: string; link?: string; altText?: string };
|
|
10
|
+
options?: {
|
|
11
|
+
youtube?: { title?: string };
|
|
12
|
+
pinterest?: { boardId?: string; title?: string; description?: string; link?: string; altText?: string };
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PlatformProjection {
|
|
17
|
+
platform: string;
|
|
18
|
+
title: string | null;
|
|
19
|
+
body: string;
|
|
20
|
+
problems: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function projectPost(post: PostProjectionInput): PlatformProjection[];
|
|
24
|
+
export function looksLikeSlug(value: string): boolean;
|
|
25
|
+
export function internalTitleProblem(value: string): string | null;
|
|
26
|
+
export function projectYoutubeTitle(input?: { title?: string | null; caption?: string | null; options?: { title?: string } }): { title: string; problems: string[] };
|
|
27
|
+
export function youtubeTitle(raw: string, options?: { shortsTag?: boolean }): string;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// What each platform will actually publish, computed in one place.
|
|
2
|
+
//
|
|
3
|
+
// **Plain .mjs with no imports, because `sprid` ships with zero dependencies.**
|
|
4
|
+
// These rules have to be readable by the CLI (which has no build step and must
|
|
5
|
+
// stay dependency-free) and by the publish adapters on the server. Anything
|
|
6
|
+
// that lived only in the server's shared package would drag an image pipeline,
|
|
7
|
+
// an ORM and a storage SDK into a command line; anything that lived only in the
|
|
8
|
+
// CLI would be a second copy of the rules the publisher actually runs. So this
|
|
9
|
+
// file is the source, and the server re-exports it.
|
|
10
|
+
//
|
|
11
|
+
// **One post is several posts, and every platform reads a different field.**
|
|
12
|
+
// Instagram takes the caption and a cover; TikTok takes the caption AND the
|
|
13
|
+
// post's title as its in-app title; YouTube takes the title for the Short and
|
|
14
|
+
// the caption for the description. Validate the fields each destination reads.
|
|
15
|
+
//
|
|
16
|
+
// This is the answer to "what goes out, exactly". It is deliberately a pure
|
|
17
|
+
// function of a post so it can serve an endpoint, a CLI gate and an editor
|
|
18
|
+
// preview from the same rules - a second implementation anywhere else drifts
|
|
19
|
+
// from the adapters and ends up agreeing with the bug.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
/** TikTok truncates its in-app title at 90 UTF-16 chars. */
|
|
23
|
+
const TIKTOK_TITLE_MAX = 90;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Does this string look like an identifier rather than something written?
|
|
27
|
+
*
|
|
28
|
+
* Three or more lowercase hyphen-joined segments with no spaces are treated
|
|
29
|
+
* as an identifier. Supply a display title or omit this check when that spelling is intentional.
|
|
30
|
+
*/
|
|
31
|
+
export function looksLikeSlug(s) {
|
|
32
|
+
return /^[a-z0-9]+(-[a-z0-9]+){2,}$/.test(s.trim());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Production labels and source annotations must never become public titles. */
|
|
36
|
+
export function internalTitleProblem(raw) {
|
|
37
|
+
const title = raw.trim().split("\n")[0].trim();
|
|
38
|
+
if (/^(?:reel(?:\s+r\d+(?:-[a-z]+)?)?|scripts?|signs|bingo)\s*:/i.test(title) ||
|
|
39
|
+
/^reel\s+r\d+(?:-[a-z]+)?(?:\s|$)/i.test(title) ||
|
|
40
|
+
/\((?:guide|guides|voc|gsc|scripts?|bingo|relatability|from\s+\d+|\d+\s+re-cut)\b/i.test(title) ||
|
|
41
|
+
/^\[asset bake\]/i.test(title)) {
|
|
42
|
+
return 'YouTube title contains an internal production label or source note. Set a viewer-facing post title or options.youtube.title.';
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resolve the exact upload title, including an explicit destination override. */
|
|
48
|
+
export function projectYoutubeTitle({ title, caption, options } = {}) {
|
|
49
|
+
const source = options?.title?.trim() || title?.trim() || caption?.trim() || "";
|
|
50
|
+
const problem = internalTitleProblem(source);
|
|
51
|
+
return {
|
|
52
|
+
title: youtubeTitle(source),
|
|
53
|
+
problems: [
|
|
54
|
+
...(source ? [] : ["YouTube Shorts requires a title or a caption to use as one"]),
|
|
55
|
+
...(problem ? [problem] : []),
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function projectPost(post) {
|
|
61
|
+
const igCaption = (post.captionInstagram ?? "").trim();
|
|
62
|
+
const ttCaption = (post.captionTiktok ?? "").trim() || igCaption;
|
|
63
|
+
const title = (post.title ?? "").trim();
|
|
64
|
+
const platforms = post.platforms ?? ["instagram", "tiktok", "youtube"];
|
|
65
|
+
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const platform of platforms) {
|
|
68
|
+
if (platform === "instagram") {
|
|
69
|
+
out.push({
|
|
70
|
+
platform,
|
|
71
|
+
title: null, // Instagram has no title concept and ignores one.
|
|
72
|
+
body: igCaption,
|
|
73
|
+
problems: igCaption ? [] : ["no caption: the post publishes bare"],
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (platform === "tiktok") {
|
|
78
|
+
const shown = title ? title.slice(0, TIKTOK_TITLE_MAX) : ttCaption.slice(0, TIKTOK_TITLE_MAX);
|
|
79
|
+
out.push({
|
|
80
|
+
platform,
|
|
81
|
+
title: shown || null,
|
|
82
|
+
body: ttCaption,
|
|
83
|
+
problems: [
|
|
84
|
+
...(ttCaption ? [] : ["no caption: the post publishes bare"]),
|
|
85
|
+
...(title && looksLikeSlug(title) ? [`the in-app title is a slug: "${title}"`] : []),
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (platform === "youtube") {
|
|
91
|
+
const projected = projectYoutubeTitle({ title, caption: igCaption, options: post.options?.youtube });
|
|
92
|
+
out.push({
|
|
93
|
+
platform,
|
|
94
|
+
title: projected.title,
|
|
95
|
+
body: igCaption,
|
|
96
|
+
problems: [
|
|
97
|
+
...projected.problems,
|
|
98
|
+
...(looksLikeSlug(projected.title) ? [`the Short's title is a slug: "${projected.title}"`] : []),
|
|
99
|
+
// A caption whose first line does not fit is cut, which is legal and
|
|
100
|
+
// reads worse. Say so where it can still be fixed.
|
|
101
|
+
...(!post.options?.youtube?.title?.trim() && !title && igCaption && igCaption.split("\n")[0].trim().length > 100
|
|
102
|
+
? ["the caption's first line is longer than a Short's title, so it is cut"]
|
|
103
|
+
: []),
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (platform === "pinterest") {
|
|
109
|
+
const pin = post.options?.pinterest ?? post.pinterestOptions ?? {};
|
|
110
|
+
const link = typeof pin.link === "string" ? pin.link.trim() : "";
|
|
111
|
+
let validLink = false;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(link);
|
|
114
|
+
validLink = parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
115
|
+
} catch {}
|
|
116
|
+
out.push({
|
|
117
|
+
platform,
|
|
118
|
+
title: typeof pin.title === "string" && pin.title.trim() ? pin.title.trim() : null,
|
|
119
|
+
body: typeof pin.description === "string" ? pin.description.trim() : "",
|
|
120
|
+
problems: [
|
|
121
|
+
...(pin.boardId ? [] : ["choose a Pinterest board"]),
|
|
122
|
+
...(validLink ? [] : ["add an absolute http or https destination link"]),
|
|
123
|
+
...((pin.title?.length ?? 0) <= 100 ? [] : ["Pinterest title exceeds 100 characters"]),
|
|
124
|
+
...((pin.description?.length ?? 0) <= 800 ? [] : ["Pinterest description exceeds 800 characters"]),
|
|
125
|
+
...((pin.altText?.length ?? 0) <= 500 ? [] : ["Pinterest alt text exceeds 500 characters"]),
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// Everything else publishes the caption and nothing else we model yet.
|
|
131
|
+
out.push({ platform, title: title || null, body: igCaption, problems: [] });
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── the YouTube title, moved here from the adapter ────────────────────────
|
|
137
|
+
|
|
138
|
+
/** YouTube rejects a snippet.title over 100 UTF-16 chars with a 400. */
|
|
139
|
+
const YT_TITLE_MAX = 100;
|
|
140
|
+
const SHORTS_TAG = " #Shorts";
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The title YouTube will accept.
|
|
144
|
+
*
|
|
145
|
+
* The optional #Shorts tag is budgeted before truncation. Without it, the
|
|
146
|
+
* entire character allowance is available to the title.
|
|
147
|
+
*/
|
|
148
|
+
export function youtubeTitle(raw, { shortsTag = false } = {}) {
|
|
149
|
+
const trimmed = raw.trim();
|
|
150
|
+
if (trimmed.includes("#Shorts")) return trimmed.slice(0, YT_TITLE_MAX);
|
|
151
|
+
const room = shortsTag ? YT_TITLE_MAX - SHORTS_TAG.length : YT_TITLE_MAX;
|
|
152
|
+
const tag = shortsTag ? SHORTS_TAG : "";
|
|
153
|
+
|
|
154
|
+
// Prefer a complete first line when it fits the title budget.
|
|
155
|
+
const firstLine = trimmed.split("\n")[0].trim();
|
|
156
|
+
if (firstLine && firstLine.length <= room) return `${firstLine}${tag}`;
|
|
157
|
+
|
|
158
|
+
// Still too long, so it has to be cut - but cut at a word. A title ending
|
|
159
|
+
// "...for" reads as broken; one ending "...the example" reads as a title.
|
|
160
|
+
const cut = trimmed.slice(0, room);
|
|
161
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
162
|
+
const body = lastSpace > room * 0.6 ? cut.slice(0, lastSpace) : cut;
|
|
163
|
+
return `${body.trimEnd().replace(/[,;:\-]$/, "")}${tag}`;
|
|
164
|
+
}
|