@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
sprid follows semver: a breaking change to a command's arguments, its output
|
|
4
|
+
shape or its exit codes is a major release.
|
|
5
|
+
|
|
6
|
+
## 0.1.2 - 2026-09-22
|
|
7
|
+
|
|
8
|
+
- Published as `@sprid/cli`. The command is still `sprid`.
|
|
9
|
+
- Reports the API's supported-version floor, and stops with the upgrade
|
|
10
|
+
instruction when the API refuses a version as too old.
|
|
11
|
+
- `bugs` reaches the package, the README drops a maintainer-only section, and
|
|
12
|
+
the sample output is generic.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Copyright (c) 2026 Väder AB. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Use is subject to Sprid's Terms of Service at https://sprid.studio/terms,
|
|
4
|
+
which set out what you may do with these files.
|
|
5
|
+
|
|
6
|
+
Bundled third-party components keep their own licences, which are included
|
|
7
|
+
beside the files they cover.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
10
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
11
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
12
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
13
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
14
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README-post.md
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
# `sprid post` — build posts in your own repo, then push them to Sprid
|
|
2
|
+
|
|
3
|
+
Build posts wherever you like, then push them to Sprid. **A post is a reel (one
|
|
4
|
+
video file) or a carousel (a directory of images)** - the registry, the gate,
|
|
5
|
+
the preview and the upload are the same for both, and a lane says which it is.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bunx @sprid/cli post init # what are your reels made of, and do you need this
|
|
9
|
+
bunx @sprid/cli post new <slug> # scaffold a spec (recipe lanes)
|
|
10
|
+
bunx @sprid/cli post build # run the lane's builder - yours, or the recipe
|
|
11
|
+
bunx @sprid/cli post register # measure every finished file into the registry
|
|
12
|
+
bunx @sprid/cli post check # the gate
|
|
13
|
+
bunx @sprid/cli post preview # the whole batch on one page: a grid, and a feed
|
|
14
|
+
bunx @sprid/cli post push # upload to Sprid
|
|
15
|
+
bunx @sprid/cli post draft # post + slide + video + caption
|
|
16
|
+
bunx @sprid/cli post status # one table, local and remote
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Start with the pixels
|
|
20
|
+
|
|
21
|
+
Cloud reel rendering is paused. Keep a text reel's spec, visual system and
|
|
22
|
+
renderer in the app repo, then use Sprid Local to register, check, upload and
|
|
23
|
+
publish the finished MP4. The same handoff covers photographs, screen recordings
|
|
24
|
+
and any other composition whose pixels the app owns.
|
|
25
|
+
|
|
26
|
+
## Two ways to make the file
|
|
27
|
+
|
|
28
|
+
A lane says how its media is built, and there are exactly two answers:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
build: "bun run scripts/make-reel.ts --spec <spec> --out <out>" // your renderer
|
|
32
|
+
build: { recipe: "stills", track: "assets/bed.mp3" } // the built-in
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Everything downstream - `register`, `check`, `push`, `draft` - cannot tell them
|
|
36
|
+
apart, so you can start on the recipe and grow into your own renderer without
|
|
37
|
+
changing anything else.
|
|
38
|
+
|
|
39
|
+
There are two built-in recipes, `stills` and `screen`.
|
|
40
|
+
|
|
41
|
+
**`stills`**: pictures, a slow eased push on each, a
|
|
42
|
+
cross-fade between, music measured and baked. It answers the thing nothing else
|
|
43
|
+
does - material that is neither text nor already a video. It **prints the ffmpeg
|
|
44
|
+
command it ran**, which is the whole difference between a recipe and a
|
|
45
|
+
framework: the output is a starting point you edit, not a black box.
|
|
46
|
+
|
|
47
|
+
A **spec** is one JSON file per reel and it is the durable artifact - the mp4
|
|
48
|
+
regenerates, the spec is what you review and commit:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{ "slug": "…", "images": ["a.jpg", "b.jpg"], "track": "bed.mp3",
|
|
52
|
+
"holdMs": 5800, "caption": "…" }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**`screen`** records your own app being used. See the next section.
|
|
56
|
+
|
|
57
|
+
**Beyond that it renders nothing.** How a file was made is your repo's business.
|
|
58
|
+
This package's whole subject is *a finished local file becoming a Sprid draft,
|
|
59
|
+
reproducibly and with the checks* - which is why it serves a repo with a bespoke
|
|
60
|
+
render pipeline and a repo whose reels come out of After Effects equally well.
|
|
61
|
+
|
|
62
|
+
**It does not need an agent.** Everything goes through the REST API with a
|
|
63
|
+
`sprd_` personal access token, so CI can run it. The MCP server is the
|
|
64
|
+
agent-facing convenience over the same endpoints, not the only door.
|
|
65
|
+
|
|
66
|
+
## `screen`: the app, driven and recorded
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
build: { recipe: "screen", app: { bundleId: "com.example.app", prepare: "…", launchMs: 19000 } }
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
It boots an iOS simulator, dresses the status bar, runs a **gesture script**
|
|
73
|
+
against the app, records the screen, and cuts the recording into a 9:16 reel
|
|
74
|
+
with TikTok-style captions, a touch indicator and music.
|
|
75
|
+
|
|
76
|
+
**The reason it exists is the seven-language version.** A demo reel is normally
|
|
77
|
+
impossible to remake in another language, because the work is a person holding a
|
|
78
|
+
phone. Here that work splits in two:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
social/scripts/complete-task.json the finger's path. NO WORDS.
|
|
82
|
+
social/specs/complete-task-en.json the locale, the captions, the music.
|
|
83
|
+
social/specs/complete-task-sv.json the same path, a different language.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A locale then costs its translation and one 90-second run. Nothing else.
|
|
87
|
+
|
|
88
|
+
### The script
|
|
89
|
+
|
|
90
|
+
Normalised coordinates, `0..1` of the device screen, and five kinds of step:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{ "steps": [
|
|
94
|
+
{ "mark": "home" },
|
|
95
|
+
{ "wait": 1600 },
|
|
96
|
+
{ "tap": [0.388, 0.730], "note": "Create task" },
|
|
97
|
+
{ "settle": 1900 },
|
|
98
|
+
{ "swipe": [0.8, 0.62, 0.22, 0.62], "duration": 300 },
|
|
99
|
+
{ "swipe": [0.5, 0.72, 0.5, 0.46], "duration": 800, "precise": true },
|
|
100
|
+
{ "shell": "xcrun simctl openurl <udid> \"app:///demo?lang=<locale>\"" }
|
|
101
|
+
] }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
- **`mark`** names a moment. Captions bind to marks, never to seconds, which is
|
|
105
|
+
the only reason the same script survives a run that is two seconds longer.
|
|
106
|
+
- **`settle`** waits for the screen to change and then to stop changing, up to
|
|
107
|
+
the given ceiling. Both halves matter - see below.
|
|
108
|
+
- **`swipe`** is a flick by default: it is still moving when the finger lifts,
|
|
109
|
+
so iOS computes momentum. `precise: true` is an eased drag that stops where
|
|
110
|
+
the finger stops, which is what you want for reading rather than browsing.
|
|
111
|
+
- **`shell`** runs a command mid-script, with `<udid>`, `<locale>` and
|
|
112
|
+
`<bundleId>` filled in from the spec - so an app that picks its language
|
|
113
|
+
through a deep link keeps ONE script.
|
|
114
|
+
|
|
115
|
+
Everything else - Metro, the dev client, the demo seed, the login - is welded to
|
|
116
|
+
your repo and belongs in `app.prepare`, a command the recipe runs and waits for.
|
|
117
|
+
**`prepare` must not launch the app**; the recipe does, after the clapperboard.
|
|
118
|
+
|
|
119
|
+
### The spec
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{ "slug": "complete-task-sv", "locale": "sv", "device": "iPhone 17",
|
|
123
|
+
"script": "social/scripts/complete-task.json",
|
|
124
|
+
"render": { "speed": 1.7, "capY": 0.78, "capSize": 76, "pill": 0, "background": "blur", "drift": 0 },
|
|
125
|
+
"captions": [ { "at": "home", "text": "Jag säger ja innan jag läst klart." } ],
|
|
126
|
+
"broll": { "clip": "assets/broll/couch.mp4", "seconds": 2.4 },
|
|
127
|
+
"track": "assets/audio/bed.mp3",
|
|
128
|
+
"caption": "the post caption" }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Captions are chunked into two or three words and timed by the same prosody model
|
|
132
|
+
Sprid's own renderer uses: **weights, not milliseconds**, so a word's share of
|
|
133
|
+
its beat tracks its syllables and the pauses sit at the punctuation. A beat that
|
|
134
|
+
is longer than its words need is **left empty at the end rather than stretched**
|
|
135
|
+
- that silence is the moment the viewer looks at the app with nothing written
|
|
136
|
+
over it, which is the product demo.
|
|
137
|
+
|
|
138
|
+
`speed` speeds the recording up; 1.6-1.7 is where a UI demo lands in the 15-18s
|
|
139
|
+
band without reading as a fast-forward. `broll` cuts a clip in front, hard, never
|
|
140
|
+
a dissolve.
|
|
141
|
+
|
|
142
|
+
**`drift` is 0 and should stay there.** It offsets the phone a few pixels on two
|
|
143
|
+
sine pairs to imitate a hand. It does not read as one: the status bar in a screen
|
|
144
|
+
recording is pin-sharp, so the movement registers as the video being unstable
|
|
145
|
+
rather than as being filmed. A screen recording holds still.
|
|
146
|
+
|
|
147
|
+
### Iterating: `--recut`
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
sprid post build <slug> # drive the app and cut
|
|
151
|
+
sprid post build <slug> --recut # re-cut what is already recorded, no simulator
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
**This is the loop that matters.** A caption, a beat, where the captions sit,
|
|
155
|
+
the speed, the background - none of it needs the app driven again. `cut.json`
|
|
156
|
+
next to the recording holds what a re-cut needs: the timeline, the second
|
|
157
|
+
clapper's wall time and the device's pixel size.
|
|
158
|
+
|
|
159
|
+
### Five things it does that are not obvious
|
|
160
|
+
|
|
161
|
+
**`settle` waits for the screen to become something else, not to stop moving.**
|
|
162
|
+
Waiting only for quiet cannot tell *the transition has finished* from *it has not
|
|
163
|
+
started*, and a Debug build behind Metro can run seconds behind the script - and
|
|
164
|
+
while it is behind, the screen it is still showing is perfectly quiet. Every
|
|
165
|
+
settle then returns on a stale frame and the next tap lands on a screen that is
|
|
166
|
+
about to be replaced. It compares against a frame captured before the gesture
|
|
167
|
+
that caused the change.
|
|
168
|
+
|
|
169
|
+
**Two clapperboards, because the recorder's clock is not the wall clock.**
|
|
170
|
+
`simctl io recordVideo` reports nothing about when it opened the stream, so the
|
|
171
|
+
run flashes the springboard dark before the app comes up and again after the
|
|
172
|
+
script, and solves `videoSeconds = v0 + k · wallMs`. Measured `k` here ranged
|
|
173
|
+
0.76 to 1.00 across runs. One flash cannot see a rate error - the first version
|
|
174
|
+
had one, and its last caption was four seconds off the screen it described.
|
|
175
|
+
|
|
176
|
+
**Full-resolution probes starve the recorder.** `settle` screenshots twice a
|
|
177
|
+
second while recording. As PNG, one heavier app came back holding 76% of the
|
|
178
|
+
elapsed time; the probes now run through `baguette screenshot --scale 4`, a
|
|
179
|
+
twentieth of the pixels for ffmpeg to decode. A recording that solves below
|
|
180
|
+
**0.9x wall is treated as spoiled and fails**: the solve keeps the captions in
|
|
181
|
+
sync but the missing time cannot be put back, so the app's own motion plays fast
|
|
182
|
+
on top of whatever `speed` asked for.
|
|
183
|
+
|
|
184
|
+
**Scratch goes outside your repo** (`$TMPDIR/sprid/<repo>/<slug>`) unless a
|
|
185
|
+
lane sets `tmp`. Inside it, Metro's watcher sees every probe screenshot, sends a
|
|
186
|
+
Fast Refresh, and RN drops its blue "Refreshing…" banner over the frame.
|
|
187
|
+
|
|
188
|
+
**Input goes through `baguette`, which does not touch the machine.** It injects
|
|
189
|
+
through SimulatorKit's private symbols with the iOS-26 calling conventions: the
|
|
190
|
+
pointer never moves, the Simulator never comes to the front, and you can keep
|
|
191
|
+
working through a run. Its `input` command streams newline-delimited JSON
|
|
192
|
+
gestures, which is the part that matters - **we keep our own velocity curve**,
|
|
193
|
+
where a canned `swipe --duration` would be the linear interpolation that makes a
|
|
194
|
+
scripted scroll read as scripted.
|
|
195
|
+
|
|
196
|
+
Two details inside that, both of which cost a take:
|
|
197
|
+
|
|
198
|
+
- **Swipes stream, taps do not.** The streaming path is `IOHIDDigitizerDispatch`,
|
|
199
|
+
built for continuous gestures. React Native surfaces can accept a
|
|
200
|
+
streamed tap and the one UIKit surface, the tab bar, silently did not.
|
|
201
|
+
`baguette tap` lands on it every time, and a tap has no timing worth owning.
|
|
202
|
+
- **A tap must not move.** An earlier version jittered the touch a pixel or two
|
|
203
|
+
during the hold, on the theory that a finger is never still. Nothing can see it
|
|
204
|
+
- a screen recording has no cursor in it - and UIKit reads those moves as the
|
|
205
|
+
start of a drag and cancels the tap.
|
|
206
|
+
|
|
207
|
+
Without baguette it falls back to posting CGEvents through a compiled Swift
|
|
208
|
+
helper (`src/screen/simkit.swift`), and **that path owns the pointer for the
|
|
209
|
+
length of the run**. It is a fallback, not a preference: the Simulator ignores
|
|
210
|
+
events posted to its process, tested both plain and with the window-number
|
|
211
|
+
routing trick, so the global HID tap is the only other way in. simkit stays
|
|
212
|
+
either way, because it also rasterises the caption cards through CoreText -
|
|
213
|
+
Homebrew's ffmpeg has neither `drawtext` nor `subtitles`.
|
|
214
|
+
|
|
215
|
+
### What it needs
|
|
216
|
+
|
|
217
|
+
macOS with Xcode 26, an Apple Silicon Mac, a simulator with your app installed,
|
|
218
|
+
`ffmpeg`, and `brew install baguette`. No npm dependency. Without baguette it
|
|
219
|
+
still runs, on the CGEvent fallback, which needs the terminal to hold macOS
|
|
220
|
+
Accessibility permission and **takes over the pointer for the length of the
|
|
221
|
+
run**.
|
|
222
|
+
|
|
223
|
+
The status bar is dressed for a reel rather than for the store: the **host
|
|
224
|
+
clock**, not 9:41, and a battery in the seventies rather than 100% and charging.
|
|
225
|
+
9:41 reads as an advertisement, and the app's own content gives the real time
|
|
226
|
+
away anyway - a row logged during the recording carries the wall clock, so a
|
|
227
|
+
9:41 status bar above it is a tell. `statusBarTime` and `battery` in the spec
|
|
228
|
+
override both.
|
|
229
|
+
|
|
230
|
+
## Two kinds of post
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
films: { media: "out/<slug>.mp4", caption: "out/<slug>.caption.txt" }, // a reel
|
|
234
|
+
decks: { slides: "decks/<slug>/*.jpg", caption: "decks/<slug>/caption.txt" }, // a carousel
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A lane that names `slides:` is a carousel and needs to say nothing else; the
|
|
238
|
+
slug is the **directory**, its files are the slides, and **they are ordered by
|
|
239
|
+
filename**, so name them `01`, `02`, `03`. That order is the most consequential
|
|
240
|
+
thing about a deck and it belongs somewhere a person can see it, not in a JSON
|
|
241
|
+
array. The record hashes the slides *and their order*, since the same pictures
|
|
242
|
+
in a different order are a different post.
|
|
243
|
+
|
|
244
|
+
What each kind is measured and gated on:
|
|
245
|
+
|
|
246
|
+
| | reel | carousel |
|
|
247
|
+
|---|---|---|
|
|
248
|
+
| record | duration, dimensions, LUFS, sha256 | per-slide dimensions, ratio, sha256; count |
|
|
249
|
+
| gate | `duration` `loudness` `batchSpread` | `slides` `aspect` |
|
|
250
|
+
| both | `caption`, `provenance` | `caption`, `provenance` |
|
|
251
|
+
| in Sprid | one video on one slide, `contentType: reel` | one image per slide, `contentType: image` |
|
|
252
|
+
|
|
253
|
+
**`aspect` is the one that catches a real defect you cannot see locally.**
|
|
254
|
+
Instagram crops every later slide to the first slide's shape, so a deck mixing
|
|
255
|
+
4:5 and 1:1 publishes with the sides cut off things nobody checked - and every
|
|
256
|
+
local preview shows the files as they are, uncropped and fine.
|
|
257
|
+
|
|
258
|
+
A check that does not apply to a kind reports `skip`, never `pass`: a carousel
|
|
259
|
+
with four green video checks would be worth less than no check at all.
|
|
260
|
+
|
|
261
|
+
## The third kind: a post Sprid composes
|
|
262
|
+
|
|
263
|
+
**Most posts are words on a background, and those are rendered on the server** -
|
|
264
|
+
templates, backdrops, fonts, timing. There is no local file, so there is nothing
|
|
265
|
+
for ffmpeg to measure and nothing to upload. A `composed` lane is for wanting
|
|
266
|
+
the *copy* here anyway:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
carousels: {
|
|
270
|
+
kind: "composed",
|
|
271
|
+
spec: "social/specs/<slug>.json",
|
|
272
|
+
// format: <format id>, // choose from this account’s formats
|
|
273
|
+
// template: <template id>, // choose from this account’s templates
|
|
274
|
+
expect: { slides: [5, 9], words: [0, 30] },
|
|
275
|
+
neverSay: ["the phrase this account decided it does not say"],
|
|
276
|
+
sourcesRequired: true,
|
|
277
|
+
},
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
The spec IS the post:
|
|
281
|
+
|
|
282
|
+
```json
|
|
283
|
+
{ "slug": "…",
|
|
284
|
+
"slides": [{ "role": "hook", "text": "…" },
|
|
285
|
+
{ "role": "body", "text": "The plan includes 10 projects", "source": "docs/guides/plans.md" }],
|
|
286
|
+
"caption": "… \n\n#a #b #c" }
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**One caption per platform, when the repo has one.** Vocabulary can differ between
|
|
290
|
+
platforms. A lane may write a second file beside its caption,
|
|
291
|
+
`<caption>` with `.tiktok` before the extension (`out/<slug>.caption.tiktok.txt`).
|
|
292
|
+
`register` copies it to `social/captions/<slug>.tiktok.txt` and `draft` sends
|
|
293
|
+
it as `captionTiktok`; absent, TikTok gets the Instagram string.
|
|
294
|
+
|
|
295
|
+
**The tags go at the end of the caption.** Sprid publishes one string per
|
|
296
|
+
platform and has no hashtag field to send them to, so there is no `hashtags`
|
|
297
|
+
key here either - a spec carrying one is refused by name rather than quietly
|
|
298
|
+
ignored. The caption in the registry is therefore the whole published text,
|
|
299
|
+
which is what the tag-count gate and the drift check read.
|
|
300
|
+
|
|
301
|
+
`build` and `push` have nothing to do; `register`, `check`, `draft` and `status`
|
|
302
|
+
work exactly as they do for a file. What you get for it is three checks a
|
|
303
|
+
rendered post cannot have:
|
|
304
|
+
|
|
305
|
+
- **`words`, per card and never totalled.** A deck averaging twenty words with
|
|
306
|
+
one card at sixty is the deck an average hides, and the long card is the one a
|
|
307
|
+
reader stops on.
|
|
308
|
+
- **`voice`.** The phrases this account has decided it does not say, plus the
|
|
309
|
+
punctuation it does not use. The mechanical half of a voice; the rest still
|
|
310
|
+
needs a reader.
|
|
311
|
+
- **`sources`.** A card stating a number names the file it came from and the
|
|
312
|
+
check *opens* it. This is the whole gate for anything where a wrong figure is
|
|
313
|
+
the unforgivable error: a slide keeps reading true long after the file behind
|
|
314
|
+
it was renamed.
|
|
315
|
+
|
|
316
|
+
**It is opt-in, and skipping it is a real answer.** If the words do not have to
|
|
317
|
+
answer to anything in your repo, compose the post in Sprid and keep nothing
|
|
318
|
+
here.
|
|
319
|
+
|
|
320
|
+
## The registry
|
|
321
|
+
|
|
322
|
+
Committed to *your* repo:
|
|
323
|
+
|
|
324
|
+
```
|
|
325
|
+
social/posts/<slug>.json the record: sha256, size, sprid ids, how it was built
|
|
326
|
+
social/captions/<slug>.txt the caption, verbatim
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The directory used to be `social/reels/`, which is still read - a record already
|
|
330
|
+
committed in somebody's repo is not worth breaking over a rename - and `status`
|
|
331
|
+
says how many are still sitting there. Renaming it is the whole migration.
|
|
332
|
+
|
|
333
|
+
**No media in git.** The bytes go into Sprid's own storage through the presigned
|
|
334
|
+
upload, and the manifest keeps a sha256, so a file that no longer matches its
|
|
335
|
+
record is caught before it is pushed - without keeping the file.
|
|
336
|
+
|
|
337
|
+
The caption is copied *into* the registry rather than pointed at, because it is
|
|
338
|
+
usually generated into a scratch directory. It is the most reviewable artifact
|
|
339
|
+
most pipelines produce and it belongs where somebody can read it in a diff.
|
|
340
|
+
|
|
341
|
+
## Config
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
// sprid.config.ts
|
|
345
|
+
export default {
|
|
346
|
+
account: "myapp", // the ACCOUNT slug, not the workspace name
|
|
347
|
+
tokenEnv: "SPRID_PAT_MYAPP", // optional, and see Auth before skipping it
|
|
348
|
+
registry: "social/",
|
|
349
|
+
lanes: {
|
|
350
|
+
reels: {
|
|
351
|
+
// `media:` one file per post · `slides:` a deck of images
|
|
352
|
+
// · `kind: "composed"` is for server-rendered carousel copy
|
|
353
|
+
media: "path/to/output/<slug>.mp4",
|
|
354
|
+
caption: "path/to/output/<slug>.caption.txt",
|
|
355
|
+
expect: { durationMs: [15_000, 50_000], lufs: [-16, -12] },
|
|
356
|
+
hashtags: [4, 6],
|
|
357
|
+
captionMustContain: ["Photos via Wikimedia Commons:"],
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
checks: ["duration", "loudness", "batchSpread", "caption"],
|
|
361
|
+
};
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
A **lane** is where one kind of finished media lives. The package ships none,
|
|
365
|
+
because what a lane is depends entirely on how you make things. `<slug>` in a
|
|
366
|
+
path is the only substitution there is, and it is also how `register` discovers
|
|
367
|
+
what exists.
|
|
368
|
+
|
|
369
|
+
### The checks
|
|
370
|
+
|
|
371
|
+
| check | what it refuses |
|
|
372
|
+
|---|---|
|
|
373
|
+
| `duration` | a file outside the lane's range - usually a render that stopped early |
|
|
374
|
+
| `loudness` | integrated LUFS outside the range |
|
|
375
|
+
| `batchSpread` | a file more than `maxDeviationDb` (1.2) from **its own lane's** median. One file's loudness says nothing; the step between two posts in a row is what a listener hears |
|
|
376
|
+
| `caption` | a `neverSay` match, an invalid configured ending, a missing `captionMustContain` string, or a hashtag count outside `hashtags` |
|
|
377
|
+
| `slides` | a deck outside `expect.slides`. Instagram takes 20, TikTok 35, and a deck of two is a post that did not get made |
|
|
378
|
+
| `aspect` | a deck that mixes ratios, or slides under `expect.minWidth`. Instagram crops every later slide to the FIRST one's shape, so a mixed deck publishes with the sides cut off things nobody checked - and it looks right in every local preview |
|
|
379
|
+
| `provenance` | nothing. It reports whether the record knows the command that made the file, so a batch where most rows skip it is a batch whose provenance lives in somebody's terminal history |
|
|
380
|
+
|
|
381
|
+
`captionMustContain` is for licence terms rather than style. A Creative Commons
|
|
382
|
+
import is somebody's work and the licence is only free if the credit is there;
|
|
383
|
+
a Mapbox static image may only drop its on-image attribution if the attribution
|
|
384
|
+
appears beside it. Both are things you notice a week after publishing, or not at
|
|
385
|
+
all.
|
|
386
|
+
|
|
387
|
+
A check that cannot run reports `skip`, never `pass`.
|
|
388
|
+
|
|
389
|
+
## The record remembers how the file was made
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
sprid post register <slug> --built-with "make-reel --spec x.json --map --depth"
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Record the command that produced the measured bytes so the build can be repeated.
|
|
396
|
+
A lane with a `build:` records that command automatically; a
|
|
397
|
+
pipeline run by hand records it with `--built-with`; and the `provenance` check
|
|
398
|
+
reports - never refuses - when a record cannot say. The preview prints the line
|
|
399
|
+
under each card, one click to copy.
|
|
400
|
+
|
|
401
|
+
## `preview`, looking at the batch before it is real
|
|
402
|
+
|
|
403
|
+
```bash
|
|
404
|
+
bunx @sprid/cli post preview --serve # a local server, opens in your browser
|
|
405
|
+
bunx @sprid/cli post preview --open # file://, fine for watching, cannot scrub
|
|
406
|
+
bunx @sprid/cli post preview --lane collage --copy --out ~/Desktop/reels
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
It reads the registry and nothing else, so it is the same command in every repo,
|
|
410
|
+
and it writes one disposable HTML file with **two views**, because a batch and a
|
|
411
|
+
post are two different questions:
|
|
412
|
+
|
|
413
|
+
- **Grid.** Everything at once, small. Hover plays a reel and walks a deck, the filter matches slug and
|
|
414
|
+
caption, and a card carries the caption itself and nothing derived from it -
|
|
415
|
+
the length, the loudness, the check result and the Sprid state ride along as
|
|
416
|
+
badges. This is where you see repetition, an outlier, a caption that ran long.
|
|
417
|
+
- **Feed.** One post at reading size with its caption under it, arrow keys
|
|
418
|
+
between them, `m` for sound, `esc` back. A carousel advances by itself at
|
|
419
|
+
reading pace (`space` stops it, `[` `]` step it, the dots say how many are
|
|
420
|
+
left), because the question a batch review asks of a deck is whether it holds
|
|
421
|
+
together as one set, and that is a thing you watch rather than click through. It is the only view that answers
|
|
422
|
+
*would I stop on this*, because it is the shape a reader meets. Each reel has
|
|
423
|
+
its own `#slug` address, so a position is a link.
|
|
424
|
+
|
|
425
|
+
**`--serve` watches and reloads.** The registry, the lanes' media directories
|
|
426
|
+
and this package's own source are all watched; a change rebuilds the page and
|
|
427
|
+
refreshes the tab you already have open, and since the current reel lives in the
|
|
428
|
+
URL hash it comes back to where you were. Running the command again while a
|
|
429
|
+
server is up says so and opens nothing, rather than stacking tabs.
|
|
430
|
+
|
|
431
|
+
Media is **symlinked**, not copied: a batch is gigabytes and this page is meant
|
|
432
|
+
to be cheap enough to regenerate without thinking about it. `--copy` is for a
|
|
433
|
+
page that has to travel. **`--serve` is worth the extra word** - over `file://`
|
|
434
|
+
a browser cannot seek inside an mp4 and Safari will not play a symlink at all.
|
|
435
|
+
|
|
436
|
+
A registered reel whose file is not on disk is drawn as a **missing card**,
|
|
437
|
+
never dropped: the mp4s regenerate from their specs and routinely are not there,
|
|
438
|
+
and silently omitting missing files would hide incomplete work. A
|
|
439
|
+
file whose size no longer matches its record is badged instead of trusted
|
|
440
|
+
(`--verify` spends the sha256 rather than comparing sizes).
|
|
441
|
+
|
|
442
|
+
## What `draft` does
|
|
443
|
+
|
|
444
|
+
1. `POST /videos/upload-url` → PUT the file → `POST /videos/:id/confirm`
|
|
445
|
+
2. `POST /posts`
|
|
446
|
+
3. `PATCH /slides/:id { videoId }` - **this is what makes the post publish your
|
|
447
|
+
exact file** with its own audio, rather than be re-composed from the slide's
|
|
448
|
+
template in the render container
|
|
449
|
+
4. `PATCH /posts/:id { contentType: "reel", captions, location,
|
|
450
|
+
audio: { silent: true }, status: "draft" }` - the caption carries the
|
|
451
|
+
hashtags; there is no separate tag field
|
|
452
|
+
5. `GET /posts/:id`, and compare it to what was asked for
|
|
453
|
+
|
|
454
|
+
**`audio: { silent: true }` is not optional when the music is already in your
|
|
455
|
+
file.** A reel with no `trackAssetId` round-robins the account's track library,
|
|
456
|
+
so leaving `audio` unset lays a second bed over the first.
|
|
457
|
+
|
|
458
|
+
**Step 5 verifies that fields were stored.** An older server may accept a request
|
|
459
|
+
while ignoring fields its schema does not support. Read them back and retain any
|
|
460
|
+
mismatch in the registry so `status` can report it.
|
|
461
|
+
|
|
462
|
+
**A new post arrives with a slide already on it**, and `POST /posts` answers
|
|
463
|
+
with the post alone - no `slides` key. `draft` therefore reads the seats back,
|
|
464
|
+
adds the ones it is short of and deletes the ones it does not need. Trusting the
|
|
465
|
+
create response is how every draft ends up with an empty leading slide and the
|
|
466
|
+
real media behind it.
|
|
467
|
+
|
|
468
|
+
`draft` stops at `draft`. Moving a post to `ready` is the last gate before
|
|
469
|
+
something is public, and that stays a person's call.
|
|
470
|
+
|
|
471
|
+
## Local production and crossposting
|
|
472
|
+
|
|
473
|
+
The consuming repo owns media, specs and its build commands. Sprid owns accounts,
|
|
474
|
+
channels and publishing state. The registry connects them without requiring a
|
|
475
|
+
particular renderer or access to another repository.
|
|
476
|
+
|
|
477
|
+
The CLI bundles the rules used by its offline checks. `check --deep` asks the
|
|
478
|
+
server for the actual platform projection of an existing post. Review both the
|
|
479
|
+
content and each platform's title, caption and cover before publishing.
|
|
480
|
+
|
|
481
|
+
- `postTitle()` derives a title from the caption's first line when present and
|
|
482
|
+
reconciles it on subsequent drafts. Review it as published copy.
|
|
483
|
+
- `coverAtMs` selects a cover frame per lane. Choose a completed, readable frame.
|
|
484
|
+
- A `.tiktok.txt` sibling provides a separate TikTok caption; otherwise it uses
|
|
485
|
+
the shared caption. YouTube's description uses the shared caption.
|
|
486
|
+
|
|
487
|
+
## `sync`: a re-render, into posts that already exist
|
|
488
|
+
|
|
489
|
+
**A booked post is never edited. The source is re-rendered and the queue catches
|
|
490
|
+
up.** That is the design decision, and the alternative is worse than it sounds:
|
|
491
|
+
reaching into Sprid to swap a file on a scheduled post makes the queue the
|
|
492
|
+
source of truth for content, and then a spec and a published post can disagree
|
|
493
|
+
with nothing able to say which is right. Here the registry owns the content,
|
|
494
|
+
Sprid owns the calendar, and `sync` is the one command that reconciles them.
|
|
495
|
+
|
|
496
|
+
```bash
|
|
497
|
+
sprid post sync # what differs, and what it would send
|
|
498
|
+
sprid post sync --execute # send it
|
|
499
|
+
sprid post sync --lane collage # scope it
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
It diffs local against remote, uploads only what changed, re-points the existing
|
|
503
|
+
post's slide at the new file, and **leaves the schedule alone** - the post keeps
|
|
504
|
+
its slot. Three rules, two of them refusals:
|
|
505
|
+
|
|
506
|
+
- **A post that is out is frozen.** It reads the live status and writes only to
|
|
507
|
+
`draft` and `ready`. Anything else is skipped by name, including a status this
|
|
508
|
+
package has never heard of: an unknown state is far more likely to be further
|
|
509
|
+
along the pipeline than behind it. Re-posting is a new slug, not an edit.
|
|
510
|
+
- **It never creates a post.** No `postId`, no sync - that is `draft`'s job, so
|
|
511
|
+
a sync cannot widen a batch by accident.
|
|
512
|
+
- **It does the minimum.** Media changed, caption changed, or neither. A slug
|
|
513
|
+
whose caption moved does not get its video re-uploaded.
|
|
514
|
+
|
|
515
|
+
Changing media across a booked queue requires reconciling both uploaded media and post metadata.
|
|
516
|
+
`sync` keeps that operation reproducible.
|
|
517
|
+
|
|
518
|
+
**`mediaShaAtPush` is what makes it possible**, and it is the counterpart of
|
|
519
|
+
`captionShaAtDraft`, which existed from the start. A manifest written before it
|
|
520
|
+
reads as *unknown*, and unknown counts as stale - a record that cannot say what
|
|
521
|
+
was uploaded cannot say it is current. `sync --adopt` is the one-time migration
|
|
522
|
+
for a batch that was never rebuilt: it records what is on disk as what was
|
|
523
|
+
uploaded and sends nothing. It is an assertion and it can be wrong, so scope it.
|
|
524
|
+
|
|
525
|
+
## `doctor`: which Sprid, and can it be reached
|
|
526
|
+
|
|
527
|
+
**A credential names the service it belongs to. Use both halves or neither.**
|
|
528
|
+
|
|
529
|
+
`sprid login` writes `~/.sprid/credentials.json` with a token *and* the API it
|
|
530
|
+
was issued against. Reading a token from one configuration and defaulting to another
|
|
531
|
+
server can make a working service appear unavailable. Keep the token and URL paired.
|
|
532
|
+
|
|
533
|
+
- `credentials.json` is read for **both** the token and `apiUrl`, after
|
|
534
|
+
`SPRID_URL` and a repo's pinned `tokenEnv`.
|
|
535
|
+
- `sprid post doctor` dials **every** candidate URL it knows about, not only
|
|
536
|
+
the winner, and prints the account and its live channels from whichever
|
|
537
|
+
answers. A disagreement between candidates is the failure mode, so the command
|
|
538
|
+
shows the disagreement rather than resolving it silently.
|
|
539
|
+
|
|
540
|
+
```
|
|
541
|
+
yourapp using http://localhost:4005
|
|
542
|
+
|
|
543
|
+
→ ~/.sprid/credentials.json http://localhost:4005
|
|
544
|
+
default https://api.sprid.studio
|
|
545
|
+
|
|
546
|
+
http://localhost:4005 ok 175ms account 3 instagram:active tiktok:active youtube:active
|
|
547
|
+
https://api.sprid.studio unreachable TimeoutError
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**`status` prints the registry and `doctor` prints the world.** "Several posts drafted"
|
|
551
|
+
reads identically whether Sprid is up, down, or a different Sprid than the one
|
|
552
|
+
holding your posts; `doctor` is the command that can tell you which.
|
|
553
|
+
|
|
554
|
+
## `pull`: the schedule, back into the registry
|
|
555
|
+
|
|
556
|
+
**The queue lives in Sprid, and until `pull` nothing local could read it.** A
|
|
557
|
+
post is committed; when it publishes was not. So the repo could not answer "what
|
|
558
|
+
goes out tomorrow" or "which one went out last" without the API being up, and a
|
|
559
|
+
booking made weeks ago left no trace anybody could review in a diff.
|
|
560
|
+
|
|
561
|
+
`pull` writes each post's status and dates into `sprid.remote` on its manifest,
|
|
562
|
+
which is what lets `status` print a calendar and work offline.
|
|
563
|
+
|
|
564
|
+
> **It was written blind.** The API was unreachable for the whole session it
|
|
565
|
+
> went in, so `scheduleOf` tries the key names a post plausibly carries and
|
|
566
|
+
> returns null rather than inventing one. `pull` prints how many posts it could
|
|
567
|
+
> not read a date from - **if that is not zero on the first real run, put the
|
|
568
|
+
> real key in `scheduleOf` and add it to the test.** `sync` has no such doubt:
|
|
569
|
+
> every call it makes is one `push` and `draft` were already making.
|
|
570
|
+
|
|
571
|
+
## Auth
|
|
572
|
+
|
|
573
|
+
`SPRID_TOKEN`, this repo's `.env`, or `~/.sprid/token`. Make one in Sprid under
|
|
574
|
+
Settings → Tokens; it starts with `sprd_`. `SPRID_URL` overrides the API host,
|
|
575
|
+
default `https://api.sprid.studio`.
|
|
576
|
+
|
|
577
|
+
**A PAT is workspace-scoped, and a machine that posts for three brands holds
|
|
578
|
+
three of them.** `tokenEnv` names the one this repo is allowed to use, and when
|
|
579
|
+
it is set nothing else is tried - a run that quietly fell back to the ambient
|
|
580
|
+
token would authenticate against workspaces this repo must not be able to reach,
|
|
581
|
+
and nothing in the output would say so. Set it in any repo that is not the only
|
|
582
|
+
one on the machine.
|
|
583
|
+
|
|
584
|
+
## Account-specific copy checks
|
|
585
|
+
|
|
586
|
+
`neverSay` lists exact strings forbidden in captions and composed slide text.
|
|
587
|
+
Punctuation is allowed unless configured there, for example `["—", "!"]` for an
|
|
588
|
+
account that avoids those characters. `captionEndsSentence` optionally requires
|
|
589
|
+
terminal punctuation. `captionEndWords` optionally lists trailing words that
|
|
590
|
+
indicate an incomplete caption in this account's language; the default is empty.
|
|
591
|
+
Do not reuse another account's phrase list or language rules without reviewing them.
|
|
592
|
+
|
|
593
|
+
## Destination selection and structured output
|
|
594
|
+
|
|
595
|
+
Local media can come from any renderer or framework. The `screen` recipe is an
|
|
596
|
+
optional iOS simulator workflow; existing Expo projects can keep their capture
|
|
597
|
+
scripts. Finished MP4s and image decks do not require Expo or Xcode. A custom
|
|
598
|
+
`build` command can run a named slug without a Sprid spec; built-in recipes still
|
|
599
|
+
require their specs. Use `--lane` when multiple builders could handle that slug.
|
|
600
|
+
|
|
601
|
+
Set `platforms: ["instagram"]` at the config or lane level to check only your
|
|
602
|
+
intended destinations in the `crosspost` check. A post spec/manifest can override it; `check --platforms
|
|
603
|
+
instagram,tiktok` overrides the selection for that check. `register --platforms
|
|
604
|
+
instagram` records a post's selection. With no selection, the existing Instagram,
|
|
605
|
+
TikTok and YouTube checks remain the default. This selects validation targets;
|
|
606
|
+
publishing destinations are still chosen when scheduling in Sprid. Local projection
|
|
607
|
+
rules currently cover Instagram, TikTok and YouTube; other platforms report a
|
|
608
|
+
skip, and `--deep` reads the booked post’s server projection. Media checks retain
|
|
609
|
+
their existing defaults and can be customized with `lane.expect`.
|
|
610
|
+
|
|
611
|
+
`sprid post <command> --json` writes one JSON result to stdout and diagnostics to
|
|
612
|
+
stderr, including output from custom builders. Results include `command`,
|
|
613
|
+
`account` and registry `posts` when available; checks include failures and a
|
|
614
|
+
nonzero exit status. `post help --json` includes the usage text. Original argument
|
|
615
|
+
order is preserved, including values of local flags such as `--lane` and `--out`.
|