@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
package/src/post/cli.mjs
ADDED
|
@@ -0,0 +1,2285 @@
|
|
|
1
|
+
// sprid post — the local half of Sprid.
|
|
2
|
+
//
|
|
3
|
+
// **It never renders anything.** How a file was made is the consuming repo's
|
|
4
|
+
// business; this package's whole subject is a finished local file becoming a
|
|
5
|
+
// Sprid draft, reproducibly and with the checks. That boundary is what lets it
|
|
6
|
+
// serve a repo whose reels come out of a bespoke pipeline and one whose reels
|
|
7
|
+
// come out of After Effects.
|
|
8
|
+
//
|
|
9
|
+
// **It does not need an agent.** Everything goes through the REST API with a
|
|
10
|
+
// `sprd_` personal access token, so a CI job can run it. The MCP server is the
|
|
11
|
+
// agent-facing convenience over the same endpoints, not the only door.
|
|
12
|
+
//
|
|
13
|
+
// **A post here is a reel OR a carousel**, and the difference is one field.
|
|
14
|
+
// Everything the package does - measure, gate, preview, upload, draft - is the
|
|
15
|
+
// same shape for a set of images as for one mp4, and the two kinds differ only
|
|
16
|
+
// in what "the media" is and what can be true of it. Naming the whole thing
|
|
17
|
+
// after reels was a bet that the pixels are always video, which they are not.
|
|
18
|
+
//
|
|
19
|
+
// The registry lives in the CONSUMING repo and is committed:
|
|
20
|
+
//
|
|
21
|
+
// <registry>/posts/<slug>.json the record (legacy: reels/, still read)
|
|
22
|
+
// <registry>/captions/<slug>.txt the caption, verbatim
|
|
23
|
+
//
|
|
24
|
+
// No media in git. The manifest carries the sha256, so a file that no longer
|
|
25
|
+
// matches its record is detectable without keeping the file, and the specs in
|
|
26
|
+
// the consuming repo reproduce it.
|
|
27
|
+
|
|
28
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import {
|
|
31
|
+
copyFileSync,
|
|
32
|
+
existsSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
readdirSync,
|
|
36
|
+
rmSync,
|
|
37
|
+
statSync,
|
|
38
|
+
symlinkSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
} from "node:fs";
|
|
41
|
+
import { watch } from "node:fs";
|
|
42
|
+
import { createServer } from "node:http";
|
|
43
|
+
import { previewHandler } from "./preview-server.mjs";
|
|
44
|
+
import { projectPost } from "./rules/platform-rules.mjs";
|
|
45
|
+
import { homedir, tmpdir } from "node:os";
|
|
46
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
47
|
+
import { pathToFileURL } from "node:url";
|
|
48
|
+
|
|
49
|
+
// Resolve both the token and its service URL from the same credentials.
|
|
50
|
+
import { resolveAuth, DEFAULT_API_URL } from "../creds.mjs";
|
|
51
|
+
import { assertCredentialDestination } from '../endpoint.mjs';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The API these verbs talk to, from the one login.
|
|
55
|
+
*
|
|
56
|
+
* **Lazy and guarded, and both matter.** `resolveAuth` throws when nobody is
|
|
57
|
+
* logged in, and `doctor` exists precisely to be run in that state; and reading
|
|
58
|
+
* it at module load would freeze the answer before `sprid.config` or an env var
|
|
59
|
+
* could be considered. Taking only the token from the login and defaulting the
|
|
60
|
+
* URL to production can select the wrong service, so both halves come from
|
|
61
|
+
* the same place or neither does.
|
|
62
|
+
*/
|
|
63
|
+
function defaultApi() {
|
|
64
|
+
if (process.env.SPRID_URL) return process.env.SPRID_URL;
|
|
65
|
+
try {
|
|
66
|
+
return resolveAuth().apiUrl || DEFAULT_API_URL;
|
|
67
|
+
} catch {
|
|
68
|
+
return DEFAULT_API_URL;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── plumbing ────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const C = {
|
|
75
|
+
dim: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
76
|
+
bold: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
77
|
+
ok: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
78
|
+
bad: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
79
|
+
warn: (s) => `\x1b[33m${s}\x1b[0m`,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function flag(argv, name) {
|
|
83
|
+
const args = argv.slice(0, argv.includes("--") ? argv.indexOf("--") : argv.length);
|
|
84
|
+
const inline = args.find(a => a.startsWith(`--${name}=`));
|
|
85
|
+
if (inline) return inline.slice(name.length + 3);
|
|
86
|
+
const i = args.indexOf(`--${name}`);
|
|
87
|
+
return i >= 0 && !args[i + 1]?.startsWith("--") ? args[i + 1] : undefined;
|
|
88
|
+
}
|
|
89
|
+
const has = (argv, name) => argv.slice(0, argv.includes("--") ? argv.indexOf("--") : argv.length).includes(`--${name}`);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The one bare word: a slug.
|
|
93
|
+
*
|
|
94
|
+
* **A flag's value is not a positional argument**, and reading the first
|
|
95
|
+
* non-`--` token as one made `register --built-with "…"` try to register a
|
|
96
|
+
* reel called "ffmpeg fixture". Every flag that takes a value is named here.
|
|
97
|
+
*/
|
|
98
|
+
const VALUE_FLAGS = new Set(["out", "lane", "built-with", "port", "serve", "kind", "platforms"]);
|
|
99
|
+
function positional(argv) {
|
|
100
|
+
for (let i = 0; i < argv.length; i++) {
|
|
101
|
+
const a = argv[i];
|
|
102
|
+
if (a === "--") return argv[i + 1];
|
|
103
|
+
if (a.startsWith("--")) {
|
|
104
|
+
if (VALUE_FLAGS.has(a.slice(2))) i++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return a;
|
|
108
|
+
}
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `KEY=value` out of the consuming repo's `.env`, so a token need not be exported. */
|
|
113
|
+
function readDotEnv(root) {
|
|
114
|
+
const f = join(root, ".env");
|
|
115
|
+
if (!existsSync(f)) return {};
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
118
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
119
|
+
if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The token - and **which** token matters more than where it is found.
|
|
126
|
+
*
|
|
127
|
+
* A PAT is workspace-scoped, and a machine that posts for three brands holds
|
|
128
|
+
* three of them. `tokenEnv` names the one THIS repo is allowed to use, and when
|
|
129
|
+
* it is set nothing else is tried: a run that quietly fell back to the personal
|
|
130
|
+
* token would authenticate against workspaces this repo must not be able to
|
|
131
|
+
* reach, and nothing in the output would say so. Without `tokenEnv` the order
|
|
132
|
+
* is the one a person would expect.
|
|
133
|
+
*/
|
|
134
|
+
function token(cfg = {}) {
|
|
135
|
+
assertCredentialDestination(cfg.apiUrl || defaultApi(), defaultApi());
|
|
136
|
+
// A repo may pin itself to one workspace's token, and when it does nothing
|
|
137
|
+
// else is tried: a run that quietly fell back to the personal token would
|
|
138
|
+
// authenticate against workspaces this repo must not reach, and nothing in
|
|
139
|
+
// the output would say so.
|
|
140
|
+
if (cfg.tokenEnv) {
|
|
141
|
+
const env = cfg.root ? readDotEnv(cfg.root) : {};
|
|
142
|
+
const v = process.env[cfg.tokenEnv] || env[cfg.tokenEnv];
|
|
143
|
+
if (v) return v.trim();
|
|
144
|
+
throw new Error(
|
|
145
|
+
`No token. sprid.config pins this repo to \`tokenEnv: "${cfg.tokenEnv}"\`, so that is the only one used.\n` +
|
|
146
|
+
` Export it, or put it in ${join(cfg.root ?? ".", ".env")}. It must be scoped to the workspace that owns "${cfg.account}".`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const env = cfg.root ? readDotEnv(cfg.root) : {};
|
|
150
|
+
if (env.SPRID_TOKEN) return env.SPRID_TOKEN.trim();
|
|
151
|
+
return resolveAuth().token;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Config, `.ts` preferred and `.json` accepted.
|
|
156
|
+
*
|
|
157
|
+
* The `.ts` form is the one worth writing - it takes comments and a type - and
|
|
158
|
+
* it loads under bun and under node 22's type stripping. The `.json` fallback
|
|
159
|
+
* exists so a customer on an older node is not blocked by a file format.
|
|
160
|
+
*/
|
|
161
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
162
|
+
for (const name of ["sprid.config.ts", "sprid.config.mjs", "sprid.config.js"]) {
|
|
163
|
+
const p = join(cwd, name);
|
|
164
|
+
if (existsSync(p)) {
|
|
165
|
+
const mod = await import(pathToFileURL(p).href);
|
|
166
|
+
return normalizeConfig(mod.default ?? mod, cwd);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const j = join(cwd, "sprid.config.json");
|
|
170
|
+
if (existsSync(j)) return normalizeConfig(JSON.parse(readFileSync(j, "utf8")), cwd);
|
|
171
|
+
throw new Error("No sprid.config.ts (or .json) here. Run `sprid post init` first.");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const ALL_CHECKS = ["duration", "loudness", "batchSpread", "caption", "slides", "aspect", "provenance", "words", "voice", "sources", "crosspost"];
|
|
175
|
+
const LANE_KEYS = new Set([
|
|
176
|
+
"kind", "media", "slides", "caption", "spec", "build",
|
|
177
|
+
"expect", "hashtags", "captionMustContain", "captionEndsSentence", "captionEndWords", "maxDeviationDb",
|
|
178
|
+
// Where in the video the poster comes from. Frame 0 by default; a lane whose
|
|
179
|
+
// opening card is still arriving there says so. See `coverFrame`.
|
|
180
|
+
"coverAtMs",
|
|
181
|
+
// composed lanes: what Sprid is asked to render, and what may not be said
|
|
182
|
+
"format", "template", "contentType", "neverSay", "sourcesRequired", "platforms",
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* **A misspelled key used to be a silent skip**, which is the worst thing a
|
|
187
|
+
* gate can do: `lufs:` written at the top of a lane instead of inside
|
|
188
|
+
* `expect:` reports "skip: no lufs range in the lane" on every row, and a
|
|
189
|
+
* skipped check reads as a batch that was measured. Anything not recognised is
|
|
190
|
+
* named here, once, at the top of every command.
|
|
191
|
+
*/
|
|
192
|
+
export function normalizeConfig(cfg, cwd) {
|
|
193
|
+
if (!cfg?.account) throw new Error("sprid.config: `account` is required (the Sprid account slug).");
|
|
194
|
+
if (!cfg.lanes || !Object.keys(cfg.lanes).length)
|
|
195
|
+
throw new Error("sprid.config: at least one lane is required. A lane says where finished media lives.");
|
|
196
|
+
|
|
197
|
+
const complaints = [];
|
|
198
|
+
for (const [name, lane] of Object.entries(cfg.lanes)) {
|
|
199
|
+
if (lane.kind !== "composed" && !lane.media && !lane.slides)
|
|
200
|
+
throw new Error(
|
|
201
|
+
`sprid.config: lane "${name}" needs \`media\` (one file per post), \`slides\` (a deck of images), ` +
|
|
202
|
+
"or `kind: \"composed\"` (Sprid renders it; this repo holds the copy).",
|
|
203
|
+
);
|
|
204
|
+
checkedPlatforms(lane.platforms);
|
|
205
|
+
for (const k of Object.keys(lane)) if (!LANE_KEYS.has(k)) complaints.push(`lane "${name}": unknown key \`${k}\``);
|
|
206
|
+
const tpl = lane.kind === "composed" ? (lane.spec ?? "social/specs/<slug>.json") : (lane.slides ?? lane.media);
|
|
207
|
+
if (!tpl.includes("<slug>")) complaints.push(`lane "${name}": the path has no <slug>, so nothing can be discovered in it`);
|
|
208
|
+
}
|
|
209
|
+
for (const c of cfg.checks ?? []) if (!ALL_CHECKS.includes(c)) complaints.push(`unknown check "${c}"`);
|
|
210
|
+
for (const c of complaints) console.log(` ${C.warn("config")} ${c}`);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
apiUrl: cfg.apiUrl || defaultApi(),
|
|
214
|
+
account: cfg.account,
|
|
215
|
+
platforms: checkedPlatforms(cfg.platforms),
|
|
216
|
+
// The PAT is workspace-scoped; a repo that must only reach one workspace
|
|
217
|
+
// names its variable here rather than trusting the ambient one.
|
|
218
|
+
tokenEnv: cfg.tokenEnv ?? null,
|
|
219
|
+
registry: resolve(cwd, cfg.registry || "social"),
|
|
220
|
+
root: cwd,
|
|
221
|
+
lanes: cfg.lanes,
|
|
222
|
+
checks: cfg.checks || ALL_CHECKS,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function api(cfg, method, path, body, extraHeaders = {}) {
|
|
227
|
+
const url = `${cfg.apiUrl}/api${path}`;
|
|
228
|
+
const res = await fetch(url, {
|
|
229
|
+
method,
|
|
230
|
+
redirect: 'error',
|
|
231
|
+
signal: AbortSignal.timeout(30000),
|
|
232
|
+
headers: { Authorization: `Bearer ${token(cfg)}`, "Content-Type": "application/json", ...extraHeaders },
|
|
233
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
234
|
+
});
|
|
235
|
+
if (!res.ok) {
|
|
236
|
+
const text = await res.text().catch(() => "");
|
|
237
|
+
throw new Error(`${method} ${path} -> ${res.status}${text ? `\n ${text.slice(0, 400)}` : ""}`);
|
|
238
|
+
}
|
|
239
|
+
return res.status === 204 ? null : res.json();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── media measurement ───────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
function ffprobe(file, entries, streamSelect = []) {
|
|
245
|
+
return execFileSync(
|
|
246
|
+
"ffprobe",
|
|
247
|
+
["-v", "error", ...streamSelect, "-show_entries", entries, "-of", "default=nw=1:nk=1", file],
|
|
248
|
+
{ encoding: "utf8" },
|
|
249
|
+
).trim();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Loudness, integrated, from ffmpeg's own two-pass measurement.
|
|
254
|
+
*
|
|
255
|
+
* Read the SPREAD across a batch, not the value: a set of reels that are each
|
|
256
|
+
* within a decibel of each other sounds like one library, and a set that
|
|
257
|
+
* averages -14 with a 4 dB spread is audible between two posts in a row.
|
|
258
|
+
*/
|
|
259
|
+
function loudness(file) {
|
|
260
|
+
// **loudnorm prints its JSON to stderr, and execFileSync returns stdout.**
|
|
261
|
+
// Reading the return value gave null and the whole command died on it.
|
|
262
|
+
const r = spawnSync(
|
|
263
|
+
"ffmpeg",
|
|
264
|
+
["-hide_banner", "-nostats", "-i", file, "-filter:a", "loudnorm=print_format=json", "-f", "null", "-"],
|
|
265
|
+
{ encoding: "utf8" },
|
|
266
|
+
);
|
|
267
|
+
const m = `${r.stderr ?? ""}${r.stdout ?? ""}`.match(/"input_i"\s*:\s*"(-?[\d.]+)"/);
|
|
268
|
+
return m ? Number(m[1]) : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function sha256(file) {
|
|
272
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The first frame, which is what a draft shows in a feed before it plays. */
|
|
276
|
+
/**
|
|
277
|
+
* The still a feed shows before anybody presses play.
|
|
278
|
+
*
|
|
279
|
+
* **Frame 0 is the wrong frame for any reel whose opening card assembles**, and
|
|
280
|
+
* that is a measured rule rather than a preference: a hook that fades in from
|
|
281
|
+
* nothing gives a feed a cover with no words on it, reproduced on two accounts
|
|
282
|
+
* (`docs/REEL_FIRST_SECONDS.md` § The skip window). A reel that opens on a
|
|
283
|
+
* camera move has the same problem in a milder form - the cover is a crop
|
|
284
|
+
* nobody composed.
|
|
285
|
+
*
|
|
286
|
+
* So a lane says when its opening card is finished assembling, and the poster
|
|
287
|
+
* comes from there. `coverAtMs` defaults to 0, so a lane that does not animate
|
|
288
|
+
* its opener is unaffected.
|
|
289
|
+
*/
|
|
290
|
+
function coverFrame(file, out, atMs = 0) {
|
|
291
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
292
|
+
execFileSync("ffmpeg", [
|
|
293
|
+
"-loglevel", "error", "-y",
|
|
294
|
+
...(atMs > 0 ? ["-ss", (atMs / 1000).toFixed(3)] : []),
|
|
295
|
+
"-i", file, "-frames:v", "1", out,
|
|
296
|
+
]);
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ─── the registry ────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
// **`posts/` is the directory; `reels/` is read because it was the directory.**
|
|
303
|
+
// A record already committed in a consuming repo is not something to break over
|
|
304
|
+
// a rename, so both are read and a manifest is rewritten where it already
|
|
305
|
+
// lives. Anything new lands in `posts/`.
|
|
306
|
+
const POSTS_DIR = "posts";
|
|
307
|
+
const LEGACY_DIR = "reels";
|
|
308
|
+
const captionPath = (cfg, slug) => join(cfg.registry, "captions", `${slug}.txt`);
|
|
309
|
+
// A sibling .tiktok caption file supplies the TikTok override. Without it,
|
|
310
|
+
// TikTok uses the shared caption. Vocabulary can differ by platform.
|
|
311
|
+
const tiktokCaptionPath = (cfg, slug) => join(cfg.registry, "captions", `${slug}.tiktok.txt`);
|
|
312
|
+
const tiktokSibling = (file) => file.replace(/(\.[^./]+)$/, ".tiktok$1");
|
|
313
|
+
|
|
314
|
+
function manifestPath(cfg, slug) {
|
|
315
|
+
const legacy = join(cfg.registry, LEGACY_DIR, `${slug}.json`);
|
|
316
|
+
if (existsSync(legacy) && !existsSync(join(cfg.registry, POSTS_DIR, `${slug}.json`))) return legacy;
|
|
317
|
+
return join(cfg.registry, POSTS_DIR, `${slug}.json`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function readManifest(cfg, slug) {
|
|
321
|
+
const p = manifestPath(cfg, slug);
|
|
322
|
+
if (!existsSync(p)) throw new Error(`${slug} is not registered. Run \`sprid post register ${slug}\`.`);
|
|
323
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function writeManifest(cfg, m) {
|
|
327
|
+
const p = manifestPath(cfg, m.slug);
|
|
328
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
329
|
+
writeFileSync(p, JSON.stringify(m, null, 2) + "\n");
|
|
330
|
+
return p;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function allManifests(cfg) {
|
|
334
|
+
const seen = new Map();
|
|
335
|
+
for (const d of [LEGACY_DIR, POSTS_DIR]) {
|
|
336
|
+
const dir = join(cfg.registry, d);
|
|
337
|
+
if (!existsSync(dir)) continue;
|
|
338
|
+
for (const f of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
|
|
339
|
+
const m = JSON.parse(readFileSync(join(dir, f), "utf8"));
|
|
340
|
+
seen.set(m.slug, m); // posts/ is read second, so it wins
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return [...seen.values()].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** `<slug>` in a lane's path template is the only substitution there is. */
|
|
347
|
+
const fill = (tpl, slug) => tpl.replaceAll("<slug>", slug);
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* What a lane produces. Inferred rather than required, because a lane that
|
|
351
|
+
* names `slides:` cannot be anything else and a config should not have to say
|
|
352
|
+
* the same thing twice.
|
|
353
|
+
*/
|
|
354
|
+
/**
|
|
355
|
+
* What a lane produces. Three answers, and only one of them owns any pixels.
|
|
356
|
+
*
|
|
357
|
+
* **`composed` is the case where Sprid renders and this repo holds the copy.**
|
|
358
|
+
* A template carousel or a text reel has no local file at all - the slides are
|
|
359
|
+
* words, the pixels are the server's templates and backdrops, and there is
|
|
360
|
+
* nothing to measure with ffmpeg. What is worth committing is the copy, the
|
|
361
|
+
* caption, the gate it passed and where each number came from; the mp4 or the
|
|
362
|
+
* PNG is downstream of that and disposable. Every other verb is unchanged,
|
|
363
|
+
* which is the point: one repo can hold a composed lane and a bespoke-render
|
|
364
|
+
* lane side by side and `status` is still one table.
|
|
365
|
+
*/
|
|
366
|
+
const kindOf = (lane) => lane.kind ?? (lane.slides ? "carousel" : "reel");
|
|
367
|
+
const isComposed = (x) => (x?.kind ?? (x?.slides ? "carousel" : "reel")) === "composed";
|
|
368
|
+
|
|
369
|
+
/** The path template a lane's media lives at, whichever kind it is. */
|
|
370
|
+
const mediaTemplate = (lane) =>
|
|
371
|
+
kindOf(lane) === "composed" ? (lane.spec ?? "social/specs/<slug>.json") : kindOf(lane) === "carousel" ? lane.slides : lane.media;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The files of one carousel, in order.
|
|
375
|
+
*
|
|
376
|
+
* **Order is the filename, sorted the way a person counts**, so `2.jpg` comes
|
|
377
|
+
* before `10.jpg`. A deck's slide order is the single most consequential thing
|
|
378
|
+
* about it and it is not worth hiding in a JSON array somewhere: name the files
|
|
379
|
+
* and the order is visible in the directory listing.
|
|
380
|
+
*/
|
|
381
|
+
function slideFiles(cfg, lane, slug) {
|
|
382
|
+
const tpl = resolve(cfg.root, fill(mediaTemplate(lane), slug));
|
|
383
|
+
const dir = dirname(tpl);
|
|
384
|
+
if (!existsSync(dir)) return [];
|
|
385
|
+
const pattern = basename(tpl);
|
|
386
|
+
const [pre, post] = pattern.includes("*") ? pattern.split("*") : [pattern, ""];
|
|
387
|
+
return readdirSync(dir)
|
|
388
|
+
.filter((f) => !f.startsWith(".") && f.startsWith(pre) && f.endsWith(post) && f.length >= pre.length + post.length)
|
|
389
|
+
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
|
390
|
+
.map((f) => join(dir, f));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** An image's pixel size, from ffprobe - already a dependency for the video. */
|
|
394
|
+
function imageSize(file) {
|
|
395
|
+
const [w, h] = ffprobe(file, "stream=width,height", ["-select_streams", "v:0"]).split("\n").map(Number);
|
|
396
|
+
return { width: w, height: h };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
|
|
400
|
+
function ratioLabel(w, h) {
|
|
401
|
+
const g = gcd(w, h) || 1;
|
|
402
|
+
return `${w / g}:${h / g}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Every slug a lane can see.
|
|
407
|
+
*
|
|
408
|
+
* Two shapes, because a reel is a file and a carousel is a directory of them:
|
|
409
|
+
* `out/<slug>.mp4` names the slug inside a filename, `decks/<slug>/*.jpg` names
|
|
410
|
+
* it as the directory, and which one a lane is, is visible in its template.
|
|
411
|
+
*/
|
|
412
|
+
export function slugsInLane(cfg, laneName) {
|
|
413
|
+
const lane = cfg.lanes[laneName];
|
|
414
|
+
const tpl = resolve(cfg.root, mediaTemplate(lane));
|
|
415
|
+
const [pre, post] = tpl.split("<slug>");
|
|
416
|
+
|
|
417
|
+
if (post.startsWith("/")) {
|
|
418
|
+
const dir = pre.replace(/\/$/, "");
|
|
419
|
+
if (!existsSync(dir)) return [];
|
|
420
|
+
return readdirSync(dir)
|
|
421
|
+
.filter((f) => !f.startsWith(".") && statSync(join(dir, f)).isDirectory())
|
|
422
|
+
.filter((slug) => slideFiles(cfg, lane, slug).length > 0)
|
|
423
|
+
.sort();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const dir = dirname(tpl);
|
|
427
|
+
if (!existsSync(dir)) return [];
|
|
428
|
+
const [fpre, fpost] = basename(tpl).split("<slug>");
|
|
429
|
+
return readdirSync(dir)
|
|
430
|
+
.filter((f) => f.startsWith(fpre) && f.endsWith(fpost) && f.length > fpre.length + fpost.length)
|
|
431
|
+
.map((f) => f.slice(fpre.length, f.length - fpost.length))
|
|
432
|
+
.sort();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function laneOf(cfg, slug) {
|
|
436
|
+
for (const name of Object.keys(cfg.lanes)) {
|
|
437
|
+
const lane = cfg.lanes[name];
|
|
438
|
+
if (kindOf(lane) === "carousel") {
|
|
439
|
+
if (slideFiles(cfg, lane, slug).length) return name;
|
|
440
|
+
} else if (existsSync(resolve(cfg.root, fill(lane.media, slug)))) return name;
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ─── commands ────────────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
const STARTER_OWN = `// sprid post. \`sprid post status\` to see where everything stands.
|
|
448
|
+
//
|
|
449
|
+
// A LANE is where one kind of finished media lives, and there are two kinds of
|
|
450
|
+
// post: a REEL is one video file, a CAROUSEL is a directory of images. The
|
|
451
|
+
// package ships no lanes, because what a lane is depends on how you make
|
|
452
|
+
// things.
|
|
453
|
+
export default {
|
|
454
|
+
account: "ACCOUNT_SLUG",
|
|
455
|
+
registry: "social/", // manifests + captions, committed to this repo
|
|
456
|
+
lanes: {
|
|
457
|
+
films: {
|
|
458
|
+
media: "path/to/output/<slug>.mp4",
|
|
459
|
+
caption: "path/to/output/<slug>.caption.txt",
|
|
460
|
+
// Optional, and worth writing: the command that makes one. It is what
|
|
461
|
+
// \`build\` runs, and what the record remembers so a re-render is the
|
|
462
|
+
// same render.
|
|
463
|
+
build: "your-renderer --slug <slug> --out <out>",
|
|
464
|
+
// What \`check\` enforces. Ranges are inclusive.
|
|
465
|
+
expect: { durationMs: [5000, 90000], lufs: [-16, -12] },
|
|
466
|
+
},
|
|
467
|
+
decks: {
|
|
468
|
+
// A carousel: the slug is the directory, the slides are its files, and
|
|
469
|
+
// they are ordered by filename - so name them 01, 02, 03.
|
|
470
|
+
slides: "path/to/decks/<slug>/*.jpg",
|
|
471
|
+
caption: "path/to/decks/<slug>/caption.txt",
|
|
472
|
+
expect: { slides: [3, 20], aspect: "4:5", minWidth: 1080 },
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
// Drop any you do not want. \`batchSpread\` is measured across a lane rather
|
|
476
|
+
// than per file, which is the only way it means anything; \`aspect\` refuses a
|
|
477
|
+
// deck whose slides are not all the same shape, because Instagram crops every
|
|
478
|
+
// later slide to the first one's.
|
|
479
|
+
checks: ["duration", "loudness", "batchSpread", "caption", "slides", "aspect", "provenance"],
|
|
480
|
+
};
|
|
481
|
+
`;
|
|
482
|
+
|
|
483
|
+
const STARTER_STILLS = `// sprid post, on the built-in recipe.
|
|
484
|
+
//
|
|
485
|
+
// One spec per reel in social/specs/, pictures and a track, and one command:
|
|
486
|
+
// sprid post new my-first-reel -> edit the spec -> sprid post build
|
|
487
|
+
export default {
|
|
488
|
+
account: "ACCOUNT_SLUG",
|
|
489
|
+
registry: "social/",
|
|
490
|
+
lanes: {
|
|
491
|
+
reels: {
|
|
492
|
+
build: { recipe: "stills", track: "assets/audio/bed.mp3" },
|
|
493
|
+
spec: "social/specs/<slug>.json",
|
|
494
|
+
media: "tmp/reels/<slug>.mp4",
|
|
495
|
+
caption: "tmp/reels/<slug>.caption.txt",
|
|
496
|
+
// Example limits. Set these for your format and audio delivery requirements.
|
|
497
|
+
expect: { durationMs: [8_000, 20_000], lufs: [-16, -12] },
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
checks: ["duration", "loudness", "batchSpread", "caption"],
|
|
501
|
+
};
|
|
502
|
+
`;
|
|
503
|
+
|
|
504
|
+
const STARTER_COMPOSED = `// sprid post, composed: Sprid renders the pixels, this repo holds the copy.
|
|
505
|
+
//
|
|
506
|
+
// One spec per post in social/specs/, then:
|
|
507
|
+
// sprid post new my-first-post -> edit the spec -> register -> check -> draft
|
|
508
|
+
export default {
|
|
509
|
+
account: "ACCOUNT_SLUG",
|
|
510
|
+
// A PAT is workspace-scoped and this machine may hold several. Name the one
|
|
511
|
+
// this repo is allowed to use and nothing else is tried.
|
|
512
|
+
tokenEnv: "SPRID_TOKEN",
|
|
513
|
+
registry: "social/",
|
|
514
|
+
lanes: {
|
|
515
|
+
carousels: {
|
|
516
|
+
kind: "composed",
|
|
517
|
+
spec: "social/specs/<slug>.json",
|
|
518
|
+
// Optional: the Sprid format a post is created from, and the template
|
|
519
|
+
// every card renders through.
|
|
520
|
+
// format: <format id from this account>,
|
|
521
|
+
// template: <template id from this account>,
|
|
522
|
+
expect: { slides: [5, 9], words: [0, 30] },
|
|
523
|
+
hashtags: [3, 6],
|
|
524
|
+
// The phrases this account has decided it does not say. The mechanical
|
|
525
|
+
// half of a voice; everything else still needs a reader.
|
|
526
|
+
neverSay: [],
|
|
527
|
+
// Every card stating a number names the file it came from, and the check
|
|
528
|
+
// opens it. Turn this on for anything where a wrong figure is the one
|
|
529
|
+
// unforgivable error.
|
|
530
|
+
sourcesRequired: false,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
checks: ["slides", "words", "voice", "sources", "caption"],
|
|
534
|
+
};
|
|
535
|
+
`;
|
|
536
|
+
|
|
537
|
+
async function cmdInit(argv) {
|
|
538
|
+
const kind = flag(argv, "kind");
|
|
539
|
+
if (!kind) {
|
|
540
|
+
console.log(`
|
|
541
|
+
${C.bold("What are your posts made of?")}
|
|
542
|
+
|
|
543
|
+
${C.bold("--kind text")} words on a background, a template, a hook and a payoff
|
|
544
|
+
${C.dim("-> render in the app repo, then let Sprid check and upload")}
|
|
545
|
+
${C.dim(" the finished mp4. Cloud reel rendering is paused.")}
|
|
546
|
+
|
|
547
|
+
${C.bold("--kind stills")} photographs, screenshots, generated images
|
|
548
|
+
${C.dim("-> the built-in recipe. A spec per reel, one command,")}
|
|
549
|
+
${C.dim(" and it prints the ffmpeg it ran so you can take it over.")}
|
|
550
|
+
|
|
551
|
+
${C.bold("--kind own")} you already render your own mp4s
|
|
552
|
+
${C.dim("-> sprid post registers, checks and pushes them.")}
|
|
553
|
+
${C.dim(" Point a lane's build: at your existing command.")}
|
|
554
|
+
|
|
555
|
+
${C.dim("Pick one and run it again, e.g.")} sprid post init --kind stills
|
|
556
|
+
`);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
if (kind === "text") {
|
|
560
|
+
const p = join(process.cwd(), "sprid.config.ts");
|
|
561
|
+
if (existsSync(p) && !has(argv, "force")) throw new Error(`${p} already exists. Pass --force to overwrite.`);
|
|
562
|
+
writeFileSync(p, STARTER_OWN);
|
|
563
|
+
console.log(`\n wrote ${C.bold("sprid.config.ts")}`);
|
|
564
|
+
console.log(` ${C.dim("point the lane's build command at the app's text renderer, then:")} sprid post build\n`);
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (kind === "composed") {
|
|
568
|
+
throw new Error("Cloud reel composition is paused. Use --kind text with the app's local renderer.");
|
|
569
|
+
}
|
|
570
|
+
const p = join(process.cwd(), "sprid.config.ts");
|
|
571
|
+
if (existsSync(p) && !has(argv, "force")) throw new Error(`${p} already exists. Pass --force to overwrite.`);
|
|
572
|
+
writeFileSync(p, kind === "stills" ? STARTER_STILLS : STARTER_OWN);
|
|
573
|
+
console.log(`\n wrote ${C.bold("sprid.config.ts")}`);
|
|
574
|
+
console.log(
|
|
575
|
+
kind === "stills"
|
|
576
|
+
? ` ${C.dim("set the account slug, then:")} sprid post new my-first-reel\n`
|
|
577
|
+
: ` ${C.dim("set the account slug and point build: at your renderer, then:")} sprid post build\n`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The step that was missing from the front of this pipeline.
|
|
583
|
+
*
|
|
584
|
+
* **`register` used to assume a finished file had appeared by magic**, which is
|
|
585
|
+
* fine for a repo that already has a renderer and is the entire problem for one
|
|
586
|
+
* that does not. A lane now says how its media is MADE, and there are exactly
|
|
587
|
+
* two answers:
|
|
588
|
+
*
|
|
589
|
+
* build: "bun run scripts/make-reel.ts --spec <spec> --out <out>"
|
|
590
|
+
* build: { recipe: "stills", track: "assets/bed.mp3" }
|
|
591
|
+
*
|
|
592
|
+
* The first runs your own renderer and this package stays out of it. The second
|
|
593
|
+
* runs the one built-in, so a repo with pictures and no pipeline is producing
|
|
594
|
+
* something on day one. Both land in the same `media` path and everything
|
|
595
|
+
* downstream - register, check, push, draft - cannot tell them apart, which is
|
|
596
|
+
* the point: a customer can start on the recipe and grow into their own
|
|
597
|
+
* renderer without changing anything else.
|
|
598
|
+
*/
|
|
599
|
+
async function cmdBuild(cfg, argv) {
|
|
600
|
+
const only = positional(argv);
|
|
601
|
+
const onlyLane = flag(argv, "lane");
|
|
602
|
+
const targets = [];
|
|
603
|
+
for (const laneName of Object.keys(cfg.lanes)) {
|
|
604
|
+
if (onlyLane && laneName !== onlyLane) continue;
|
|
605
|
+
const lane = cfg.lanes[laneName];
|
|
606
|
+
if (!lane.build) continue;
|
|
607
|
+
// **A named slug belongs to ONE lane.** Without this a repo with two build
|
|
608
|
+
// lanes - a screen recipe and a stills one - runs the named slug through
|
|
609
|
+
// both, and the second fails on a spec it was never given.
|
|
610
|
+
const slugs = only
|
|
611
|
+
? (typeof lane.build === "string" || existsSync(specPath(cfg, laneName, only)) ? [only] : [])
|
|
612
|
+
: specSlugs(cfg, laneName);
|
|
613
|
+
for (const slug of slugs) targets.push([laneName, slug]);
|
|
614
|
+
}
|
|
615
|
+
if (only && targets.length > 1 && !onlyLane)
|
|
616
|
+
throw new Error(
|
|
617
|
+
`"${only}" has a spec in ${targets.length} lanes (${targets.map((t) => t[0]).join(", ")}). ` +
|
|
618
|
+
"Say which: `build " + only + " --lane " + targets[0][0] + "`, or give the lanes separate spec directories.",
|
|
619
|
+
);
|
|
620
|
+
if (!targets.length)
|
|
621
|
+
throw new Error(
|
|
622
|
+
Object.values(cfg.lanes).every((l) => isComposed(l))
|
|
623
|
+
? "Nothing to build, and nothing to build it with: every lane here is `composed`, which means Sprid renders it.\n" +
|
|
624
|
+
" Write the copy in a spec, then `register` -> `check` -> `draft`."
|
|
625
|
+
: "Nothing to build. A lane needs a `build:` - either a command of your own, or { recipe: \"stills\" }.\n" +
|
|
626
|
+
" With a recipe, each reel is a spec file; `sprid post new <slug>` writes one.",
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
for (const [laneName, slug] of targets) {
|
|
630
|
+
const lane = cfg.lanes[laneName];
|
|
631
|
+
const out = resolve(cfg.root, fill(lane.media, slug));
|
|
632
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
633
|
+
|
|
634
|
+
if (typeof lane.build === "string") {
|
|
635
|
+
const cmd = lane.build.replaceAll("<slug>", slug).replaceAll("<out>", out).replaceAll("<spec>", specPath(cfg, laneName, slug));
|
|
636
|
+
console.log(` ${C.dim(cmd)}`);
|
|
637
|
+
const r = spawnSync(cmd, { shell: true, stdio: "inherit", cwd: cfg.root });
|
|
638
|
+
if (r.status !== 0) throw new Error(`build failed for ${slug}`);
|
|
639
|
+
} else if (lane.build.recipe === "screen") {
|
|
640
|
+
// The screen recipe drives a simulator, so it is async and it needs the
|
|
641
|
+
// repo root, a scratch directory and the spec's own script file. It
|
|
642
|
+
// writes the caption exactly like the stills recipe does below.
|
|
643
|
+
const { buildScreen } = await import("./recipes/screen.mjs");
|
|
644
|
+
const spec = readSpec(cfg, laneName, slug);
|
|
645
|
+
const scriptPath = resolve(cfg.root, spec.script ?? `${basename(cfg.registry)}/scripts/${slug}.json`);
|
|
646
|
+
if (!existsSync(scriptPath))
|
|
647
|
+
throw new Error(`${slug}: no gesture script at ${scriptPath}. A screen spec names one with \`script\`; ` +
|
|
648
|
+
`one script serves every locale.`);
|
|
649
|
+
const r = await buildScreen({
|
|
650
|
+
spec: { ...lane.build, ...spec },
|
|
651
|
+
script: JSON.parse(readFileSync(scriptPath, "utf8")),
|
|
652
|
+
root: cfg.root,
|
|
653
|
+
out,
|
|
654
|
+
// **Scratch goes OUTSIDE the repo unless a lane says otherwise.** The
|
|
655
|
+
// recording writes a settle screenshot twice a second into this
|
|
656
|
+
// directory; with it inside the project, Metro's file watcher sees
|
|
657
|
+
// every one of them, sends the app a Fast Refresh, and RN drops its
|
|
658
|
+
// blue "Refreshing…" banner over the top of the frame. It landed on
|
|
659
|
+
// three of eight sampled frames of the first reel built here.
|
|
660
|
+
recut: has(argv, "recut"),
|
|
661
|
+
tmp: lane.build.tmp
|
|
662
|
+
? resolve(cfg.root, lane.build.tmp, slug)
|
|
663
|
+
: join(tmpdir(), "sprid", basename(cfg.root), slug),
|
|
664
|
+
});
|
|
665
|
+
for (const a of r.advice) console.log(` ${C.warn("note")} ${a}`);
|
|
666
|
+
// The graph is 8KB of overlay expressions; printing it drowns the log it
|
|
667
|
+
// is supposed to accompany. It still gets written down - a recipe that
|
|
668
|
+
// hides the command it ran is a framework.
|
|
669
|
+
console.log(` ${C.dim(`ffmpeg command: ${r.commandPath}`)}`);
|
|
670
|
+
if (spec.caption && lane.caption) {
|
|
671
|
+
const cp = resolve(cfg.root, fill(lane.caption, slug));
|
|
672
|
+
mkdirSync(dirname(cp), { recursive: true });
|
|
673
|
+
writeFileSync(cp, spec.caption.trim() + "\n");
|
|
674
|
+
}
|
|
675
|
+
} else {
|
|
676
|
+
const { buildStills } = await import("./recipes/stills.mjs");
|
|
677
|
+
const spec = readSpec(cfg, laneName, slug);
|
|
678
|
+
const r = buildStills({
|
|
679
|
+
images: (spec.images ?? []).map((i) => resolve(cfg.root, i)),
|
|
680
|
+
track: spec.track ? resolve(cfg.root, spec.track) : lane.build.track ? resolve(cfg.root, lane.build.track) : undefined,
|
|
681
|
+
out,
|
|
682
|
+
...(lane.build.holdMs ? { holdMs: lane.build.holdMs } : {}),
|
|
683
|
+
...(spec.holdMs ? { holdMs: spec.holdMs } : {}),
|
|
684
|
+
});
|
|
685
|
+
for (const a of r.advice) console.log(` ${C.warn("note")} ${a}`);
|
|
686
|
+
console.log(` ${C.dim(r.command)}`);
|
|
687
|
+
// A spec's caption is written out where `register` will find it, so a
|
|
688
|
+
// recipe lane needs no separate caption step.
|
|
689
|
+
if (spec.caption && lane.caption) {
|
|
690
|
+
const cp = resolve(cfg.root, fill(lane.caption, slug));
|
|
691
|
+
mkdirSync(dirname(cp), { recursive: true });
|
|
692
|
+
writeFileSync(cp, spec.caption.trim() + "\n");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
// The record is now wrong about the bytes and right about nothing until
|
|
696
|
+
// `register` runs, but the command is knowable here and nowhere else.
|
|
697
|
+
if (existsSync(manifestPath(cfg, slug))) {
|
|
698
|
+
const m = readManifest(cfg, slug);
|
|
699
|
+
m.build = buildRecord(cfg, laneName, slug, undefined, {});
|
|
700
|
+
writeManifest(cfg, m);
|
|
701
|
+
}
|
|
702
|
+
console.log(` ${C.ok("built")} ${slug.padEnd(40)} ${out.replace(cfg.root + "/", "")}\n`);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const specPath = (cfg, laneName, slug) =>
|
|
707
|
+
resolve(cfg.root, fill(cfg.lanes[laneName].spec ?? `${basename(cfg.registry)}/specs/<slug>.json`, slug));
|
|
708
|
+
|
|
709
|
+
function readSpec(cfg, laneName, slug) {
|
|
710
|
+
const p = specPath(cfg, laneName, slug);
|
|
711
|
+
if (!existsSync(p)) throw new Error(`No spec at ${p}. Write one, or \`sprid post new ${slug}\`.`);
|
|
712
|
+
const spec = JSON.parse(readFileSync(p, "utf8"));
|
|
713
|
+
// **Hashtags are part of the caption.** Sprid publishes one string per
|
|
714
|
+
// platform and has no tag field to send them to, so a spec carrying them
|
|
715
|
+
// separately is named rather than quietly dropped - a silently ignored key
|
|
716
|
+
// is how a post ships without its tags and nobody finds out until it is up.
|
|
717
|
+
if (spec.hashtags !== undefined)
|
|
718
|
+
throw new Error(`${p}: \`hashtags\` is not a field. Put the tags at the end of \`caption\`.`);
|
|
719
|
+
return spec;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Every slug a recipe lane can build, from its spec directory. */
|
|
723
|
+
function specSlugs(cfg, laneName) {
|
|
724
|
+
const tpl = specPath(cfg, laneName, " ");
|
|
725
|
+
const dir = dirname(tpl);
|
|
726
|
+
if (!existsSync(dir)) return [];
|
|
727
|
+
const [pre, post] = basename(tpl).split(" ");
|
|
728
|
+
return readdirSync(dir)
|
|
729
|
+
.filter((f) => f.startsWith(pre) && f.endsWith(post) && f.length > pre.length + post.length)
|
|
730
|
+
.map((f) => f.slice(pre.length, f.length - post.length))
|
|
731
|
+
.sort();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* A spec is the durable artifact and the file a person edits.
|
|
736
|
+
*
|
|
737
|
+
* Carried over from the pipeline this package was drawn from, where it is the
|
|
738
|
+
* single best structural decision in the whole thing: the mp4 is disposable and
|
|
739
|
+
* regenerates, the spec is what is reviewed, committed and argued about.
|
|
740
|
+
*/
|
|
741
|
+
async function cmdNew(cfg, argv) {
|
|
742
|
+
const slug = positional(argv);
|
|
743
|
+
if (!slug) throw new Error("Give it a slug: `sprid post new my-first-reel`.");
|
|
744
|
+
// Whichever lane keeps specs: a composed one, or a recipe one. Those are the
|
|
745
|
+
// two kinds of lane a spec means anything to.
|
|
746
|
+
const laneName =
|
|
747
|
+
flag(argv, "lane") ??
|
|
748
|
+
Object.keys(cfg.lanes).find((l) => isComposed(cfg.lanes[l]) || typeof cfg.lanes[l].build === "object") ??
|
|
749
|
+
Object.keys(cfg.lanes)[0];
|
|
750
|
+
const p = specPath(cfg, laneName, slug);
|
|
751
|
+
if (existsSync(p) && !has(argv, "force")) throw new Error(`${p} already exists.`);
|
|
752
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
753
|
+
// The spec's shape is the lane's kind: a composed post is its copy, a recipe
|
|
754
|
+
// reel is its pictures. Scaffolding the wrong one is a file somebody has to
|
|
755
|
+
// delete before they can start.
|
|
756
|
+
const starter = isComposed(cfg.lanes[laneName])
|
|
757
|
+
? {
|
|
758
|
+
slug,
|
|
759
|
+
slides: [
|
|
760
|
+
{ role: "hook", text: "The line that makes someone stop." },
|
|
761
|
+
{ role: "body", text: "One thought.", source: "docs/where-this-came-from.md" },
|
|
762
|
+
{ role: "cta", text: "The line they screenshot." },
|
|
763
|
+
],
|
|
764
|
+
caption: "What this post says. Written here, committed, reviewed in a diff.\n\n#one #two #three",
|
|
765
|
+
}
|
|
766
|
+
: {
|
|
767
|
+
slug,
|
|
768
|
+
images: ["path/to/first.jpg", "path/to/second.jpg", "path/to/third.jpg"],
|
|
769
|
+
track: cfg.lanes[laneName].build?.track ?? "path/to/bed.mp3",
|
|
770
|
+
holdMs: 3000,
|
|
771
|
+
caption: "What this post says. Written here, committed, reviewed in a diff.",
|
|
772
|
+
};
|
|
773
|
+
writeFileSync(p, JSON.stringify(starter, null, 2) + "\n");
|
|
774
|
+
console.log(`\n wrote ${C.bold(p.replace(cfg.root + "/", ""))}`);
|
|
775
|
+
console.log(` ${C.dim("point `images` at real files, then:")} sprid post build ${slug}\n`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** Everything that is true of one finished mp4. */
|
|
779
|
+
function measureReel(cfg, lane, slug) {
|
|
780
|
+
const media = resolve(cfg.root, fill(cfg.lanes[lane].media, slug));
|
|
781
|
+
const durationMs = Math.round(Number(ffprobe(media, "format=duration")) * 1000);
|
|
782
|
+
const { width, height } = imageSize(media);
|
|
783
|
+
const sizeBytes = statSync(media).size;
|
|
784
|
+
const lufs = loudness(media);
|
|
785
|
+
const digest = sha256(media);
|
|
786
|
+
return {
|
|
787
|
+
digest,
|
|
788
|
+
record: { media: { sha256: digest, durationMs, width, height, sizeBytes, lufs } },
|
|
789
|
+
summary:
|
|
790
|
+
`${(durationMs / 1000).toFixed(1)}s ${(sizeBytes / 1e6).toFixed(1)} MB ` +
|
|
791
|
+
`${lufs === null ? C.dim("no audio") : `${lufs.toFixed(1)} LUFS`}`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Everything that is true of one carousel.
|
|
797
|
+
*
|
|
798
|
+
* **The digest is over the slides AND their order**, since two decks holding
|
|
799
|
+
* the same pictures in a different order are two different posts, and the order
|
|
800
|
+
* is the half a reviewer argues about.
|
|
801
|
+
*/
|
|
802
|
+
function measureCarousel(cfg, lane, slug) {
|
|
803
|
+
const files = slideFiles(cfg, cfg.lanes[lane], slug);
|
|
804
|
+
if (!files.length) throw new Error(`No slides for ${slug} at ${fill(mediaTemplate(cfg.lanes[lane]), slug)}`);
|
|
805
|
+
const slides = files.map((f) => {
|
|
806
|
+
const { width, height } = imageSize(f);
|
|
807
|
+
return {
|
|
808
|
+
file: f.replace(cfg.root + "/", ""),
|
|
809
|
+
sha256: sha256(f),
|
|
810
|
+
width,
|
|
811
|
+
height,
|
|
812
|
+
sizeBytes: statSync(f).size,
|
|
813
|
+
aspect: ratioLabel(width, height),
|
|
814
|
+
};
|
|
815
|
+
});
|
|
816
|
+
const digest = createHash("sha256").update(slides.map((s) => s.sha256).join("\n")).digest("hex");
|
|
817
|
+
const bytes = slides.reduce((n, s) => n + s.sizeBytes, 0);
|
|
818
|
+
const ratios = [...new Set(slides.map((s) => s.aspect))];
|
|
819
|
+
return {
|
|
820
|
+
digest,
|
|
821
|
+
record: { media: { sha256: digest, slideCount: slides.length, sizeBytes: bytes, aspect: ratios[0] }, slides },
|
|
822
|
+
summary:
|
|
823
|
+
`${slides.length} slides ${(bytes / 1e6).toFixed(1)} MB ` +
|
|
824
|
+
(ratios.length === 1 ? ratios[0] : C.warn(`mixed ratios: ${ratios.join(", ")}`)),
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Everything that is true of one composed post.
|
|
830
|
+
*
|
|
831
|
+
* **The spec IS the artifact here**, not a recipe for one, so the digest is
|
|
832
|
+
* over the spec and the words are measured off it. Word counts are kept per
|
|
833
|
+
* slide rather than totalled: a deck whose average is fine and whose third card
|
|
834
|
+
* runs to sixty words is exactly the deck the total hides.
|
|
835
|
+
*/
|
|
836
|
+
function measureComposed(cfg, lane, slug) {
|
|
837
|
+
const spec = readSpec(cfg, lane, slug);
|
|
838
|
+
const slides = spec.slides ?? [];
|
|
839
|
+
if (!slides.length) throw new Error(`${slug}: the spec has no \`slides\`. A composed post is its copy.`);
|
|
840
|
+
const words = slides.map((sl) => wordsOf(sl));
|
|
841
|
+
const body = JSON.stringify(slides);
|
|
842
|
+
return {
|
|
843
|
+
digest: createHash("sha256").update(body).digest("hex"),
|
|
844
|
+
record: {
|
|
845
|
+
media: {
|
|
846
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
847
|
+
slideCount: slides.length,
|
|
848
|
+
sizeBytes: Buffer.byteLength(body),
|
|
849
|
+
words,
|
|
850
|
+
},
|
|
851
|
+
copy: slides,
|
|
852
|
+
},
|
|
853
|
+
summary: `${slides.length} cards ${Math.max(...words)} words at the longest`,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** Every word a card shows, headline and body and bullets alike. */
|
|
858
|
+
const wordsOf = (sl) =>
|
|
859
|
+
[sl.text, sl.subtitle, ...(sl.bullets ?? [])]
|
|
860
|
+
.filter(Boolean)
|
|
861
|
+
.join(" ")
|
|
862
|
+
.split(/\s+/)
|
|
863
|
+
.filter(Boolean).length;
|
|
864
|
+
|
|
865
|
+
const digestOf = (m) => m?.media?.sha256 ?? null;
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* How this file was made.
|
|
869
|
+
*
|
|
870
|
+
* **A record that measures the bytes and forgets the command is half a record.**
|
|
871
|
+
* On 2026-09-03 a reel registered at 17.2s came back from a re-render at 13.8s
|
|
872
|
+
* without `--map` and 20.6s with it, and nothing in the registry could say
|
|
873
|
+
* which invocation the 17.2s belonged to - the render line was living in a
|
|
874
|
+
* deck's notes. The command is the difference between a spec that reproduces a
|
|
875
|
+
* post and a spec that reproduces something like it.
|
|
876
|
+
*
|
|
877
|
+
* Three sources, in the order you would trust them: what the operator says they
|
|
878
|
+
* just ran, what the lane says it runs, and what the record already held.
|
|
879
|
+
*/
|
|
880
|
+
function buildRecord(cfg, lane, slug, builtWith, prev) {
|
|
881
|
+
if (builtWith) return { command: builtWith, source: "operator", at: new Date().toISOString() };
|
|
882
|
+
const b = cfg.lanes[lane]?.build;
|
|
883
|
+
if (typeof b === "string")
|
|
884
|
+
return { command: fill(b, slug).replaceAll("<out>", fill(mediaTemplate(cfg.lanes[lane]), slug)), source: "lane", at: new Date().toISOString() };
|
|
885
|
+
if (b && typeof b === "object")
|
|
886
|
+
return { command: `sprid post build ${slug}`, source: "recipe", at: new Date().toISOString() };
|
|
887
|
+
return prev?.build ?? null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function cmdRegister(cfg, argv) {
|
|
891
|
+
const only = positional(argv);
|
|
892
|
+
const targets = [];
|
|
893
|
+
if (only) {
|
|
894
|
+
const lane = flag(argv, "lane") ?? laneOf(cfg, only);
|
|
895
|
+
if (!lane) throw new Error(`No lane in sprid.config has media for "${only}".`);
|
|
896
|
+
targets.push([lane, only]);
|
|
897
|
+
} else {
|
|
898
|
+
for (const lane of Object.keys(cfg.lanes)) for (const slug of slugsInLane(cfg, lane)) targets.push([lane, slug]);
|
|
899
|
+
}
|
|
900
|
+
if (!targets.length) throw new Error("Nothing to register. Check the `media` paths in your lanes.");
|
|
901
|
+
|
|
902
|
+
for (const [lane, slug] of targets) {
|
|
903
|
+
const kind = kindOf(cfg.lanes[lane]);
|
|
904
|
+
const measured =
|
|
905
|
+
kind === "composed"
|
|
906
|
+
? measureComposed(cfg, lane, slug)
|
|
907
|
+
: kind === "carousel"
|
|
908
|
+
? measureCarousel(cfg, lane, slug)
|
|
909
|
+
: measureReel(cfg, lane, slug);
|
|
910
|
+
|
|
911
|
+
// The caption is copied INTO the registry rather than pointed at. It is the
|
|
912
|
+
// most reviewable thing the pipeline produces and it usually lives in a
|
|
913
|
+
// scratch directory, where nobody can read it in a diff.
|
|
914
|
+
// A composed post keeps its caption in the spec, next to the copy it
|
|
915
|
+
// belongs to; a rendered one keeps it wherever the renderer dropped it.
|
|
916
|
+
// Either way it is COPIED into the registry rather than pointed at - it is
|
|
917
|
+
// the most reviewable thing any of these pipelines produce and it usually
|
|
918
|
+
// lives in a scratch directory nobody reads a diff of.
|
|
919
|
+
const capSrc = cfg.lanes[lane].caption ? resolve(cfg.root, fill(cfg.lanes[lane].caption, slug)) : null;
|
|
920
|
+
let caption = null;
|
|
921
|
+
if (kind === "composed") {
|
|
922
|
+
const spec = readSpec(cfg, lane, slug);
|
|
923
|
+
caption = spec.caption ? String(spec.caption).trim() : null;
|
|
924
|
+
} else if (capSrc && existsSync(capSrc)) {
|
|
925
|
+
caption = readFileSync(capSrc, "utf8").trim();
|
|
926
|
+
}
|
|
927
|
+
if (caption) {
|
|
928
|
+
mkdirSync(dirname(captionPath(cfg, slug)), { recursive: true });
|
|
929
|
+
writeFileSync(captionPath(cfg, slug), caption + "\n");
|
|
930
|
+
}
|
|
931
|
+
const ttSrc = capSrc ? tiktokSibling(capSrc) : null;
|
|
932
|
+
const captionTiktok = ttSrc && existsSync(ttSrc) ? readFileSync(ttSrc, "utf8").trim() : null;
|
|
933
|
+
if (captionTiktok) writeFileSync(tiktokCaptionPath(cfg, slug), captionTiktok + "\n");
|
|
934
|
+
else if (existsSync(tiktokCaptionPath(cfg, slug))) rmSync(tiktokCaptionPath(cfg, slug));
|
|
935
|
+
|
|
936
|
+
const prev = existsSync(manifestPath(cfg, slug)) ? readManifest(cfg, slug) : {};
|
|
937
|
+
const authored = existsSync(specPath(cfg, lane, slug)) ? readSpec(cfg, lane, slug) : {};
|
|
938
|
+
if (authored.remotePostId !== undefined && (!Number.isInteger(authored.remotePostId) || authored.remotePostId <= 0))
|
|
939
|
+
throw new Error(`${slug}: remotePostId must be a positive integer`);
|
|
940
|
+
const m = {
|
|
941
|
+
slug,
|
|
942
|
+
lane,
|
|
943
|
+
kind,
|
|
944
|
+
build: buildRecord(cfg, lane, slug, flag(argv, "built-with"), prev),
|
|
945
|
+
source: fill(mediaTemplate(cfg.lanes[lane]), slug),
|
|
946
|
+
...measured.record,
|
|
947
|
+
post: {
|
|
948
|
+
platforms: checkedPlatforms(flag(argv, "platforms")?.split(",") ??
|
|
949
|
+
authored.platforms ??
|
|
950
|
+
prev.post?.platforms),
|
|
951
|
+
title: authored.title ?? prev.post?.title,
|
|
952
|
+
captionFile: caption ? `${basename(cfg.registry)}/captions/${slug}.txt` : null,
|
|
953
|
+
captionSha: caption ? createHash("sha256").update(caption + (captionTiktok ?? "")).digest("hex").slice(0, 12) : null,
|
|
954
|
+
captionTiktokFile: captionTiktok ? `${basename(cfg.registry)}/captions/${slug}.tiktok.txt` : null,
|
|
955
|
+
instagramLocationId: prev.post?.instagramLocationId ?? null,
|
|
956
|
+
},
|
|
957
|
+
// A record is only true of the bytes it measured, so a changed digest
|
|
958
|
+
// drops the check result with it.
|
|
959
|
+
checks: measured.digest === digestOf(prev) ? (prev.checks ?? null) : null,
|
|
960
|
+
sprid: {
|
|
961
|
+
account: cfg.account,
|
|
962
|
+
videoId: null,
|
|
963
|
+
imageIds: null,
|
|
964
|
+
postId: authored.remotePostId ?? null,
|
|
965
|
+
status: "none",
|
|
966
|
+
captionShaAtDraft: null,
|
|
967
|
+
...(prev.sprid ?? {}),
|
|
968
|
+
...(authored.remotePostId !== undefined ? { postId: authored.remotePostId } : {}),
|
|
969
|
+
},
|
|
970
|
+
registeredAt: new Date().toISOString(),
|
|
971
|
+
};
|
|
972
|
+
writeManifest(cfg, m);
|
|
973
|
+
console.log(` ${C.ok("registered")} ${slug.padEnd(40)} ${measured.summary}`);
|
|
974
|
+
}
|
|
975
|
+
console.log(`\n ${targets.length} registered in ${C.bold(cfg.registry)}\n`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* The gate.
|
|
980
|
+
*
|
|
981
|
+
* Every check is a refusal to publish something a person would have caught by
|
|
982
|
+
* watching the file, and the reason each one exists is written next to it. A
|
|
983
|
+
* check that cannot run reports that it could not, never a pass.
|
|
984
|
+
*/
|
|
985
|
+
/** Validate the platform projections before a local post is uploaded. */
|
|
986
|
+
export function checkedPlatforms(platforms) {
|
|
987
|
+
if (platforms === undefined) return undefined;
|
|
988
|
+
const supported = ["instagram", "tiktok", "youtube", "facebook", "linkedin", "pinterest", "x"];
|
|
989
|
+
if (!Array.isArray(platforms) || !platforms.length || platforms.some(p => !supported.includes(p)))
|
|
990
|
+
throw new Error(`platforms must be a non-empty array from: ${supported.join(", ")}`);
|
|
991
|
+
return [...new Set(platforms)];
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export function crosspostCheck(m, caption, slug, platforms) {
|
|
995
|
+
const title = m.post?.title ?? postTitle(caption, slug);
|
|
996
|
+
const selected = checkedPlatforms(platforms ?? m.post?.platforms);
|
|
997
|
+
const modeled = selected?.filter(p => ["instagram", "tiktok", "youtube", "pinterest"].includes(p));
|
|
998
|
+
const unmodeled = selected?.filter(p => !["instagram", "tiktok", "youtube", "pinterest"].includes(p)) ?? [];
|
|
999
|
+
const problems = projectPost({ title, captionInstagram: caption,
|
|
1000
|
+
pinterestOptions: m.post?.pinterestOptions,
|
|
1001
|
+
platforms: modeled })
|
|
1002
|
+
.flatMap((p) => p.problems.map((x) => `${p.platform}: ${x}`));
|
|
1003
|
+
return problems.length ? `fail: ${problems.join("; ")}`
|
|
1004
|
+
: unmodeled.length ? `skip: local projection rules unavailable for ${unmodeled.join(", ")}; use --deep for booked posts` : "pass";
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
export function runChecks(cfg, m, batch) {
|
|
1009
|
+
const lane = cfg.lanes[m.lane] ?? {};
|
|
1010
|
+
const want = lane.expect ?? {};
|
|
1011
|
+
const out = {};
|
|
1012
|
+
const enabled = new Set(cfg.checks);
|
|
1013
|
+
const kind = m.kind ?? kindOf(lane);
|
|
1014
|
+
const carousel = kind === "carousel";
|
|
1015
|
+
const composed = kind === "composed";
|
|
1016
|
+
|
|
1017
|
+
// **A check that does not apply says so; it never passes.** Duration and
|
|
1018
|
+
// loudness are properties of a video, and a carousel that reported four
|
|
1019
|
+
// green checks it never ran would be worth less than no check at all. A
|
|
1020
|
+
// composed post has no pixels here at all, so it skips the aspect check too.
|
|
1021
|
+
if (carousel || composed) {
|
|
1022
|
+
for (const name of ["duration", "loudness", "batchSpread"]) {
|
|
1023
|
+
if (enabled.has(name)) out[name] = `skip: not applicable to a ${kind === "composed" ? "composed post" : "carousel"}`;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (composed && enabled.has("aspect")) out.aspect = "skip: Sprid renders this one, so the shape is the template's";
|
|
1027
|
+
|
|
1028
|
+
// How many slides. Instagram takes 20, TikTok 35, and a deck of two is a
|
|
1029
|
+
// post that did not get made.
|
|
1030
|
+
if ((carousel || composed) && enabled.has("slides")) {
|
|
1031
|
+
const n = m.media?.slideCount ?? m.slides?.length ?? 0;
|
|
1032
|
+
const [lo, hi] = want.slides ?? [2, 20];
|
|
1033
|
+
out.slides = n >= lo && n <= hi ? "pass" : `fail: ${n} slides, wanted ${lo}-${hi}`;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// **One ratio for the whole deck.** Instagram crops every later slide to the
|
|
1037
|
+
// first one's shape, so a deck that mixes 4:5 and 1:1 publishes with the
|
|
1038
|
+
// sides cut off things nobody checked - and it looks fine in every local
|
|
1039
|
+
// preview that shows the files as they are.
|
|
1040
|
+
if (carousel && enabled.has("aspect")) {
|
|
1041
|
+
const ratios = [...new Set((m.slides ?? []).map((s) => s.aspect))];
|
|
1042
|
+
const small = (m.slides ?? []).filter((s) => s.width < (want.minWidth ?? 1080));
|
|
1043
|
+
const problems = [];
|
|
1044
|
+
if (ratios.length > 1) problems.push(`mixed ratios: ${ratios.join(", ")}`);
|
|
1045
|
+
else if (want.aspect && ratios[0] !== want.aspect) problems.push(`${ratios[0]}, wanted ${want.aspect}`);
|
|
1046
|
+
if (small.length) problems.push(`${small.length} slide(s) under ${want.minWidth ?? 1080}px wide`);
|
|
1047
|
+
out.aspect = problems.length ? `fail: ${problems.join("; ")}` : "pass";
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (!carousel && enabled.has("duration") && want.durationMs) {
|
|
1051
|
+
const [lo, hi] = want.durationMs;
|
|
1052
|
+
out.duration =
|
|
1053
|
+
m.media.durationMs >= lo && m.media.durationMs <= hi
|
|
1054
|
+
? "pass"
|
|
1055
|
+
: `fail: ${(m.media.durationMs / 1000).toFixed(1)}s outside ${(lo / 1000).toFixed(1)}-${(hi / 1000).toFixed(1)}s`;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (!carousel && enabled.has("loudness")) {
|
|
1059
|
+
if (m.media.lufs === null) out.loudness = "skip: no audio stream";
|
|
1060
|
+
else if (want.lufs) {
|
|
1061
|
+
const [lo, hi] = want.lufs;
|
|
1062
|
+
out.loudness = m.media.lufs >= lo && m.media.lufs <= hi ? "pass" : `fail: ${m.media.lufs.toFixed(1)} LUFS outside ${lo}..${hi}`;
|
|
1063
|
+
} else out.loudness = "skip: no lufs range in the lane";
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// Compare loudness within a lane and fail only the outliers.
|
|
1067
|
+
if (!carousel && enabled.has("batchSpread")) {
|
|
1068
|
+
const peers = batch.filter((x) => x.lane === m.lane && typeof x.media?.lufs === "number");
|
|
1069
|
+
const vals = peers.map((x) => x.media.lufs);
|
|
1070
|
+
if (vals.length < 3) out.batchSpread = "skip: fewer than three measured files in this lane";
|
|
1071
|
+
else {
|
|
1072
|
+
const sorted = [...vals].sort((a, b) => a - b);
|
|
1073
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
1074
|
+
const tol = lane.maxDeviationDb ?? 1.2;
|
|
1075
|
+
const off = Math.abs(m.media.lufs - median);
|
|
1076
|
+
out.batchSpread =
|
|
1077
|
+
off <= tol
|
|
1078
|
+
? "pass"
|
|
1079
|
+
: `fail: ${m.media.lufs.toFixed(1)} LUFS is ${off.toFixed(1)} dB off the ${m.lane} median (${median.toFixed(1)}), ` +
|
|
1080
|
+
`lane spread ${(Math.max(...vals) - Math.min(...vals)).toFixed(1)} dB`;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Not a pass/fail on the media, a statement about the record: can this file
|
|
1085
|
+
// be made again, exactly? A batch where most rows skip this is a batch whose
|
|
1086
|
+
// provenance lives in somebody's terminal history.
|
|
1087
|
+
if (enabled.has("provenance")) {
|
|
1088
|
+
out.provenance = m.build?.command
|
|
1089
|
+
? "pass"
|
|
1090
|
+
: "skip: no build command recorded - pass --built-with \"<command>\" to register, or give the lane a build:";
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// **The word budget is per CARD, never the total.** A deck averaging twenty
|
|
1094
|
+
// words with one card at sixty is exactly the deck an average hides, and the
|
|
1095
|
+
// long card is the one a reader stops on.
|
|
1096
|
+
if (composed && enabled.has("words") && want.words) {
|
|
1097
|
+
const [lo, hi] = want.words;
|
|
1098
|
+
const over = (m.media?.words ?? []).map((n, i) => [i + 1, n]).filter(([, n]) => n < lo || n > hi);
|
|
1099
|
+
out.words = over.length
|
|
1100
|
+
? `fail: card ${over.map(([i, n]) => `${i} (${n} words)`).join(", ")} outside ${lo}-${hi}`
|
|
1101
|
+
: "pass";
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Account-specific phrase restrictions belong in the lane configuration.
|
|
1105
|
+
if (composed && enabled.has("voice")) {
|
|
1106
|
+
const banned = lane.neverSay ?? [];
|
|
1107
|
+
if (!banned.length) out.voice = "skip: no `neverSay` list in the lane";
|
|
1108
|
+
else {
|
|
1109
|
+
const text = (m.copy ?? []).map((sl) => [sl.text, sl.subtitle, ...(sl.bullets ?? [])].filter(Boolean).join(" ")).join("\n");
|
|
1110
|
+
const hits = banned.filter((phrase) => text.toLowerCase().includes(String(phrase).toLowerCase()));
|
|
1111
|
+
const problems = hits.map((h) => `"${h}"`);
|
|
1112
|
+
out.voice = problems.length ? `fail: ${problems.join(", ")}` : "pass";
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// **Where the number came from, as a file that exists.** For a YMYL account
|
|
1117
|
+
// this is the whole gate: a slide claiming a cost, a threshold or a rule
|
|
1118
|
+
// names the fact-checked file it came from, and the check opens it. A source
|
|
1119
|
+
// that has been renamed or deleted is the failure worth catching, because the
|
|
1120
|
+
// slide keeps reading true long after the file behind it stopped being.
|
|
1121
|
+
if (composed && enabled.has("sources") && lane.sourcesRequired) {
|
|
1122
|
+
const missing = [];
|
|
1123
|
+
const unsourced = [];
|
|
1124
|
+
(m.copy ?? []).forEach((sl, i) => {
|
|
1125
|
+
const src = sl.source ? [sl.source] : (sl.sources ?? []);
|
|
1126
|
+
const numeric = /\d/.test([sl.text, sl.subtitle, ...(sl.bullets ?? [])].filter(Boolean).join(" "));
|
|
1127
|
+
if (!src.length) {
|
|
1128
|
+
if (numeric) unsourced.push(i + 1);
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
for (const one of src) if (!existsSync(resolve(cfg.root, String(one).split("#")[0]))) missing.push(`${i + 1}: ${one}`);
|
|
1132
|
+
});
|
|
1133
|
+
const problems = [
|
|
1134
|
+
...(missing.length ? [`source file gone - ${missing.join(", ")}`] : []),
|
|
1135
|
+
...(unsourced.length ? [`card ${unsourced.join(", ")} states a number with no source`] : []),
|
|
1136
|
+
];
|
|
1137
|
+
out.sources = problems.length ? `fail: ${problems.join("; ")}` : "pass";
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (enabled.has("crosspost")) {
|
|
1141
|
+
const cp = captionPath(cfg, m.slug);
|
|
1142
|
+
// The local half. `check` runs offline and before anything is pushed, so it
|
|
1143
|
+
// asserts what the registry can see. The authoritative half lives in Sprid
|
|
1144
|
+
// and is asked for by `check --deep`, which projects the actual post - see
|
|
1145
|
+
// `deepCrosspost`.
|
|
1146
|
+
out.crosspost = existsSync(cp)
|
|
1147
|
+
? crosspostCheck(m, readFileSync(cp, "utf8"), m.slug, cfg.checkPlatforms ?? m.post?.platforms ?? lane.platforms ?? cfg.platforms)
|
|
1148
|
+
: "fail: no caption in the registry";
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (enabled.has("caption")) {
|
|
1152
|
+
const p = captionPath(cfg, m.slug);
|
|
1153
|
+
if (!existsSync(p)) out.caption = "fail: no caption in the registry";
|
|
1154
|
+
else {
|
|
1155
|
+
const text = readFileSync(p, "utf8");
|
|
1156
|
+
const problems = [];
|
|
1157
|
+
for (const phrase of lane.neverSay ?? []) {
|
|
1158
|
+
if (text.toLowerCase().includes(String(phrase).toLowerCase())) problems.push(`forbidden "${phrase}"`);
|
|
1159
|
+
}
|
|
1160
|
+
// Attribution and hashtag lines do not participate in sentence checks.
|
|
1161
|
+
const prose = text
|
|
1162
|
+
.split("\n")
|
|
1163
|
+
.filter((l) => !/^\s*#/.test(l) && !/^(Photos via|Map ©|Photo:)/.test(l.trim()))
|
|
1164
|
+
.join("\n")
|
|
1165
|
+
.trim();
|
|
1166
|
+
// Language-dependent endings are configured per lane.
|
|
1167
|
+
const tail = prose.split(/\s+/).pop()?.toLowerCase().replace(/[^\p{L}]/gu, "") ?? "";
|
|
1168
|
+
const endWords = new Set((lane.captionEndWords ?? []).map((word) => String(word).toLowerCase()));
|
|
1169
|
+
if (/,$/.test(prose)) problems.push("prose ends on a comma");
|
|
1170
|
+
else if (endWords.has(tail)) problems.push(`prose ends on "${tail}"`);
|
|
1171
|
+
else if (lane.captionEndsSentence && !/[.!?。!?:]$/.test(prose)) problems.push("prose does not end a sentence");
|
|
1172
|
+
for (const rule of lane.captionMustContain ?? []) if (!text.includes(rule)) problems.push(`missing "${rule}"`);
|
|
1173
|
+
if (lane.hashtags) {
|
|
1174
|
+
// `\w` stops at the first non-ASCII letter, so a Swedish lane counted
|
|
1175
|
+
// "#tvätt" as "#tv" and passed a caption that was short of its range.
|
|
1176
|
+
const n = (text.match(/#[\p{L}\p{M}\p{N}_]+/gu) ?? []).length;
|
|
1177
|
+
const [lo, hi] = lane.hashtags;
|
|
1178
|
+
if (n < lo || n > hi) problems.push(`${n} hashtags, wanted ${lo}-${hi}`);
|
|
1179
|
+
}
|
|
1180
|
+
out.caption = problems.length ? `fail: ${problems.join(", ")}` : "pass";
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
out.at = new Date().toISOString();
|
|
1185
|
+
return out;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* What Sprid says each platform will publish, for posts that exist there.
|
|
1190
|
+
*
|
|
1191
|
+
* **The local `crosspost` check asserts preconditions; this one reads the
|
|
1192
|
+
* answer.** A copy of the platform rules living in this package would drift
|
|
1193
|
+
* from the adapters that actually publish and end up agreeing with the bug, so
|
|
1194
|
+
* the rules stay server-side (`projectPost`, beside the adapters) and this asks
|
|
1195
|
+
* for the projection. It is `--deep` rather than the default because `check`
|
|
1196
|
+
* has to work offline and before a post exists.
|
|
1197
|
+
*
|
|
1198
|
+
*/
|
|
1199
|
+
async function deepCrosspost(cfg, manifests) {
|
|
1200
|
+
const rows = [];
|
|
1201
|
+
for (const m of manifests) {
|
|
1202
|
+
if (!m.sprid?.postId) continue;
|
|
1203
|
+
const p = await api(cfg, "GET", `/posts/${m.sprid.postId}/projection`);
|
|
1204
|
+
if (!p) continue;
|
|
1205
|
+
for (const problem of p.problems ?? []) rows.push({ slug: m.slug, ...problem });
|
|
1206
|
+
}
|
|
1207
|
+
return rows;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function cmdCheck(cfg, argv) {
|
|
1211
|
+
const only = positional(argv);
|
|
1212
|
+
cfg.checkPlatforms = checkedPlatforms(flag(argv, "platforms")?.split(","));
|
|
1213
|
+
const batch = allManifests(cfg);
|
|
1214
|
+
const hasCarousel = Object.values(cfg.lanes).some((l) => kindOf(l) === "carousel");
|
|
1215
|
+
const missingGates = ["slides", "aspect"].filter((n) => !cfg.checks.includes(n));
|
|
1216
|
+
if (hasCarousel && missingGates.length)
|
|
1217
|
+
console.log(` ${C.warn("note")} carousel lane with no ${missingGates.join("/")} check - add them to \`checks\` in sprid.config\n`);
|
|
1218
|
+
const targets = only ? [readManifest(cfg, only)] : batch;
|
|
1219
|
+
let failed = 0;
|
|
1220
|
+
for (const m of targets) {
|
|
1221
|
+
m.checks = runChecks(cfg, m, batch);
|
|
1222
|
+
writeManifest(cfg, m);
|
|
1223
|
+
const bad = Object.entries(m.checks).filter(([k, v]) => k !== "at" && String(v).startsWith("fail"));
|
|
1224
|
+
const skipped = Object.entries(m.checks).filter(([k, v]) => k !== "at" && String(v).startsWith("skip"));
|
|
1225
|
+
if (bad.length) {
|
|
1226
|
+
failed++;
|
|
1227
|
+
console.log(` ${C.bad("fail")} ${m.slug}`);
|
|
1228
|
+
for (const [k, v] of bad) console.log(` ${k}: ${v.replace(/^fail: /, "")}`);
|
|
1229
|
+
} else {
|
|
1230
|
+
console.log(` ${C.ok("pass")} ${m.slug}${skipped.length ? C.dim(` (${skipped.map(([k]) => k).join(", ")} skipped)`) : ""}`);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
console.log(`\n ${targets.length - failed}/${targets.length} pass\n`);
|
|
1234
|
+
|
|
1235
|
+
// `--deep` asks Sprid what each platform will actually publish. Off by
|
|
1236
|
+
// default because `check` must work offline and before a post exists.
|
|
1237
|
+
let deepProblems = [];
|
|
1238
|
+
if (has(argv, "deep")) {
|
|
1239
|
+
const rows = await deepCrosspost(cfg, targets);
|
|
1240
|
+
if (!rows.length) console.log(` ${C.ok("crosspost")} every booked platform publishes what it should\n`);
|
|
1241
|
+
else {
|
|
1242
|
+
deepProblems = rows;
|
|
1243
|
+
console.log(` ${C.bad(`crosspost: ${rows.length} problem(s) in Sprid`)}\n`);
|
|
1244
|
+
for (const r of rows) console.log(` ${r.slug.padEnd(42)}${r.platform.padEnd(11)}${r.message}`);
|
|
1245
|
+
console.log(`\n ${C.dim("`sprid post sync --execute` rewrites a post's fields from the registry")}\n`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return { exitCode: failed || deepProblems.length ? 1 : 0, checked: targets.length, failed, deepProblems };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const checksPass = (m) =>
|
|
1252
|
+
m.checks && Object.entries(m.checks).every(([k, v]) => k === "at" || !String(v).startsWith("fail"));
|
|
1253
|
+
|
|
1254
|
+
async function cmdPush(cfg, argv) {
|
|
1255
|
+
const only = positional(argv);
|
|
1256
|
+
const targets = (only ? [readManifest(cfg, only)] : allManifests(cfg)).filter(
|
|
1257
|
+
(m) => has(argv, "force") || (m.sprid.videoId == null && !(m.sprid.imageIds?.length > 0)),
|
|
1258
|
+
);
|
|
1259
|
+
if (!targets.length) return console.log("\n nothing to push (everything is already in Sprid)\n");
|
|
1260
|
+
|
|
1261
|
+
for (const m of targets) {
|
|
1262
|
+
if (!checksPass(m)) {
|
|
1263
|
+
console.log(` ${C.warn("skip")} ${m.slug} ${C.dim("- checks not passing, run `sprid post check`")}`);
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
// **A composed post has nothing here to upload.** Its pixels are the
|
|
1267
|
+
// server's templates; the copy travels in `draft`. Saying so beats
|
|
1268
|
+
// silently doing nothing, because "0 pushed" otherwise reads as a failure.
|
|
1269
|
+
if (isComposed(m)) {
|
|
1270
|
+
console.log(` ${C.dim("composed")} ${m.slug.padEnd(40)} ${C.dim("nothing to upload - go straight to `draft`")}`);
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
if ((m.kind ?? "reel") === "carousel") {
|
|
1274
|
+
await pushCarousel(cfg, m);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
const file = resolve(cfg.root, m.source);
|
|
1278
|
+
if (!existsSync(file)) {
|
|
1279
|
+
console.log(` ${C.bad("gone")} ${m.slug} ${C.dim(`- ${m.source} is not on disk; rebuild it`)}`);
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1282
|
+
// Guard the record against a silent rebuild: the manifest is only true of
|
|
1283
|
+
// the bytes it measured.
|
|
1284
|
+
if (sha256(file) !== m.media.sha256) {
|
|
1285
|
+
console.log(` ${C.bad("stale")} ${m.slug} ${C.dim("- the file changed since it was registered; re-register it")}`);
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const filename = `${m.slug}.mp4`;
|
|
1290
|
+
const { uploadUrl, videoId } = await api(cfg, "POST", "/videos/upload-url", {
|
|
1291
|
+
filename,
|
|
1292
|
+
contentType: "video/mp4",
|
|
1293
|
+
accountSlug: cfg.account,
|
|
1294
|
+
});
|
|
1295
|
+
const put = await fetch(uploadUrl, {
|
|
1296
|
+
method: "PUT",
|
|
1297
|
+
headers: { "Content-Type": "video/mp4" },
|
|
1298
|
+
body: readFileSync(file),
|
|
1299
|
+
});
|
|
1300
|
+
if (!put.ok) throw new Error(`PUT to storage -> ${put.status}`);
|
|
1301
|
+
|
|
1302
|
+
// **Both stills, or the grids show nothing.** Sprid's card reads the small
|
|
1303
|
+
// one and its player reads the big one, and a row that carries only a
|
|
1304
|
+
// poster used to be recorded as carrying both - so every tile asked the
|
|
1305
|
+
// CDN for a thumbnail that was never written. ffmpeg is already required
|
|
1306
|
+
// here for measuring, so the second frame costs one more invocation.
|
|
1307
|
+
const cover = coverFrame(
|
|
1308
|
+
file,
|
|
1309
|
+
join(cfg.registry, ".cache", `${m.slug}-cover.png`),
|
|
1310
|
+
cfg.lanes[m.lane]?.coverAtMs ?? 0,
|
|
1311
|
+
);
|
|
1312
|
+
const small = thumbnail(cfg, cover, `${m.slug}-cover`);
|
|
1313
|
+
await api(cfg, "POST", `/videos/${videoId}/confirm`, {
|
|
1314
|
+
durationMs: m.media.durationMs,
|
|
1315
|
+
width: m.media.width,
|
|
1316
|
+
height: m.media.height,
|
|
1317
|
+
sizeBytes: m.media.sizeBytes,
|
|
1318
|
+
posterBase64: `data:image/png;base64,${readFileSync(cover).toString("base64")}`,
|
|
1319
|
+
thumbBase64: small
|
|
1320
|
+
? `data:image/jpeg;base64,${readFileSync(small).toString("base64")}`
|
|
1321
|
+
: undefined,
|
|
1322
|
+
});
|
|
1323
|
+
rmSync(cover, { force: true });
|
|
1324
|
+
if (small) rmSync(small, { force: true });
|
|
1325
|
+
|
|
1326
|
+
// **`mediaShaAtPush` is what makes a re-render detectable**, and it is the
|
|
1327
|
+
// exact counterpart of `captionShaAtDraft`, which has existed since the
|
|
1328
|
+
// beginning. Without it the registry can say the file changed and cannot
|
|
1329
|
+
// say whether Sprid still holds the old one, so a rebuilt reel sits in a
|
|
1330
|
+
// scheduled post looking finished. Record the exact bytes uploaded.
|
|
1331
|
+
m.sprid = { ...m.sprid, account: cfg.account, videoId, status: "uploaded", mediaShaAtPush: m.media.sha256 };
|
|
1332
|
+
writeManifest(cfg, m);
|
|
1333
|
+
console.log(` ${C.ok("pushed")} ${m.slug.padEnd(40)} video ${videoId}`);
|
|
1334
|
+
}
|
|
1335
|
+
console.log();
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* The same two phases as a video, once per slide.
|
|
1340
|
+
*
|
|
1341
|
+
* **Every slide is uploaded twice, main and thumb**, because that is the shape
|
|
1342
|
+
* Sprid's image record has and the thumbnail is what its own grids read. The
|
|
1343
|
+
* small one is made with ffmpeg, which is already required here for measuring,
|
|
1344
|
+
* so the package still has no dependencies.
|
|
1345
|
+
*/
|
|
1346
|
+
async function pushCarousel(cfg, m) {
|
|
1347
|
+
const files = (m.slides ?? []).map((s) => resolve(cfg.root, s.file));
|
|
1348
|
+
const missing = files.filter((f) => !existsSync(f));
|
|
1349
|
+
if (missing.length) {
|
|
1350
|
+
console.log(` ${C.bad("gone")} ${m.slug} ${C.dim(`- ${missing.length} slide(s) not on disk; rebuild it`)}`);
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const changed = files.filter((f, i) => sha256(f) !== m.slides[i].sha256);
|
|
1354
|
+
if (changed.length) {
|
|
1355
|
+
console.log(` ${C.bad("stale")} ${m.slug} ${C.dim(`- ${changed.length} slide(s) changed since registering; re-register it`)}`);
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const imageIds = [];
|
|
1360
|
+
for (const [i, file] of files.entries()) {
|
|
1361
|
+
const ext = file.slice(file.lastIndexOf(".") + 1).toLowerCase();
|
|
1362
|
+
const type = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
|
|
1363
|
+
const res = await api(cfg, "POST", "/images/upload-url", {
|
|
1364
|
+
filename: `${m.slug}-${String(i + 1).padStart(2, "0")}.${ext}`,
|
|
1365
|
+
contentType: type,
|
|
1366
|
+
accountSlug: cfg.account,
|
|
1367
|
+
tag: i === 0 ? "hero" : "body",
|
|
1368
|
+
source: "sprid post",
|
|
1369
|
+
});
|
|
1370
|
+
// A deduped answer means Sprid already holds this file; nothing to upload.
|
|
1371
|
+
if (res.deduped) {
|
|
1372
|
+
imageIds.push(res.image.id);
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
const bytes = readFileSync(file);
|
|
1376
|
+
const thumb = thumbnail(cfg, file, `${m.slug}-${i}`);
|
|
1377
|
+
for (const [url, body] of [
|
|
1378
|
+
[res.mainUploadUrl, bytes],
|
|
1379
|
+
[res.thumbUploadUrl, thumb ? readFileSync(thumb) : bytes],
|
|
1380
|
+
]) {
|
|
1381
|
+
const put = await fetch(url, { method: "PUT", headers: { "Content-Type": type }, body });
|
|
1382
|
+
if (!put.ok) throw new Error(`PUT to storage -> ${put.status}`);
|
|
1383
|
+
}
|
|
1384
|
+
if (thumb) rmSync(thumb, { force: true });
|
|
1385
|
+
await api(cfg, "POST", `/images/${res.image.id}/confirm`, {
|
|
1386
|
+
width: m.slides[i].width,
|
|
1387
|
+
height: m.slides[i].height,
|
|
1388
|
+
sizeBytes: m.slides[i].sizeBytes,
|
|
1389
|
+
});
|
|
1390
|
+
imageIds.push(res.image.id);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
m.sprid = {
|
|
1394
|
+
...m.sprid, account: cfg.account, imageIds, status: "uploaded",
|
|
1395
|
+
mediaShaAtPush: (m.slides ?? []).map((sl) => sl.sha256).join(","),
|
|
1396
|
+
};
|
|
1397
|
+
writeManifest(cfg, m);
|
|
1398
|
+
console.log(` ${C.ok("pushed")} ${m.slug.padEnd(40)} ${imageIds.length} images`);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** A 400px-wide copy, or null if ffmpeg will not make one. */
|
|
1402
|
+
function thumbnail(cfg, file, name) {
|
|
1403
|
+
const out = join(cfg.registry, ".cache", `${name}-thumb.jpg`);
|
|
1404
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
1405
|
+
const r = spawnSync("ffmpeg", ["-loglevel", "error", "-y", "-i", file, "-vf", "scale=400:-2", out]);
|
|
1406
|
+
return r.status === 0 && existsSync(out) ? out : null;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Post, slide, video, caption. Stops at `draft` on purpose.
|
|
1411
|
+
*
|
|
1412
|
+
* Moving a post to `ready` is the last gate before something is public, and
|
|
1413
|
+
* that stays a person's decision.
|
|
1414
|
+
*/
|
|
1415
|
+
async function cmdDraft(cfg, argv) {
|
|
1416
|
+
const only = positional(argv);
|
|
1417
|
+
// A composed post never gets uploaded, so waiting for an asset id would keep
|
|
1418
|
+
// it out of every batch forever.
|
|
1419
|
+
const uploaded = (m) => isComposed(m) || m.sprid.videoId != null || (m.sprid.imageIds?.length ?? 0) > 0;
|
|
1420
|
+
const targets = (only ? [readManifest(cfg, only)] : allManifests(cfg)).filter(
|
|
1421
|
+
(m) => uploaded(m) && (has(argv, "force") || m.sprid.postId == null),
|
|
1422
|
+
);
|
|
1423
|
+
if (!targets.length) return console.log("\n nothing to draft (push first, or everything is drafted)\n");
|
|
1424
|
+
|
|
1425
|
+
for (const m of targets) {
|
|
1426
|
+
// **`push` refused a failing post and `draft` did not, which left the gate
|
|
1427
|
+
// ungated.** For a composed post there is nothing to push at all, so
|
|
1428
|
+
// `draft` was the only verb that touched Sprid and it happily drafted a
|
|
1429
|
+
// deck that had failed its own checks. Both verbs refuse now; `--force`
|
|
1430
|
+
// does not override this, because a gate you can pass with a flag is a
|
|
1431
|
+
// preference.
|
|
1432
|
+
if (!checksPass(m)) {
|
|
1433
|
+
console.log(` ${C.warn("skip")} ${m.slug} ${C.dim("- checks not passing, run `sprid post check`")}`);
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
const caption = existsSync(captionPath(cfg, m.slug))
|
|
1437
|
+
? readFileSync(captionPath(cfg, m.slug), "utf8").trim()
|
|
1438
|
+
: "";
|
|
1439
|
+
const captionTiktok = existsSync(tiktokCaptionPath(cfg, m.slug))
|
|
1440
|
+
? readFileSync(tiktokCaptionPath(cfg, m.slug), "utf8").trim()
|
|
1441
|
+
: caption;
|
|
1442
|
+
|
|
1443
|
+
// **`--force` re-syncs the post it already made; it does not make another
|
|
1444
|
+
// one.** The only reason to run `draft` twice is that something here
|
|
1445
|
+
// changed - the caption was edited after the draft went over, which
|
|
1446
|
+
// `status` reports and had no command to resolve, or the media was rebuilt
|
|
1447
|
+
// and re-pushed. Answering that with a second post leaves the first one in
|
|
1448
|
+
// the account looking equally real. A post that was deleted in Sprid is the
|
|
1449
|
+
// one case where a new one is right, and a 404 is how that is known.
|
|
1450
|
+
const existingPost = m.sprid.postId
|
|
1451
|
+
? await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch((e) => {
|
|
1452
|
+
// Only a post that is GONE justifies making another one. A network
|
|
1453
|
+
// failure that fell through to the create branch would quietly
|
|
1454
|
+
// duplicate the whole batch.
|
|
1455
|
+
if (/-> 404/.test(e.message)) return null;
|
|
1456
|
+
throw e;
|
|
1457
|
+
})
|
|
1458
|
+
: null;
|
|
1459
|
+
const post =
|
|
1460
|
+
existingPost ??
|
|
1461
|
+
(await api(cfg, "POST", `/posts?account=${encodeURIComponent(cfg.account)}`, {
|
|
1462
|
+
// Public titles come from approved copy; identifiers stay in internalName.
|
|
1463
|
+
title: m.post?.title ?? postTitle(caption, m.slug),
|
|
1464
|
+
internalName: m.post?.internalName ?? m.slug,
|
|
1465
|
+
// A composed lane may name the Sprid format its posts are made from,
|
|
1466
|
+
// and creating with it is what scaffolds the right slide roles.
|
|
1467
|
+
...(cfg.lanes[m.lane]?.format ? { formatId: cfg.lanes[m.lane].format } : {}),
|
|
1468
|
+
}));
|
|
1469
|
+
const carousel = (m.kind ?? "reel") === "carousel";
|
|
1470
|
+
const composed = isComposed(m);
|
|
1471
|
+
|
|
1472
|
+
// **A new post already has slides, and `POST /posts` does not return
|
|
1473
|
+
// them.** It scaffolds one row (or one per the format's template) and
|
|
1474
|
+
// answers with the post alone, so `post.slides?.[0]` was always undefined
|
|
1475
|
+
// and every draft this package made carried an EMPTY leading slide with
|
|
1476
|
+
// the real media behind it - a blank first frame on a reel, a blank first
|
|
1477
|
+
// card on a deck. Verified against the API on 2026-09-03: create returns
|
|
1478
|
+
// no `slides` key, `GET /posts/:id` shows exactly one row at position 0.
|
|
1479
|
+
//
|
|
1480
|
+
// So: read the seats the post came with, add the ones it is short of,
|
|
1481
|
+
// delete the ones it does not need, and fill them in order.
|
|
1482
|
+
const seats = ((existingPost ? post : await api(cfg, "GET", `/posts/${post.id}`)).slides ?? []).sort(
|
|
1483
|
+
(a, b) => a.position - b.position,
|
|
1484
|
+
);
|
|
1485
|
+
const need = composed ? (m.copy ?? []).length : carousel ? (m.sprid.imageIds ?? []).length : 1;
|
|
1486
|
+
while (seats.length < need)
|
|
1487
|
+
seats.push(await api(cfg, "POST", `/posts/${post.id}/slides`, { role: "body", text: "", subtitle: "" }));
|
|
1488
|
+
for (const spare of seats.splice(need)) await api(cfg, "DELETE", `/slides/${spare.id}`);
|
|
1489
|
+
|
|
1490
|
+
if (composed) {
|
|
1491
|
+
// **The words are the payload.** One card per spec slide, in the spec's
|
|
1492
|
+
// order, and the template if the lane names one - after which Sprid holds
|
|
1493
|
+
// everything it needs to render, and this repo still holds the copy that
|
|
1494
|
+
// was gated.
|
|
1495
|
+
const tpl = cfg.lanes[m.lane]?.template ?? null;
|
|
1496
|
+
for (const [i, seat] of seats.entries()) {
|
|
1497
|
+
const sl = m.copy[i];
|
|
1498
|
+
await api(cfg, "PATCH", `/slides/${seat.id}`, {
|
|
1499
|
+
text: sl.text ?? "",
|
|
1500
|
+
subtitle: sl.subtitle ?? "",
|
|
1501
|
+
...(sl.bullets ? { bullets: sl.bullets } : {}),
|
|
1502
|
+
role: sl.role ?? (i === 0 ? "hook" : "body"),
|
|
1503
|
+
...(sl.template || tpl ? { templateId: sl.template ?? tpl } : {}),
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
} else if (carousel) {
|
|
1507
|
+
// One slide per image, in the registry's order.
|
|
1508
|
+
const ids = m.sprid.imageIds ?? [];
|
|
1509
|
+
for (const [i, seat] of seats.entries())
|
|
1510
|
+
await api(cfg, "PATCH", `/slides/${seat.id}`, { imageId: ids[i], role: i === 0 ? "hook" : "body" });
|
|
1511
|
+
} else {
|
|
1512
|
+
// A finished reel is the whole composition. Clear every old render
|
|
1513
|
+
// instruction as it is attached, or the publisher correctly refuses to
|
|
1514
|
+
// guess whether the video or the leftover template should win.
|
|
1515
|
+
await api(cfg, "PATCH", `/slides/${seats[0].id}`, {
|
|
1516
|
+
videoId: m.sprid.videoId,
|
|
1517
|
+
durationMs: m.media.durationMs,
|
|
1518
|
+
templateId: null,
|
|
1519
|
+
beats: [],
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
// **`audio: { silent: true }` is not optional when the music is already in
|
|
1524
|
+
// the file.** A reel with no trackAssetId round-robins the account's track
|
|
1525
|
+
// library, so leaving audio unset lays a second bed over the first.
|
|
1526
|
+
const wanted = {
|
|
1527
|
+
// Reconcile the title on every draft, including previously created posts.
|
|
1528
|
+
title: m.post?.title ?? postTitle(caption, m.slug),
|
|
1529
|
+
internalName: m.post?.internalName ?? m.slug,
|
|
1530
|
+
// Sprid's two content types: a carousel is `image`, whatever it holds.
|
|
1531
|
+
// Sprid's two content types. A composed lane says which it is with
|
|
1532
|
+
// `kind: "composed"` plus its own `contentType`; a deck of images is
|
|
1533
|
+
// `image` whatever it holds.
|
|
1534
|
+
contentType: composed ? (cfg.lanes[m.lane]?.contentType ?? "image") : carousel ? "image" : "reel",
|
|
1535
|
+
// One string per platform, tags included. Sprid has no hashtags field.
|
|
1536
|
+
captionInstagram: caption,
|
|
1537
|
+
captionTiktok,
|
|
1538
|
+
...(m.post.instagramLocationId ? { instagramLocationId: m.post.instagramLocationId } : {}),
|
|
1539
|
+
...(m.post.pinterestOptions ? { pinterestOptions: m.post.pinterestOptions } : {}),
|
|
1540
|
+
// **Silence is a reel's problem only.** A carousel has no audio to
|
|
1541
|
+
// double up, and Sprid picks its own track for one if it wants.
|
|
1542
|
+
// **Silence is a rendered reel's problem only.** It says "the music is
|
|
1543
|
+
// already inside this file"; a composed reel has no file yet, so Sprid's
|
|
1544
|
+
// own track rotation is exactly what should happen to it.
|
|
1545
|
+
...(carousel || composed ? {} : { audio: { silent: true }, motion: null }),
|
|
1546
|
+
};
|
|
1547
|
+
await api(cfg, "PATCH", `/posts/${post.id}`, { ...wanted, status: "draft" });
|
|
1548
|
+
|
|
1549
|
+
// **A 200 does not prove the field landed.** The API validates with a
|
|
1550
|
+
// schema that STRIPS what it does not declare, so a field the server is
|
|
1551
|
+
// too old to know about is dropped and answered with a success. Measured
|
|
1552
|
+
// 2026-09-03 against api.sprid.studio: `audio: { silent: true }` PATCHed
|
|
1553
|
+
// cleanly, came back `null`, and the reel it drafted would have published
|
|
1554
|
+
// with a second music bed laid over its own - which is the exact failure
|
|
1555
|
+
// the field exists to prevent. The consuming repo has no way to know that
|
|
1556
|
+
// from the output, so the record is read back and compared.
|
|
1557
|
+
const dropped = verifyDraft(await api(cfg, "GET", `/posts/${post.id}`), wanted);
|
|
1558
|
+
for (const [field, why] of dropped) console.log(` ${C.bad("dropped")} ${m.slug.padEnd(40)} ${field} - ${why}`);
|
|
1559
|
+
|
|
1560
|
+
m.sprid = {
|
|
1561
|
+
...m.sprid,
|
|
1562
|
+
postId: post.id,
|
|
1563
|
+
status: "drafted",
|
|
1564
|
+
captionShaAtDraft: m.post.captionSha ?? null,
|
|
1565
|
+
// Kept, so `status` can still say it a week later and so a re-run after
|
|
1566
|
+
// the server is updated has something to compare against.
|
|
1567
|
+
dropped: dropped.length ? dropped.map(([f]) => f) : null,
|
|
1568
|
+
};
|
|
1569
|
+
writeManifest(cfg, m);
|
|
1570
|
+
console.log(
|
|
1571
|
+
` ${C.ok(existingPost ? "updated" : "drafted")} ${m.slug.padEnd(40)} post ${post.id}` +
|
|
1572
|
+
(composed ? C.dim(` ${seats.length} cards`) : carousel ? C.dim(` ${m.sprid.imageIds.length} slides`) : ""),
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
console.log(`\n ${C.dim("review in Sprid, then move each to `ready` yourself.")}\n`);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* What the server kept, against what was asked of it.
|
|
1580
|
+
*
|
|
1581
|
+
* Only fields where a silent drop changes what gets PUBLISHED - the caption,
|
|
1582
|
+
* the shape of the post, the music, the location tag. Each answer carries the
|
|
1583
|
+
* consequence rather than the diff, because "audio.silent missing" is not a
|
|
1584
|
+
* thing anybody can act on and "this reel will publish with two soundtracks"
|
|
1585
|
+
* is.
|
|
1586
|
+
*/
|
|
1587
|
+
export function verifyDraft(back, wanted) {
|
|
1588
|
+
const out = [];
|
|
1589
|
+
if (wanted.audio?.silent && back.audio?.silent !== true)
|
|
1590
|
+
out.push(["audio.silent", "this reel will publish with a rotated track OVER its own audio. Update Sprid, then `draft --force`"]);
|
|
1591
|
+
if (wanted.instagramLocationId && back.instagramLocationId !== wanted.instagramLocationId)
|
|
1592
|
+
out.push(["instagramLocationId", "the post will publish untagged"]);
|
|
1593
|
+
if (wanted.contentType && back.contentType !== wanted.contentType)
|
|
1594
|
+
out.push(["contentType", `the post is a "${back.contentType}", not a "${wanted.contentType}"`]);
|
|
1595
|
+
if ((back.captionInstagram ?? "") !== (wanted.captionInstagram ?? ""))
|
|
1596
|
+
out.push(["captionInstagram", "the caption in Sprid is not the one in the registry"]);
|
|
1597
|
+
if (wanted.title && back.title !== wanted.title)
|
|
1598
|
+
out.push(["title", "YouTube publishes this as the Short's title; Sprid is holding a different one"]);
|
|
1599
|
+
if (wanted.pinterestOptions && JSON.stringify(back.pinterestOptions ?? null) !== JSON.stringify(wanted.pinterestOptions))
|
|
1600
|
+
out.push(["pinterestOptions", "the Pin will lose its reviewed board, destination or metadata"]);
|
|
1601
|
+
return out;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* What is different here from what Sprid is holding, and the smallest set of
|
|
1606
|
+
* calls that closes the gap.
|
|
1607
|
+
*
|
|
1608
|
+
* **A post is never edited; a deck is re-rendered and the queue catches up.**
|
|
1609
|
+
* That is the whole design decision. The alternative - reaching into Sprid to
|
|
1610
|
+
* swap a file on a scheduled post - makes the queue the source of truth for
|
|
1611
|
+
* content, and then a spec and a published reel can disagree with nothing able
|
|
1612
|
+
* to say which is right. Here the registry owns the content, Sprid owns the
|
|
1613
|
+
* calendar, and this is the one command that reconciles them.
|
|
1614
|
+
*
|
|
1615
|
+
* Reconcile media and metadata together so a rebuilt queue remains reproducible.
|
|
1616
|
+
*
|
|
1617
|
+
* Three rules, and the first two are refusals:
|
|
1618
|
+
*
|
|
1619
|
+
* - **A post that is out is frozen.** Sync reads the post's status and touches
|
|
1620
|
+
* only `draft` and `ready`. Anything else - published, publishing, whatever
|
|
1621
|
+
* Sprid adds next - is skipped by name. Fail-closed on an unknown status is
|
|
1622
|
+
* deliberate: re-posting is a new slug, not an edit.
|
|
1623
|
+
* - **It never creates anything.** No postId, no sync; that is what `draft` is
|
|
1624
|
+
* for. So a sync can never widen a batch by accident.
|
|
1625
|
+
* - **It does the minimum.** Media drifted, caption drifted, or neither. A slug
|
|
1626
|
+
* whose caption changed does not get its video re-uploaded.
|
|
1627
|
+
*
|
|
1628
|
+
* The schedule is not touched at all. Swapping the file behind a booked post
|
|
1629
|
+
* keeps its slot, which is the point.
|
|
1630
|
+
*/
|
|
1631
|
+
async function cmdSync(cfg, argv) {
|
|
1632
|
+
const only = positional(argv);
|
|
1633
|
+
const execute = has(argv, "execute");
|
|
1634
|
+
const lane = flag(argv, "lane");
|
|
1635
|
+
const all = (only ? [readManifest(cfg, only)] : allManifests(cfg))
|
|
1636
|
+
.filter((m) => m.sprid?.postId)
|
|
1637
|
+
.filter((m) => !lane || m.lane === lane);
|
|
1638
|
+
if (!all.length) return console.log("\n nothing drafted yet - `push` then `draft` first\n");
|
|
1639
|
+
|
|
1640
|
+
// **`--adopt` is a migration, not a sync.** Every manifest written before
|
|
1641
|
+
// `mediaShaAtPush` existed reads as stale, which for a batch that was never
|
|
1642
|
+
// rebuilt means uploading files Sprid already holds. Adopting records what is
|
|
1643
|
+
// on disk as what was uploaded and sends nothing.
|
|
1644
|
+
//
|
|
1645
|
+
// It is an assertion, so it can be wrong: adopt a slug that WAS rebuilt and
|
|
1646
|
+
// Sprid keeps serving the old file with nothing left to say so. Scope it -
|
|
1647
|
+
// `--lane`, or a slug - to the ones you know were never touched.
|
|
1648
|
+
if (has(argv, "adopt")) {
|
|
1649
|
+
const orphans = all.filter((m) => m.sprid.mediaShaAtPush == null && !isComposed(m));
|
|
1650
|
+
if (!orphans.length) return console.log("\n nothing to adopt - every post already records what was uploaded\n");
|
|
1651
|
+
if (!execute) {
|
|
1652
|
+
console.log(`\n ${C.bold("would adopt")} ${orphans.length} post(s)`);
|
|
1653
|
+
console.log(` ${C.dim("asserts Sprid is holding the file now on disk. Wrong for anything rebuilt since.")}\n`);
|
|
1654
|
+
for (const m of orphans) console.log(` ${m.slug.padEnd(42)}${C.dim(m.lane ?? "")}`);
|
|
1655
|
+
return console.log(`\n ${C.dim("nothing written. re-run with --execute")}\n`);
|
|
1656
|
+
}
|
|
1657
|
+
for (const m of orphans) {
|
|
1658
|
+
m.sprid = {
|
|
1659
|
+
...m.sprid,
|
|
1660
|
+
mediaShaAtPush:
|
|
1661
|
+
(m.kind ?? "reel") === "carousel"
|
|
1662
|
+
? (m.slides ?? []).map((sl) => sl.sha256).join(",")
|
|
1663
|
+
: m.media?.sha256 ?? null,
|
|
1664
|
+
};
|
|
1665
|
+
writeManifest(cfg, m);
|
|
1666
|
+
}
|
|
1667
|
+
return console.log(`\n ${C.ok("adopted")} ${orphans.length} post(s) - nothing uploaded\n`);
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// **A third kind of drift, and it is invisible to a sha.** The title Sprid
|
|
1671
|
+
// holds is what TikTok and YouTube publish, and it is derived from the
|
|
1672
|
+
// caption rather than stored beside it - so a post whose media and caption
|
|
1673
|
+
// are both current can still be carrying an outdated title.
|
|
1674
|
+
// One GET each during planning; the execute pass needs the record anyway.
|
|
1675
|
+
/** A post whose current title could not be read. Counts as drift. */
|
|
1676
|
+
const UNREADABLE = Symbol("unreadable");
|
|
1677
|
+
const remoteTitle = new Map();
|
|
1678
|
+
for (const m of all) {
|
|
1679
|
+
const cp = captionPath(cfg, m.slug);
|
|
1680
|
+
if (!existsSync(cp)) continue;
|
|
1681
|
+
// **A read that failed is not a post that agrees with us.** The first cut
|
|
1682
|
+
// swallowed the error and left the slug out of the map, which reads as "no
|
|
1683
|
+
// title drift" - the same unverified-negative that cost the whole day. One
|
|
1684
|
+
// retry for a restarting dev server, then it counts as drift.
|
|
1685
|
+
let post = await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch(() => null);
|
|
1686
|
+
if (!post) post = await api(cfg, "GET", `/posts/${m.sprid.postId}`).catch(() => null);
|
|
1687
|
+
remoteTitle.set(m.slug, post ? (post.title ?? "") : UNREADABLE);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
const plan = [];
|
|
1691
|
+
for (const m of all) {
|
|
1692
|
+
// **Unknown counts as stale, and it has to.** `mediaShaAtPush` did not
|
|
1693
|
+
// exist before 2026-09-09, so every manifest written before it carries
|
|
1694
|
+
// none - and a record that cannot say what was uploaded cannot say it is
|
|
1695
|
+
// current. Assuming fresh would silently leave old files in the queue,
|
|
1696
|
+
// which is the failure this command exists for.
|
|
1697
|
+
const pushedSha = m.sprid.mediaShaAtPush ?? null;
|
|
1698
|
+
const localSha = isComposed(m)
|
|
1699
|
+
? null
|
|
1700
|
+
: (m.kind ?? "reel") === "carousel"
|
|
1701
|
+
? (m.slides ?? []).map((sl) => sl.sha256).join(",")
|
|
1702
|
+
: m.media?.sha256 ?? null;
|
|
1703
|
+
const media = isComposed(m) ? false : pushedSha == null ? "unknown" : pushedSha !== localSha;
|
|
1704
|
+
const caption = (m.post?.captionSha ?? null) !== (m.sprid.captionShaAtDraft ?? null);
|
|
1705
|
+
const cp = captionPath(cfg, m.slug);
|
|
1706
|
+
const want = m.post?.title ?? (existsSync(cp) ? postTitle(readFileSync(cp, "utf8"), m.slug) : null);
|
|
1707
|
+
const title = remoteTitle.has(m.slug) && want !== null && remoteTitle.get(m.slug) !== want;
|
|
1708
|
+
const unreadable = remoteTitle.get(m.slug) === UNREADABLE;
|
|
1709
|
+
if (media || caption || title) plan.push({ m, media, caption, title, unreadable });
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
if (!plan.length) { console.log("\n in sync - nothing in the registry differs from Sprid\n"); return { plan: [], execute }; }
|
|
1713
|
+
|
|
1714
|
+
console.log(`\n ${C.bold(execute ? "syncing" : "would sync")} ${plan.length} of ${all.length}\n`);
|
|
1715
|
+
for (const { m, media, caption, title, unreadable } of plan) {
|
|
1716
|
+
const what = [
|
|
1717
|
+
media === "unknown" ? C.warn("media (never recorded, assumed stale)") : media ? C.warn("media") : null,
|
|
1718
|
+
caption ? C.warn("caption") : null,
|
|
1719
|
+
title ? C.warn(unreadable ? "title (could not read Sprid's, assumed stale)" : "title") : null,
|
|
1720
|
+
].filter(Boolean).join(" + ");
|
|
1721
|
+
console.log(` ${m.slug.padEnd(42)}${what}`);
|
|
1722
|
+
}
|
|
1723
|
+
if (!execute) {
|
|
1724
|
+
console.log(`\n ${C.dim("nothing written. re-run with --execute")}\n`);
|
|
1725
|
+
return { execute, plan: plan.map(({ m, ...change }) => ({ slug: m.slug, ...change })) };
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
console.log();
|
|
1729
|
+
let done = 0, skipped = 0;
|
|
1730
|
+
for (const { m, media } of plan) {
|
|
1731
|
+
// The live status decides whether this one may be touched at all. Read it
|
|
1732
|
+
// here rather than trusting the manifest: the manifest's `status` is what
|
|
1733
|
+
// this machine last wrote, and the queue moves without us.
|
|
1734
|
+
let post;
|
|
1735
|
+
try {
|
|
1736
|
+
post = await api(cfg, "GET", `/posts/${m.sprid.postId}`);
|
|
1737
|
+
} catch (e) {
|
|
1738
|
+
if (/-> 404/.test(e.message)) {
|
|
1739
|
+
console.log(` ${C.bad("gone")} ${m.slug.padEnd(40)} ${C.dim("no such post in Sprid - `draft` makes a new one")}`);
|
|
1740
|
+
skipped++;
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
throw e;
|
|
1744
|
+
}
|
|
1745
|
+
const state = post.status ?? "unknown";
|
|
1746
|
+
if (!SYNCABLE.has(state)) {
|
|
1747
|
+
console.log(` ${C.dim("frozen")} ${m.slug.padEnd(40)} ${C.dim(`status "${state}" - not a draft any more, leave it`)}`);
|
|
1748
|
+
skipped++;
|
|
1749
|
+
continue;
|
|
1750
|
+
}
|
|
1751
|
+
if (media) await cmdPush(cfg, [m.slug, "--force"]);
|
|
1752
|
+
// Re-read: push rewrote the manifest, and draft has to send the new videoId.
|
|
1753
|
+
await cmdDraft(cfg, [readManifest(cfg, m.slug).slug, "--force"]);
|
|
1754
|
+
done++;
|
|
1755
|
+
}
|
|
1756
|
+
console.log(` ${done} synced, ${skipped} left alone\n`);
|
|
1757
|
+
return { execute, synced: done, skipped, plan: plan.map(({ m, ...change }) => ({ slug: m.slug, ...change })) };
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* The post states a sync may write to.
|
|
1762
|
+
*
|
|
1763
|
+
* An allowlist rather than a list of the ones to avoid: a status this package
|
|
1764
|
+
* has never heard of is far more likely to be further along the pipeline than
|
|
1765
|
+
* behind it, and the cost of skipping one is a line of output.
|
|
1766
|
+
*/
|
|
1767
|
+
const SYNCABLE = new Set(["draft", "ready"]);
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Bring Sprid's side of each post back into the registry.
|
|
1771
|
+
*
|
|
1772
|
+
* **The queue lives in Sprid and nothing here could read it**, which is a
|
|
1773
|
+
* bigger hole than it sounds: the repo could not answer "what goes out
|
|
1774
|
+
* tomorrow" or "which one went out last" without the API being reachable, and
|
|
1775
|
+
* a booking made weeks ago left no trace anybody could review in a diff. A post
|
|
1776
|
+
* is committed; when it publishes was not.
|
|
1777
|
+
*
|
|
1778
|
+
* So this writes what Sprid knows back into `sprid.remote`: the status, and the
|
|
1779
|
+
* schedule if the post carries one. The schedule's shape is read defensively -
|
|
1780
|
+
* whatever key it arrives under, it is recorded verbatim under `raw` as well,
|
|
1781
|
+
* so a field this package does not recognise is visible rather than lost.
|
|
1782
|
+
*/
|
|
1783
|
+
/**
|
|
1784
|
+
* A post's remote state, from its publish rows.
|
|
1785
|
+
*
|
|
1786
|
+
* Track each platform separately: a reel may be published on one and scheduled
|
|
1787
|
+
* on another. Summary dates are the earliest observed date of each kind.
|
|
1788
|
+
*/
|
|
1789
|
+
export function remoteOf(publishes) {
|
|
1790
|
+
return {
|
|
1791
|
+
publishes,
|
|
1792
|
+
scheduledAt: publishes.map((p) => p.scheduledFor).filter(Boolean).sort()[0] ?? null,
|
|
1793
|
+
publishedAt: publishes.map((p) => p.publishedAt).filter(Boolean).sort()[0] ?? null,
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
async function cmdPull(cfg, argv) {
|
|
1798
|
+
const only = positional(argv);
|
|
1799
|
+
const all = (only ? [readManifest(cfg, only)] : allManifests(cfg)).filter((m) => m.sprid?.postId);
|
|
1800
|
+
if (!all.length) return console.log("\n nothing drafted yet\n");
|
|
1801
|
+
|
|
1802
|
+
// Publish rows hold schedule dates; post summaries do not include them.
|
|
1803
|
+
const rows = await api(cfg, "GET", `/publishes?account=${encodeURIComponent(cfg.account)}&limit=1000`);
|
|
1804
|
+
const byPost = new Map();
|
|
1805
|
+
for (const r of Array.isArray(rows) ? rows : []) {
|
|
1806
|
+
const list = byPost.get(r.postId) ?? [];
|
|
1807
|
+
list.push({
|
|
1808
|
+
platform: r.platform,
|
|
1809
|
+
status: r.status,
|
|
1810
|
+
scheduledFor: r.scheduledFor ?? null,
|
|
1811
|
+
publishedAt: r.publishedAt ?? null,
|
|
1812
|
+
lastError: r.lastError ?? null,
|
|
1813
|
+
attempts: r.attempts ?? 0,
|
|
1814
|
+
});
|
|
1815
|
+
byPost.set(r.postId, list);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
let orphans = 0;
|
|
1819
|
+
for (const m of all) {
|
|
1820
|
+
const publishes = (byPost.get(m.sprid.postId) ?? []).sort((a, b) => a.platform.localeCompare(b.platform));
|
|
1821
|
+
if (!publishes.length) orphans++;
|
|
1822
|
+
m.sprid = { ...m.sprid, remote: { ...remoteOf(publishes), at: new Date().toISOString() } };
|
|
1823
|
+
writeManifest(cfg, m);
|
|
1824
|
+
const when = m.sprid.remote.publishedAt ?? m.sprid.remote.scheduledAt;
|
|
1825
|
+
const failed = publishes.filter((p) => p.lastError);
|
|
1826
|
+
console.log(
|
|
1827
|
+
` ${C.ok("pulled")} ${m.slug.padEnd(42)}` +
|
|
1828
|
+
publishes.map((p) => `${p.platform[0]}${p.status === "published" ? C.ok("✓") : p.status === "scheduled" ? C.dim("·") : C.bad("!")}`).join(" ").padEnd(22) +
|
|
1829
|
+
C.dim(when ? when.slice(0, 16).replace("T", " ") : "not booked") +
|
|
1830
|
+
(failed.length ? ` ${C.bad(failed.map((f) => `${f.platform}: ${f.lastError}`).join("; ").slice(0, 60))}` : ""),
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
if (orphans)
|
|
1834
|
+
console.log(`\n ${C.warn(`${orphans} post(s) carry no publish row at all - drafted but never booked`)}`);
|
|
1835
|
+
console.log();
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* What a post is created with, and it is published rather than filed.
|
|
1840
|
+
*
|
|
1841
|
+
* YouTube's adapter takes `post.title` ahead of the caption for a Short's
|
|
1842
|
+
* `snippet.title`, so a slug here ships "some-slug-like-this #Shorts" to a
|
|
1843
|
+
* viewer. A caption's first line is a title in every repo using this package;
|
|
1844
|
+
* the slug is stored as internalName so an uncaptioned post stays findable
|
|
1845
|
+
* without making an identifier public.
|
|
1846
|
+
*/
|
|
1847
|
+
export function postTitle(caption, slug) {
|
|
1848
|
+
return (caption ?? "").split("\n")[0].trim();
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
/**
|
|
1852
|
+
* Where this repo is pointed, whether it answers, and what it says back.
|
|
1853
|
+
*
|
|
1854
|
+
* Probe each candidate URL and report disagreements. A saved registry does
|
|
1855
|
+
* not establish whether its configured service is reachable.
|
|
1856
|
+
*/
|
|
1857
|
+
async function cmdDoctor(cfg) {
|
|
1858
|
+
let logged = null;
|
|
1859
|
+
try {
|
|
1860
|
+
logged = resolveAuth();
|
|
1861
|
+
} catch {
|
|
1862
|
+
// Not logged in is a state to report, not to throw on: it is exactly what
|
|
1863
|
+
// this command is for.
|
|
1864
|
+
}
|
|
1865
|
+
const candidates = [
|
|
1866
|
+
["SPRID_URL", process.env.SPRID_URL],
|
|
1867
|
+
["sprid.config apiUrl", cfg.apiUrl === defaultApi() ? null : cfg.apiUrl],
|
|
1868
|
+
[`sprid login (${logged?.source ?? "not logged in"})`, logged?.apiUrl],
|
|
1869
|
+
["default", DEFAULT_API_URL],
|
|
1870
|
+
].filter(([, v]) => v);
|
|
1871
|
+
|
|
1872
|
+
console.log(`\n ${C.bold(cfg.account)} ${C.dim("using")} ${C.bold(cfg.apiUrl)}\n`);
|
|
1873
|
+
for (const [where, url] of candidates)
|
|
1874
|
+
console.log(` ${url === cfg.apiUrl ? C.ok("→") : " "} ${where.padEnd(28)}${C.dim(url)}`);
|
|
1875
|
+
if (new Set(candidates.map(([, u]) => u)).size > 1)
|
|
1876
|
+
console.log(`\n ${C.dim("more than one candidate: the arrow is the one in use")}`);
|
|
1877
|
+
|
|
1878
|
+
// Dial every candidate, not only the chosen one. A repo pointed at a host
|
|
1879
|
+
// that is down while another candidate answers is the whole failure mode.
|
|
1880
|
+
console.log();
|
|
1881
|
+
for (const url of new Set(candidates.map(([, u]) => u))) {
|
|
1882
|
+
const t0 = Date.now();
|
|
1883
|
+
let line;
|
|
1884
|
+
try {
|
|
1885
|
+
const res = await fetch(`${url}/api/accounts`, {
|
|
1886
|
+
redirect: 'error',
|
|
1887
|
+
headers: url === cfg.apiUrl ? { Authorization: `Bearer ${token(cfg)}` } : {},
|
|
1888
|
+
signal: AbortSignal.timeout(8000),
|
|
1889
|
+
});
|
|
1890
|
+
const ms = Date.now() - t0;
|
|
1891
|
+
if (!res.ok) line = `${C.bad(String(res.status))} ${C.dim(`${ms}ms`)}`;
|
|
1892
|
+
else {
|
|
1893
|
+
const accounts = await res.json();
|
|
1894
|
+
const mine = Array.isArray(accounts) ? accounts.find((a) => a.slug === cfg.account) : null;
|
|
1895
|
+
line = mine
|
|
1896
|
+
? `${C.ok("ok")} ${C.dim(`${ms}ms`)} ${C.bold(`account ${mine.id}`)} ` +
|
|
1897
|
+
(mine.channelSummary ?? [])
|
|
1898
|
+
.map((c) => `${c.platform}:${c.status === "active" ? C.ok(c.status) : C.bad(c.status)}`)
|
|
1899
|
+
.join(" ")
|
|
1900
|
+
: `${C.warn("reachable, but no account")} "${cfg.account}" ${C.dim(`(${ms}ms)`)}`;
|
|
1901
|
+
}
|
|
1902
|
+
} catch (e) {
|
|
1903
|
+
line = `${C.bad("unreachable")} ${C.dim(String(e?.cause?.code ?? e?.name ?? e?.message).slice(0, 40))}`;
|
|
1904
|
+
}
|
|
1905
|
+
console.log(` ${url.padEnd(30)} ${line}`);
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
// How stale the local view of the queue is. `status` cannot say this and it
|
|
1909
|
+
// is the difference between "Several posts drafted" and "Several posts drafted, as of the last pull".
|
|
1910
|
+
const pulled = allManifests(cfg)
|
|
1911
|
+
.map((m) => m.sprid?.remote?.at)
|
|
1912
|
+
.filter(Boolean)
|
|
1913
|
+
.sort();
|
|
1914
|
+
console.log(
|
|
1915
|
+
`\n ${pulled.length ? `${pulled.length} post(s) pulled, oldest ${pulled[0].slice(0, 16).replace("T", " ")}` : C.warn("never pulled - `sprid post pull` brings the schedule in")}\n`,
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
async function cmdStatus(cfg) {
|
|
1920
|
+
const all = allManifests(cfg);
|
|
1921
|
+
if (!all.length) return console.log("\n nothing registered yet\n");
|
|
1922
|
+
const lufs = all.map((m) => m.media?.lufs).filter((v) => typeof v === "number");
|
|
1923
|
+
|
|
1924
|
+
console.log(`\n ${C.bold(cfg.account)} ${C.dim(cfg.apiUrl)}\n`);
|
|
1925
|
+
console.log(` ${"slug".padEnd(42)}${"lane".padEnd(15)}${"size".padEnd(11)}${"checks".padEnd(9)}sprid`);
|
|
1926
|
+
console.log(` ${C.dim("-".repeat(91))}`);
|
|
1927
|
+
for (const m of all) {
|
|
1928
|
+
const checks = !m.checks ? C.dim("-") : checksPass(m) ? C.ok("pass") : C.bad("fail");
|
|
1929
|
+
let state = m.sprid?.status ?? "none";
|
|
1930
|
+
// The one way the two halves drift apart in silence: the caption was
|
|
1931
|
+
// edited here after the draft went over.
|
|
1932
|
+
if (state === "drafted" && m.sprid.captionShaAtDraft && m.post.captionSha !== m.sprid.captionShaAtDraft)
|
|
1933
|
+
state = C.warn("caption changed");
|
|
1934
|
+
// The other way they drift, and the one that had no name until 2026-09-09:
|
|
1935
|
+
// the media was rebuilt and re-registered, so Sprid is still serving the
|
|
1936
|
+
// file from before. `sync` is the answer to both.
|
|
1937
|
+
if (m.sprid?.postId && !isComposed(m) && m.sprid.mediaShaAtPush && m.media?.sha256 !== m.sprid.mediaShaAtPush)
|
|
1938
|
+
state = C.warn("media changed");
|
|
1939
|
+
// A post that is out is a fact about the world, and outranks any local drift.
|
|
1940
|
+
if (m.sprid?.remote?.publishedAt) state = C.dim(`published ${m.sprid.remote.publishedAt.slice(0, 10)}`);
|
|
1941
|
+
else if (m.sprid?.remote?.scheduledAt) state += C.dim(` ${m.sprid.remote.scheduledAt.slice(0, 16).replace("T", " ")}`);
|
|
1942
|
+
// A field the server silently refused stays visible: it is the one kind of
|
|
1943
|
+
// damage that is invisible in Sprid's own UI too.
|
|
1944
|
+
if (m.sprid?.dropped?.length) state = C.bad(`${m.sprid.dropped.join(", ")} dropped`);
|
|
1945
|
+
// What "how big" means depends on the kind: a reel is seconds, a carousel
|
|
1946
|
+
// is slides.
|
|
1947
|
+
const size = isComposed(m)
|
|
1948
|
+
? `${m.media?.slideCount ?? 0} cards`
|
|
1949
|
+
: (m.kind ?? "reel") === "carousel"
|
|
1950
|
+
? `${m.media?.slideCount ?? m.slides?.length ?? 0} slides`
|
|
1951
|
+
: `${(m.media.durationMs / 1000).toFixed(1)}s`;
|
|
1952
|
+
console.log(
|
|
1953
|
+
` ${m.slug.padEnd(42)}${(m.lane ?? "").padEnd(15)}${size.padEnd(11)}` +
|
|
1954
|
+
`${checks.padEnd(checks.includes("\x1b") ? 18 : 9)}${state}`,
|
|
1955
|
+
);
|
|
1956
|
+
}
|
|
1957
|
+
const drafted = all.filter((m) => m.sprid?.postId).length;
|
|
1958
|
+
const pushed = all.filter((m) => m.sprid?.videoId || m.sprid?.imageIds?.length).length;
|
|
1959
|
+
const legacy = existsSync(join(cfg.registry, LEGACY_DIR))
|
|
1960
|
+
? readdirSync(join(cfg.registry, LEGACY_DIR)).filter((f) => f.endsWith(".json")).length
|
|
1961
|
+
: 0;
|
|
1962
|
+
if (legacy)
|
|
1963
|
+
console.log(
|
|
1964
|
+
`\n ${C.dim(`${legacy} record(s) still in ${LEGACY_DIR}/ - the directory is called ${POSTS_DIR}/ now, a rename is the whole migration`)}`,
|
|
1965
|
+
);
|
|
1966
|
+
console.log(
|
|
1967
|
+
`\n ${all.length} registered · ${pushed} uploaded · ${drafted} drafted` +
|
|
1968
|
+
(lufs.length > 1 ? ` · loudness spread ${(Math.max(...lufs) - Math.min(...lufs)).toFixed(1)} dB` : "") +
|
|
1969
|
+
"\n",
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
|
|
1974
|
+
/**
|
|
1975
|
+
* The preview — the whole batch on one page, before any of it is a post.
|
|
1976
|
+
*
|
|
1977
|
+
* **It reads the registry and nothing else**, so it is the same command in
|
|
1978
|
+
* every repo: manifests carry the lane, the length, the check result and the
|
|
1979
|
+
* Sprid state, the captions sit beside them, and the media is wherever the lane
|
|
1980
|
+
* says. That is the entire input. The page it writes is disposable.
|
|
1981
|
+
*
|
|
1982
|
+
* The media is symlinked rather than copied, because a batch is gigabytes and a
|
|
1983
|
+
* preview is meant to be cheap enough to regenerate without thinking about it.
|
|
1984
|
+
* `--copy` is there for the case where the page has to travel (an AirDrop, a
|
|
1985
|
+
* zip to somebody who is not on this machine), and it is the slow path on
|
|
1986
|
+
* purpose.
|
|
1987
|
+
*
|
|
1988
|
+
* **A file that is not on disk is drawn as a missing card, never dropped.** The
|
|
1989
|
+
* mp4s regenerate from their specs and routinely are not there; a preview that
|
|
1990
|
+
* silently omitted them would hide incomplete work.
|
|
1991
|
+
*/
|
|
1992
|
+
async function cmdPreview(cfg, argv) {
|
|
1993
|
+
const opts = {
|
|
1994
|
+
outDir: resolve(cfg.root, flag(argv, "out") ?? "tmp/sprid-preview"),
|
|
1995
|
+
laneOnly: flag(argv, "lane"),
|
|
1996
|
+
copy: has(argv, "copy"),
|
|
1997
|
+
verify: has(argv, "verify"),
|
|
1998
|
+
};
|
|
1999
|
+
const { indexFile } = await buildPreview(cfg, opts);
|
|
2000
|
+
if (has(argv, "serve"))
|
|
2001
|
+
return serve(cfg, opts, Number(flag(argv, "serve") ?? flag(argv, "port") ?? portForAccount(cfg.account)));
|
|
2002
|
+
if (has(argv, "open")) spawnSync("open", [indexFile]);
|
|
2003
|
+
return { indexFile };
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
async function buildPreview(cfg, { outDir, laneOnly, copy, verify }) {
|
|
2007
|
+
const { renderPreview } = await import(`./preview.mjs?v=${Date.now()}`);
|
|
2008
|
+
const mediaDir = join(outDir, "media");
|
|
2009
|
+
|
|
2010
|
+
let all = allManifests(cfg);
|
|
2011
|
+
if (laneOnly) all = all.filter((m) => m.lane === laneOnly);
|
|
2012
|
+
if (!all.length) throw new Error(laneOnly ? `Nothing registered in lane "${laneOnly}".` : "Nothing registered yet.");
|
|
2013
|
+
|
|
2014
|
+
rmSync(mediaDir, { recursive: true, force: true });
|
|
2015
|
+
mkdirSync(mediaDir, { recursive: true });
|
|
2016
|
+
|
|
2017
|
+
let missing = 0;
|
|
2018
|
+
let stale = 0;
|
|
2019
|
+
const items = all.map((m) => {
|
|
2020
|
+
const kind = m.kind ?? "reel";
|
|
2021
|
+
const file = kind === "carousel" ? null : resolve(cfg.root, m.source);
|
|
2022
|
+
const present = kind === "carousel" ? (m.slides ?? []).every((sl) => existsSync(resolve(cfg.root, sl.file))) : existsSync(file);
|
|
2023
|
+
let src = null;
|
|
2024
|
+
let slideSrcs = null;
|
|
2025
|
+
let isStale = false;
|
|
2026
|
+
|
|
2027
|
+
if (kind === "carousel" && present) {
|
|
2028
|
+
// Each slide gets its own link, numbered so the page can rely on order.
|
|
2029
|
+
slideSrcs = (m.slides ?? []).map((sl, i) => {
|
|
2030
|
+
const from = resolve(cfg.root, sl.file);
|
|
2031
|
+
if (statSync(from).size !== sl.sizeBytes) isStale = true;
|
|
2032
|
+
const ext = from.slice(from.lastIndexOf("."));
|
|
2033
|
+
const link = join(mediaDir, `${m.slug}-${String(i + 1).padStart(2, "0")}${ext}`);
|
|
2034
|
+
if (copy) copyFileSync(from, link);
|
|
2035
|
+
else symlinkSync(from, link);
|
|
2036
|
+
return `media/${basename(link)}`;
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
if (kind !== "carousel" && present) {
|
|
2041
|
+
// Cheap by default: a size that no longer matches the record means the
|
|
2042
|
+
// file was rebuilt since it was measured. `--verify` spends the sha.
|
|
2043
|
+
const size = statSync(file).size;
|
|
2044
|
+
isStale = verify ? sha256(file) !== m.media?.sha256 : size !== m.media?.sizeBytes;
|
|
2045
|
+
const link = join(mediaDir, `${m.slug}.mp4`);
|
|
2046
|
+
if (copy) copyFileSync(file, link);
|
|
2047
|
+
else symlinkSync(file, link);
|
|
2048
|
+
src = `media/${m.slug}.mp4`;
|
|
2049
|
+
}
|
|
2050
|
+
if (!present) missing++;
|
|
2051
|
+
if (isStale) stale++;
|
|
2052
|
+
|
|
2053
|
+
const capFile = captionPath(cfg, m.slug);
|
|
2054
|
+
const named = Object.entries(m.checks ?? {}).filter(([k]) => k !== "at");
|
|
2055
|
+
const failures = named.filter(([, v]) => String(v).startsWith("fail")).map(([k]) => k);
|
|
2056
|
+
const passed = named.filter(([, v]) => v === "pass").map(([k]) => k);
|
|
2057
|
+
|
|
2058
|
+
return {
|
|
2059
|
+
slug: m.slug,
|
|
2060
|
+
lane: m.lane,
|
|
2061
|
+
kind,
|
|
2062
|
+
source: m.source,
|
|
2063
|
+
src,
|
|
2064
|
+
slides: slideSrcs,
|
|
2065
|
+
stale: isStale,
|
|
2066
|
+
caption: existsSync(capFile) ? readFileSync(capFile, "utf8").trim() : null,
|
|
2067
|
+
durationMs: m.media?.durationMs ?? null,
|
|
2068
|
+
slideCount: m.media?.slideCount ?? m.slides?.length ?? null,
|
|
2069
|
+
aspect: m.media?.aspect ?? null,
|
|
2070
|
+
sizeBytes: m.media?.sizeBytes ?? null,
|
|
2071
|
+
lufs: typeof m.media?.lufs === "number" ? m.media.lufs : null,
|
|
2072
|
+
checks: !m.checks ? null : failures.length ? "fail" : "pass",
|
|
2073
|
+
failures,
|
|
2074
|
+
passed,
|
|
2075
|
+
build: m.build?.command ?? null,
|
|
2076
|
+
spridStatus: m.sprid?.status ?? "none",
|
|
2077
|
+
captionDrifted:
|
|
2078
|
+
m.sprid?.status === "drafted" &&
|
|
2079
|
+
m.sprid?.captionShaAtDraft != null &&
|
|
2080
|
+
m.post?.captionSha !== m.sprid.captionShaAtDraft,
|
|
2081
|
+
};
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
const indexFile = join(outDir, "index.html");
|
|
2085
|
+
writeFileSync(
|
|
2086
|
+
indexFile,
|
|
2087
|
+
renderPreview({ account: cfg.account, items, generatedAt: new Date().toISOString().replace("T", " ").slice(0, 16) }),
|
|
2088
|
+
);
|
|
2089
|
+
|
|
2090
|
+
console.log(
|
|
2091
|
+
` ${C.ok("preview")} ${items.length} post${items.length === 1 ? "" : "s"}` +
|
|
2092
|
+
(missing ? C.warn(` ${missing} with no file on disk`) : "") +
|
|
2093
|
+
(stale ? C.warn(` ${stale} differing from their record`) : "") +
|
|
2094
|
+
` ${C.dim(indexFile.replace(cfg.root + "/", ""))}`,
|
|
2095
|
+
);
|
|
2096
|
+
return { indexFile, outDir, count: items.length };
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* A static server for the preview, with Range and a live reload.
|
|
2101
|
+
*
|
|
2102
|
+
* **`file://` is enough to watch a reel and not enough to scrub one**: without
|
|
2103
|
+
* a 206 the browser cannot seek inside an mp4, and Safari will not play a
|
|
2104
|
+
* symlinked file at all.
|
|
2105
|
+
*
|
|
2106
|
+
* **The reload is here because the alternative is a pile of tabs.** Registering
|
|
2107
|
+
* a reel, editing a caption or changing this page's own code is something you
|
|
2108
|
+
* do a dozen times in a sitting, and re-running the command to see it opens a
|
|
2109
|
+
* new tab each time and loses where you were. The page holds an EventSource;
|
|
2110
|
+
* a change rebuilds the file and tells the tab you already have to refresh -
|
|
2111
|
+
* and since the current reel lives in the URL hash, a refresh comes back to it.
|
|
2112
|
+
*/
|
|
2113
|
+
/**
|
|
2114
|
+
* A stable port per account, so several repos can preview at once.
|
|
2115
|
+
*
|
|
2116
|
+
* Derive a stable preferred port from the account slug to reduce collisions
|
|
2117
|
+
* between projects. Hash collisions remain possible; --serve <port> overrides.
|
|
2118
|
+
*/
|
|
2119
|
+
export function portForAccount(account, base = 4599) {
|
|
2120
|
+
let h = 0;
|
|
2121
|
+
for (const ch of account) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
|
2122
|
+
return base + (h % 40);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
async function serve(cfg, opts, port) {
|
|
2126
|
+
const dir = opts.outDir;
|
|
2127
|
+
const clients = new Set();
|
|
2128
|
+
const srv = createServer(previewHandler({ directory: dir, account: cfg.account, clients }));
|
|
2129
|
+
|
|
2130
|
+
// **A busy port is answered by asking who is on it, never by assuming.** If
|
|
2131
|
+
// it is this account's own preview, re-running the command is how you find
|
|
2132
|
+
// out one is already up and the right answer is to say so. If it is another
|
|
2133
|
+
// project's, walking to the next free port is the only answer that does not
|
|
2134
|
+
// hand somebody the wrong batch.
|
|
2135
|
+
for (let tries = 0; tries < 12; tries++, port++) {
|
|
2136
|
+
const holder = await whoIsServing(port);
|
|
2137
|
+
if (holder === cfg.account) {
|
|
2138
|
+
console.log(
|
|
2139
|
+
` ${C.warn("already serving")} ${C.bold(cfg.account)} on http://localhost:${port}/` +
|
|
2140
|
+
` - that tab is live, it reloads itself\n`,
|
|
2141
|
+
);
|
|
2142
|
+
return { url: `http://localhost:${port}/`, alreadyServing: true };
|
|
2143
|
+
}
|
|
2144
|
+
if (holder != null) {
|
|
2145
|
+
// A server too old to answer `/__whoami` reports "", which still means
|
|
2146
|
+
// "not ours" - say that rather than printing an empty pair of quotes.
|
|
2147
|
+
console.log(
|
|
2148
|
+
` ${C.dim(`:${port} is serving ${holder ? `"${holder}"` : "something else"}, trying :${port + 1}`)}`,
|
|
2149
|
+
);
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
const bound = await new Promise((ok) => {
|
|
2153
|
+
srv.once("error", (e) => (e.code === "EADDRINUSE" ? ok(false) : (() => { throw e; })()));
|
|
2154
|
+
srv.listen(port, "127.0.0.1", () => ok(true));
|
|
2155
|
+
});
|
|
2156
|
+
if (!bound) continue;
|
|
2157
|
+
const url = `http://localhost:${port}/`;
|
|
2158
|
+
console.log(` ${C.bold(url)} ${C.bold(cfg.account)} ${C.dim("watching for changes · ctrl-c to stop")}\n`);
|
|
2159
|
+
spawnSync("open", [url]);
|
|
2160
|
+
watchAndRebuild(cfg, opts, clients);
|
|
2161
|
+
return { url };
|
|
2162
|
+
}
|
|
2163
|
+
throw new Error(`no free port from ${port - 12}; pass --serve <port>`);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
/**
|
|
2167
|
+
* The account whose preview is on this port, or null if nothing is.
|
|
2168
|
+
*
|
|
2169
|
+
* A server too old to answer `/__whoami` returns "" rather than null, which
|
|
2170
|
+
* still means "occupied by something that is not us" - the safe reading.
|
|
2171
|
+
*/
|
|
2172
|
+
async function whoIsServing(port) {
|
|
2173
|
+
try {
|
|
2174
|
+
const res = await fetch(`http://localhost:${port}/__whoami`, { signal: AbortSignal.timeout(600) });
|
|
2175
|
+
if (!res.ok) return "";
|
|
2176
|
+
const body = await res.json().catch(() => null);
|
|
2177
|
+
return typeof body?.account === "string" ? body.account : "";
|
|
2178
|
+
} catch (e) {
|
|
2179
|
+
// ECONNREFUSED is the only "nothing there"; a timeout is something there.
|
|
2180
|
+
return /ECONNREFUSED|refused/i.test(String(e?.cause?.code ?? e?.message ?? "")) ? null : "";
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
/** Everything a preview is a function of: the registry, the media, this code. */
|
|
2185
|
+
function watchAndRebuild(cfg, opts, clients) {
|
|
2186
|
+
// **Both registry directories.** The records moved to `posts/` and the
|
|
2187
|
+
// watcher stayed on `reels/`, so in a repo that had migrated, registering
|
|
2188
|
+
// something while the server was up changed nothing on the page.
|
|
2189
|
+
const dirs = new Set([
|
|
2190
|
+
join(cfg.registry, POSTS_DIR),
|
|
2191
|
+
join(cfg.registry, LEGACY_DIR),
|
|
2192
|
+
join(cfg.registry, "captions"),
|
|
2193
|
+
dirname(new URL(import.meta.url).pathname),
|
|
2194
|
+
]);
|
|
2195
|
+
for (const name of Object.keys(cfg.lanes)) {
|
|
2196
|
+
if (opts.laneOnly && name !== opts.laneOnly) continue;
|
|
2197
|
+
dirs.add(resolve(cfg.root, dirname(fill(mediaTemplate(cfg.lanes[name]), "x"))));
|
|
2198
|
+
if (cfg.lanes[name].caption) dirs.add(resolve(cfg.root, dirname(fill(cfg.lanes[name].caption, "x"))));
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
let timer = null;
|
|
2202
|
+
const rebuild = () => {
|
|
2203
|
+
clearTimeout(timer);
|
|
2204
|
+
timer = setTimeout(async () => {
|
|
2205
|
+
try {
|
|
2206
|
+
await buildPreview(cfg, opts);
|
|
2207
|
+
for (const res of clients) res.write("data: reload\n\n");
|
|
2208
|
+
} catch (e) {
|
|
2209
|
+
console.log(` ${C.bad("build failed")} ${e.message}`);
|
|
2210
|
+
}
|
|
2211
|
+
}, 200);
|
|
2212
|
+
};
|
|
2213
|
+
|
|
2214
|
+
for (const d of dirs) {
|
|
2215
|
+
if (!existsSync(d)) continue;
|
|
2216
|
+
try {
|
|
2217
|
+
watch(d, rebuild);
|
|
2218
|
+
} catch {
|
|
2219
|
+
// A directory that cannot be watched is not worth failing the server for.
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
// ─── entry ───────────────────────────────────────────────────────────────────
|
|
2225
|
+
|
|
2226
|
+
const USAGE = `
|
|
2227
|
+
sprid post — build posts in your own repo, then push them to Sprid.
|
|
2228
|
+
A post is a reel (one video), a carousel (a directory of images), or composed
|
|
2229
|
+
(no local file at all - Sprid renders it and this repo holds the copy).
|
|
2230
|
+
|
|
2231
|
+
post init what are your posts made of, and do you even need this
|
|
2232
|
+
--kind text | composed | stills | own
|
|
2233
|
+
post new <slug> scaffold a spec - the copy, or the pictures
|
|
2234
|
+
post build [slug] run the lane's builder - yours, or the stills recipe
|
|
2235
|
+
post register [slug] measure finished media into the registry (no slug = every lane)
|
|
2236
|
+
--built-with "<cmd>" record the command that made it, so it can be made again
|
|
2237
|
+
post check [slug] run the gate; writes the result into each manifest
|
|
2238
|
+
--deep also ask Sprid what each platform will publish
|
|
2239
|
+
post push [slug] upload to Sprid (refuses anything not passing check)
|
|
2240
|
+
post draft [slug] create the post, attach the media or the copy, set the caption
|
|
2241
|
+
refuses anything not passing check, --force included
|
|
2242
|
+
post sync [slug] a re-render into the posts that already exist, keeping their
|
|
2243
|
+
slot. Plan only, --execute to write. Refuses anything past
|
|
2244
|
+
'ready' - a published post is frozen.
|
|
2245
|
+
--adopt one-time: record what is on disk as what was uploaded,
|
|
2246
|
+
for a batch that predates the record. Uploads nothing.
|
|
2247
|
+
--lane <name> scope either of the above to one lane
|
|
2248
|
+
post pull [slug] bring Sprid's status and schedule back into the registry,
|
|
2249
|
+
so 'status' is a calendar and works offline
|
|
2250
|
+
post preview one page of the whole batch: a grid, and a feed you arrow through
|
|
2251
|
+
post status one table of everything, local and remote
|
|
2252
|
+
post doctor which API, can it be reached, which channels are live
|
|
2253
|
+
|
|
2254
|
+
--force push/draft again for something already done
|
|
2255
|
+
--json one JSON result; diagnostics and builder output on stderr
|
|
2256
|
+
check --platforms instagram,tiktok override crosspost validation targets
|
|
2257
|
+
register --platforms instagram save a post's intended validation targets
|
|
2258
|
+
preview --serve [port] a local server (needed to scrub, and for Safari)
|
|
2259
|
+
--open file:// straight into the browser · --lane <name> · --copy
|
|
2260
|
+
--verify sha every file instead of trusting its size
|
|
2261
|
+
Config sprid.config.ts in this repo says where finished media lives and what
|
|
2262
|
+
has to be true of it. Registry: social/, committed.
|
|
2263
|
+
Login \`sprid login\` - one credentials file for every sprid command.
|
|
2264
|
+
`;
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
export async function run(argv, { cwd = process.cwd() } = {}) {
|
|
2268
|
+
const cmd = argv[0];
|
|
2269
|
+
const rest = argv.slice(1);
|
|
2270
|
+
if (!cmd || cmd === "help" || cmd === "--help") {
|
|
2271
|
+
console.log(USAGE);
|
|
2272
|
+
return { command: "help", usage: USAGE };
|
|
2273
|
+
}
|
|
2274
|
+
if (cmd === "init") {
|
|
2275
|
+
await cmdInit(rest);
|
|
2276
|
+
return { command: cmd };
|
|
2277
|
+
}
|
|
2278
|
+
const commands = { new: cmdNew, build: cmdBuild, register: cmdRegister, check: cmdCheck,
|
|
2279
|
+
push: cmdPush, draft: cmdDraft, preview: cmdPreview, doctor: cmdDoctor, sync: cmdSync,
|
|
2280
|
+
pull: cmdPull, status: cmdStatus };
|
|
2281
|
+
if (!commands[cmd]) throw new Error(`Unknown command "${cmd}".${USAGE}`);
|
|
2282
|
+
const cfg = await loadConfig(cwd);
|
|
2283
|
+
const details = await commands[cmd](cfg, rest);
|
|
2284
|
+
return { command: cmd, account: cfg.account, posts: allManifests(cfg), ...details };
|
|
2285
|
+
}
|