ossclip 0.1.31 → 0.1.34
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/README.md +2 -0
- package/editor-dist/assets/index-Dn813PGp.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/caption-regen-panel.ts +74 -0
- package/src/caption-report.ts +26 -1
- package/src/edit.ts +1016 -3
- package/src/produce.ts +374 -22
- package/src/program.ts +119 -1
- package/src/publish.ts +696 -0
- package/src/render-replay-args.ts +50 -0
- package/src/replay-argv.ts +16 -0
- package/src/ui/animation.ts +5 -1
- package/editor-dist/assets/index-C537KpNT.js +0 -166
package/src/publish.ts
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { InvalidArgumentError } from "commander";
|
|
5
|
+
import { z } from "zod/v4";
|
|
6
|
+
import {
|
|
7
|
+
PLATFORM_SIZE_CAP_BYTES,
|
|
8
|
+
YOUTUBE_APPROVED_BASENAME,
|
|
9
|
+
YoutubePackSchema,
|
|
10
|
+
buildPostsPayload,
|
|
11
|
+
captionForProvider,
|
|
12
|
+
checkDurationCaps,
|
|
13
|
+
createPostizProvider,
|
|
14
|
+
deliveryEncodePlan,
|
|
15
|
+
encodeEta,
|
|
16
|
+
ensureDeliveryFile,
|
|
17
|
+
formatMinSec,
|
|
18
|
+
loadConfig,
|
|
19
|
+
probe,
|
|
20
|
+
type DeliveryPlan,
|
|
21
|
+
type DurationViolation,
|
|
22
|
+
type OssclipConfig,
|
|
23
|
+
type PublishPost,
|
|
24
|
+
type PublishProvider,
|
|
25
|
+
type PublishReceipt,
|
|
26
|
+
type PublishTarget,
|
|
27
|
+
type PublishWhen,
|
|
28
|
+
type YoutubePack,
|
|
29
|
+
} from "@ossclip/core";
|
|
30
|
+
import { readRecordedCommand, recordedOutPath } from "./cover";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `ossclip publish` — push the finished render to the user's own social
|
|
34
|
+
* accounts through their self-hosted Postiz instance (publish/postiz.ts has
|
|
35
|
+
* the API contract). This file is the CLI orchestration; every decision that
|
|
36
|
+
* can be pure IS pure and exported, so the edit server's /api/publish speaks
|
|
37
|
+
* the same code instead of a second spelling of it.
|
|
38
|
+
*
|
|
39
|
+
* Publishing is ALWAYS an explicit act: the human-review doctrine (README's
|
|
40
|
+
* "review the output before publishing") means nothing here runs at the end
|
|
41
|
+
* of produce, and a workdir that already published refuses to double-post
|
|
42
|
+
* without --force.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** The double-post guard's file — holds the last `PublishReceipt`. */
|
|
46
|
+
export const PUBLISH_RECEIPT_BASENAME = "publish-receipt.json";
|
|
47
|
+
|
|
48
|
+
export function publishReceiptPath(workdir: string): string {
|
|
49
|
+
return join(workdir, PUBLISH_RECEIPT_BASENAME);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `--at <iso>` → a validated FUTURE instant (§93a: reject, never coerce — a
|
|
54
|
+
* typo'd date must not schedule a post for 1970 or fire immediately).
|
|
55
|
+
* Exported so the rejection matrix is testable without commander's exit.
|
|
56
|
+
*/
|
|
57
|
+
export function atFlag(v: string, now: () => number = Date.now): string {
|
|
58
|
+
const ms = Date.parse(v);
|
|
59
|
+
if (Number.isNaN(ms)) {
|
|
60
|
+
throw new InvalidArgumentError(
|
|
61
|
+
`--at wants an ISO-8601 time like 2026-09-01T08:00:00+02:00, got "${v}"`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (ms <= now()) {
|
|
65
|
+
throw new InvalidArgumentError(`--at wants a time in the future, got "${v}" (already passed)`);
|
|
66
|
+
}
|
|
67
|
+
return new Date(ms).toISOString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `--platforms a,b` → trimmed, lowercased, deduped identifiers. Rejects an
|
|
71
|
+
* empty list; whether an identifier MATCHES anything is checked against the
|
|
72
|
+
* live integrations list, where the error can name what actually exists. */
|
|
73
|
+
export function platformsFlag(v: string): string[] {
|
|
74
|
+
const list = [...new Set(v.split(",").map((p) => p.trim().toLowerCase()).filter((p) => p.length > 0))];
|
|
75
|
+
if (list.length === 0) {
|
|
76
|
+
throw new InvalidArgumentError(`--platforms wants a comma-separated list like "linkedin,instagram", got "${v}"`);
|
|
77
|
+
}
|
|
78
|
+
return list;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `--accounts id1,id2` → trimmed ids (matched against integration ids). */
|
|
82
|
+
export function accountsFlag(v: string): string[] {
|
|
83
|
+
const list = v.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
|
|
84
|
+
if (list.length === 0) {
|
|
85
|
+
throw new InvalidArgumentError(`--accounts wants a comma-separated list of integration ids, got "${v}"`);
|
|
86
|
+
}
|
|
87
|
+
return list;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Postiz configuration, resolved: `postizUrl` from config.json (non-secret),
|
|
92
|
+
* the API key from the environment ONLY (env.ts's rule — secrets never live
|
|
93
|
+
* in config.json). The error names exactly what's missing and where it goes,
|
|
94
|
+
* because "publish isn't configured" without the fix is a support ticket.
|
|
95
|
+
*/
|
|
96
|
+
export type PublishConfig =
|
|
97
|
+
| { ok: true; baseUrl: string; apiKey: string }
|
|
98
|
+
| { ok: false; message: string };
|
|
99
|
+
|
|
100
|
+
export const POSTIZ_API_KEY_ENV = "OSSCLIP_POSTIZ_API_KEY";
|
|
101
|
+
|
|
102
|
+
export function publishConfigured(
|
|
103
|
+
config: Pick<OssclipConfig, "postizUrl">,
|
|
104
|
+
env: NodeJS.ProcessEnv,
|
|
105
|
+
): PublishConfig {
|
|
106
|
+
const url = typeof config.postizUrl === "string" ? config.postizUrl.trim() : "";
|
|
107
|
+
const key = env[POSTIZ_API_KEY_ENV]?.trim() ?? "";
|
|
108
|
+
const missing: string[] = [];
|
|
109
|
+
if (url.length === 0) missing.push(`"postizUrl" in ~/.ossclip/config.json (your Postiz instance's URL)`);
|
|
110
|
+
if (key.length === 0) missing.push(`${POSTIZ_API_KEY_ENV} in the environment or ~/.ossclip/.env (Postiz → Settings → Public API)`);
|
|
111
|
+
if (missing.length > 0) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
message: `publish needs a self-hosted Postiz instance (https://postiz.com). Missing: ${missing.join("; ")}.`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { ok: true, baseUrl: url, apiKey: key };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Which connected accounts this run posts to — the pure selection rules the
|
|
122
|
+
* CLI flags and the editor's checkbox list both compile down to.
|
|
123
|
+
*
|
|
124
|
+
* - `accounts` picks by integration id, and an unknown id is an ERROR naming
|
|
125
|
+
* the known ones (a typo must not silently post to fewer places);
|
|
126
|
+
* - `platforms` filters by provider identifier, and a platform with no
|
|
127
|
+
* connected account is an ERROR naming what IS connected;
|
|
128
|
+
* - `all` (or the editor sending explicit ids) takes what's left.
|
|
129
|
+
* An empty result is always an error — "publish to nothing" is never intent.
|
|
130
|
+
*/
|
|
131
|
+
export function selectTargets(
|
|
132
|
+
targets: PublishTarget[],
|
|
133
|
+
opts: { platforms?: string[]; accounts?: string[]; all?: boolean },
|
|
134
|
+
): PublishTarget[] {
|
|
135
|
+
const available = () =>
|
|
136
|
+
targets.map((t) => ` ${t.id} ${t.provider} ${t.name}`).join("\n") || " (none connected)";
|
|
137
|
+
let pool = targets;
|
|
138
|
+
if (opts.platforms) {
|
|
139
|
+
for (const p of opts.platforms) {
|
|
140
|
+
if (!targets.some((t) => t.provider === p)) {
|
|
141
|
+
throw new Error(`no connected ${p} account in Postiz. Connected:\n${available()}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
pool = pool.filter((t) => opts.platforms!.includes(t.provider));
|
|
145
|
+
}
|
|
146
|
+
if (opts.accounts) {
|
|
147
|
+
const picked: PublishTarget[] = [];
|
|
148
|
+
for (const id of opts.accounts) {
|
|
149
|
+
const hit = pool.find((t) => t.id === id);
|
|
150
|
+
if (!hit) throw new Error(`no integration with id "${id}". Connected:\n${available()}`);
|
|
151
|
+
picked.push(hit);
|
|
152
|
+
}
|
|
153
|
+
return picked;
|
|
154
|
+
}
|
|
155
|
+
if (opts.all) {
|
|
156
|
+
if (pool.length === 0) throw new Error(`no connected accounts in Postiz. Connected:\n${available()}`);
|
|
157
|
+
return pool;
|
|
158
|
+
}
|
|
159
|
+
return pool;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Targets → the posts a publish sends: caption per platform from the pack
|
|
164
|
+
* (captionForProvider owns the authored-else-derived rule), and YouTube gets
|
|
165
|
+
* the pack's first title — the one required settings field Postiz won't
|
|
166
|
+
* default.
|
|
167
|
+
*/
|
|
168
|
+
export function buildPublishPosts(
|
|
169
|
+
pack: YoutubePack,
|
|
170
|
+
targets: PublishTarget[],
|
|
171
|
+
opts: { youtubePrivacy?: YoutubePrivacy } = {},
|
|
172
|
+
): PublishPost[] {
|
|
173
|
+
return targets.map((target) => ({
|
|
174
|
+
target,
|
|
175
|
+
caption: captionForProvider(pack, target.provider),
|
|
176
|
+
...(target.provider === "youtube"
|
|
177
|
+
? {
|
|
178
|
+
title: pack.titles[0],
|
|
179
|
+
// Undefined here means `buildPostsPayload`'s own safe default
|
|
180
|
+
// (private) — one place decides it, not two (2026-08-28).
|
|
181
|
+
...(opts.youtubePrivacy !== undefined
|
|
182
|
+
? { youtubePrivacy: opts.youtubePrivacy }
|
|
183
|
+
: {}),
|
|
184
|
+
}
|
|
185
|
+
: {}),
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** `--youtube-privacy` — the values Postiz's own DTO accepts, nothing else. */
|
|
190
|
+
export const YOUTUBE_PRIVACIES = ["public", "unlisted", "private"] as const;
|
|
191
|
+
export type YoutubePrivacy = (typeof YOUTUBE_PRIVACIES)[number];
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* `--youtube-privacy <public|unlisted|private>` → a validated choice.
|
|
195
|
+
* Rejected rather than coerced (§93a, the `--clip` idiom): a typo'd
|
|
196
|
+
* `--youtube-privacy pubic` must not silently fall back to a value that
|
|
197
|
+
* publishes to a subscriber list. Exported so the rejection matrix is
|
|
198
|
+
* testable without commander's exit behaviour.
|
|
199
|
+
*/
|
|
200
|
+
export function youtubePrivacyFlag(v: string): YoutubePrivacy {
|
|
201
|
+
const found = YOUTUBE_PRIVACIES.find((p) => p === v.trim());
|
|
202
|
+
if (found === undefined) {
|
|
203
|
+
throw new InvalidArgumentError(
|
|
204
|
+
`--youtube-privacy wants one of ${YOUTUBE_PRIVACIES.join(", ")}, got "${v}"`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** `--delivery` — what actually uploads (2026-08-29 handoff, item 1). */
|
|
211
|
+
export const DELIVERY_MODES = ["auto", "master"] as const;
|
|
212
|
+
export type DeliveryMode = (typeof DELIVERY_MODES)[number];
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `--delivery <auto|master>` → a validated choice, zod-parsed and rejected
|
|
216
|
+
* rather than coerced (§93a): a typo'd `--delivery masterr` silently falling
|
|
217
|
+
* back to `auto` would re-encode the one run where the user explicitly wanted
|
|
218
|
+
* the untouched master. Exported so the rejection matrix is testable without
|
|
219
|
+
* commander's exit behaviour.
|
|
220
|
+
*/
|
|
221
|
+
export function deliveryFlag(v: string): DeliveryMode {
|
|
222
|
+
const parsed = z.enum(DELIVERY_MODES).safeParse(v.trim());
|
|
223
|
+
if (!parsed.success) {
|
|
224
|
+
throw new InvalidArgumentError(
|
|
225
|
+
`--delivery wants one of ${DELIVERY_MODES.join(", ")}, got "${v}"`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return parsed.data;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Seconds → "5:20" for the duration-cap messages — a cap named in seconds
|
|
232
|
+
* ("video is 320s") makes the user do the platform's arithmetic. Moved to
|
|
233
|
+
* core with the encode-progress work (2026-08-29) so the editor server spells
|
|
234
|
+
* ETAs the same way; re-exported here so callers keep their import. */
|
|
235
|
+
export { formatMinSec } from "@ossclip/core";
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The encode-progress line: `▸ encoding delivery … 42% · ~1:50 left (1.6x)`.
|
|
239
|
+
* ETA and speed drop off rather than print garbage when ffmpeg hasn't said
|
|
240
|
+
* yet (its warm-up block is all N/A). Pure so the wording is pinned by a
|
|
241
|
+
* test; whether it lands as a \r-rewrite or a log line is the TTY shell's
|
|
242
|
+
* call below.
|
|
243
|
+
*/
|
|
244
|
+
export function encodeProgressLine(
|
|
245
|
+
durationSec: number,
|
|
246
|
+
p: { outTimeSec?: number; speed?: number },
|
|
247
|
+
): string {
|
|
248
|
+
const pct =
|
|
249
|
+
durationSec > 0 && p.outTimeSec !== undefined
|
|
250
|
+
? Math.min(100, Math.floor((p.outTimeSec / durationSec) * 100))
|
|
251
|
+
: 0;
|
|
252
|
+
const eta =
|
|
253
|
+
p.outTimeSec !== undefined && p.speed !== undefined
|
|
254
|
+
? encodeEta(durationSec, p.outTimeSec, p.speed)
|
|
255
|
+
: null;
|
|
256
|
+
const tail =
|
|
257
|
+
eta !== null ? ` · ~${formatMinSec(eta)} left (${p.speed!.toFixed(1)}x)` : "";
|
|
258
|
+
return `▸ encoding delivery … ${pct}%${tail}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* One loud line per refused channel — the platform hard-fails an over-cap
|
|
263
|
+
* upload anyway (the 5:20 take was doomed on Threads' 5:00 cap before a
|
|
264
|
+
* single byte uploaded), so the refusal names the cap instead of letting the
|
|
265
|
+
* platform's opaque error do it. Pure so the wording is pinned by a test.
|
|
266
|
+
*/
|
|
267
|
+
export function durationCapMessages(violations: DurationViolation[], durationSec: number): string[] {
|
|
268
|
+
return violations.map(
|
|
269
|
+
(v) =>
|
|
270
|
+
`▸ ${v.target.provider} capped at ${formatMinSec(v.capSec)}, video is ` +
|
|
271
|
+
`${formatMinSec(durationSec)} — skipping ${v.target.name}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The surviving targets grouped by their platform's byte ceiling
|
|
277
|
+
* (`PLATFORM_SIZE_CAP_BYTES`) — one delivery encode per distinct cap, not per
|
|
278
|
+
* channel, so two capped accounts on the same platform share one file.
|
|
279
|
+
* Uncapped targets don't appear; they ride the default delivery encode.
|
|
280
|
+
*/
|
|
281
|
+
export function sizeCapGroups(targets: PublishTarget[]): Map<number, PublishTarget[]> {
|
|
282
|
+
const groups = new Map<number, PublishTarget[]>();
|
|
283
|
+
for (const t of targets) {
|
|
284
|
+
const capBytes = PLATFORM_SIZE_CAP_BYTES[t.provider];
|
|
285
|
+
if (capBytes === undefined) continue;
|
|
286
|
+
const group = groups.get(capBytes);
|
|
287
|
+
if (group !== undefined) group.push(t);
|
|
288
|
+
else groups.set(capBytes, [t]);
|
|
289
|
+
}
|
|
290
|
+
return groups;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* One loud line per refused channel when a platform's size cap cannot be met
|
|
295
|
+
* above the quality floor (delivery.ts: below ~1 Mbps, 1080p h264 is mush the
|
|
296
|
+
* platform would host forever) — same drop-and-continue semantics as the
|
|
297
|
+
* duration caps. The line carries the arithmetic that doomed the channel so
|
|
298
|
+
* the fix (shorten, or publish by hand) is obvious. Pure so the wording is
|
|
299
|
+
* pinned by a test.
|
|
300
|
+
*/
|
|
301
|
+
export function sizeCapUnattainableMessages(
|
|
302
|
+
group: PublishTarget[],
|
|
303
|
+
capBytes: number,
|
|
304
|
+
fittedKbps: number,
|
|
305
|
+
durationSec: number,
|
|
306
|
+
): string[] {
|
|
307
|
+
const capMb = Math.round(capBytes / 1_000_000);
|
|
308
|
+
return group.map(
|
|
309
|
+
(t) =>
|
|
310
|
+
`▸ ${t.provider} needs ≤${capMb}MB but a ${formatMinSec(durationSec)} video fits only ` +
|
|
311
|
+
`~${fittedKbps} kbps — skipping ${t.name}; publish it manually or shorten the cut`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The over-cap warning for `--delivery master`: master mode bypasses the
|
|
317
|
+
* encode entirely, INCLUDING the size-capped variant, so a capped platform
|
|
318
|
+
* may get a file its ingest will bounce (Instagram's 2207077). The user
|
|
319
|
+
* explicitly chose master, so this warns loudly and proceeds — the one size
|
|
320
|
+
* decision the user is allowed to overrule. Pure so the wording is pinned.
|
|
321
|
+
*/
|
|
322
|
+
export function masterOverCapWarning(
|
|
323
|
+
group: PublishTarget[],
|
|
324
|
+
capBytes: number,
|
|
325
|
+
masterBytes: number,
|
|
326
|
+
): string {
|
|
327
|
+
const providers = [...new Set(group.map((t) => t.provider))].join(", ");
|
|
328
|
+
return (
|
|
329
|
+
`▸ WARNING: the master is ${Math.round(masterBytes / 1_000_000)}MB, over the ` +
|
|
330
|
+
`${Math.round(capBytes / 1_000_000)}MB ${providers} cap — uploading it anyway (--delivery master)`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Posts → posts with their per-platform media override set: a size-capped
|
|
336
|
+
* platform carries its own smaller encode (`PublishPost.videoPath`), everyone
|
|
337
|
+
* else rides the request's default. A capped path that EQUALS the default
|
|
338
|
+
* (the fitted bitrate came out at the 10 Mbps target, so both plans named the
|
|
339
|
+
* same file) sets no override — the provider would dedupe the upload anyway,
|
|
340
|
+
* but a redundant override obscures which posts genuinely differ.
|
|
341
|
+
*/
|
|
342
|
+
export function attachDeliveryMedia(
|
|
343
|
+
posts: PublishPost[],
|
|
344
|
+
defaultPath: string,
|
|
345
|
+
cappedPaths: ReadonlyMap<number, string>,
|
|
346
|
+
): PublishPost[] {
|
|
347
|
+
return posts.map((p) => {
|
|
348
|
+
const capBytes = PLATFORM_SIZE_CAP_BYTES[p.target.provider];
|
|
349
|
+
const path = capBytes !== undefined ? cappedPaths.get(capBytes) : undefined;
|
|
350
|
+
return path !== undefined && path !== defaultPath ? { ...p, videoPath: path } : p;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* What the confirm prompt says will upload — decided BEFORE the "yes" so the
|
|
356
|
+
* user approves the actual file, not a surprise re-encode after it. Pure;
|
|
357
|
+
* the encode itself runs post-confirm.
|
|
358
|
+
*/
|
|
359
|
+
export function describeUpload(mode: DeliveryMode, plan: DeliveryPlan | null): string {
|
|
360
|
+
if (mode === "master") return "master (--delivery master)";
|
|
361
|
+
return plan === null
|
|
362
|
+
? "master (already within delivery limits)"
|
|
363
|
+
: `${plan.fileName} (delivery encode, cached in workdir)`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The pack this publish reads: the APPROVED file when the editor wrote one
|
|
368
|
+
* (an edited pack is the user's decision), else the provider-keyed cache
|
|
369
|
+
* produce wrote. No pack, no publish — the copy is the pack's job, and a
|
|
370
|
+
* publish that invents captions on the spot would end-run the review gate.
|
|
371
|
+
*/
|
|
372
|
+
export async function loadPublishPack(workdir: string): Promise<YoutubePack | null> {
|
|
373
|
+
const approved = join(workdir, YOUTUBE_APPROVED_BASENAME);
|
|
374
|
+
const candidates = [approved];
|
|
375
|
+
try {
|
|
376
|
+
const { readdir, stat } = await import("node:fs/promises");
|
|
377
|
+
const caches: Array<{ path: string; mtime: number }> = [];
|
|
378
|
+
for (const name of await readdir(workdir)) {
|
|
379
|
+
if (/^youtube-.+\.json$/.test(name) && name !== YOUTUBE_APPROVED_BASENAME) {
|
|
380
|
+
const path = join(workdir, name);
|
|
381
|
+
caches.push({ path, mtime: (await stat(path)).mtimeMs });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Newest cache first — the edit server's currentYoutubePack rule: what
|
|
385
|
+
// the LAST produce generated, not whichever key readdir happens to list.
|
|
386
|
+
caches.sort((a, b) => b.mtime - a.mtime);
|
|
387
|
+
candidates.push(...caches.map((c) => c.path));
|
|
388
|
+
} catch {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
for (const path of candidates) {
|
|
392
|
+
if (!existsSync(path)) continue;
|
|
393
|
+
try {
|
|
394
|
+
const raw = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
395
|
+
// Cache files wrap the pack ({pack: ...}) in some produce versions;
|
|
396
|
+
// accept both spellings and validate either way.
|
|
397
|
+
const inner =
|
|
398
|
+
typeof raw === "object" && raw !== null && "pack" in (raw as Record<string, unknown>)
|
|
399
|
+
? (raw as { pack: unknown }).pack
|
|
400
|
+
: raw;
|
|
401
|
+
const parsed = YoutubePackSchema.safeParse(inner);
|
|
402
|
+
if (parsed.success) return parsed.data;
|
|
403
|
+
} catch {
|
|
404
|
+
// unreadable candidate — try the next
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export async function readPublishReceipt(workdir: string): Promise<PublishReceipt | null> {
|
|
411
|
+
const path = publishReceiptPath(workdir);
|
|
412
|
+
if (!existsSync(path)) return null;
|
|
413
|
+
try {
|
|
414
|
+
return JSON.parse(await readFile(path, "utf8")) as PublishReceipt;
|
|
415
|
+
} catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** One line per outgoing post, for the confirm prompt and --dry-run. */
|
|
421
|
+
export function summarizePosts(posts: PublishPost[], when: PublishWhen): string {
|
|
422
|
+
const head =
|
|
423
|
+
when.kind === "now" ? "publish NOW" : `schedule for ${when.iso}`;
|
|
424
|
+
const rows = posts.map((p) => {
|
|
425
|
+
const firstLine = p.caption.split("\n")[0] ?? "";
|
|
426
|
+
const preview = firstLine.length > 60 ? `${firstLine.slice(0, 57)}...` : firstLine;
|
|
427
|
+
return ` ${p.target.provider.padEnd(10)} ${p.target.name.padEnd(20)} ${p.caption.length} chars "${preview}"`;
|
|
428
|
+
});
|
|
429
|
+
return [`▸ ${head} → ${posts.length} account(s):`, ...rows].join("\n");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface PublishFlags {
|
|
433
|
+
at?: string;
|
|
434
|
+
platforms?: string[];
|
|
435
|
+
accounts?: string[];
|
|
436
|
+
all?: boolean;
|
|
437
|
+
dryRun?: boolean;
|
|
438
|
+
yes?: boolean;
|
|
439
|
+
force?: boolean;
|
|
440
|
+
/** `--youtube-privacy`; undefined = the payload's safe `private` default. */
|
|
441
|
+
youtubePrivacy?: YoutubePrivacy;
|
|
442
|
+
/** `--delivery`; undefined = `auto` (upload the delivery encode). */
|
|
443
|
+
delivery?: DeliveryMode;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* The command. Everything above is the logic; this is the I/O shell —
|
|
448
|
+
* resolve artifacts, pick targets (interactive when nothing selected them),
|
|
449
|
+
* confirm, publish, write the receipt.
|
|
450
|
+
*/
|
|
451
|
+
export async function runPublish(
|
|
452
|
+
workdir: string,
|
|
453
|
+
flags: PublishFlags,
|
|
454
|
+
deps: {
|
|
455
|
+
provider?: PublishProvider;
|
|
456
|
+
config?: OssclipConfig;
|
|
457
|
+
env?: NodeJS.ProcessEnv;
|
|
458
|
+
/** The two ffmpeg-family shell-outs, injectable so tests never spawn a
|
|
459
|
+
* binary (the `provider` seam applied to ffprobe/ffmpeg): `probeVideo`
|
|
460
|
+
* feeds the duration caps and the upload summary, `ensureDelivery`
|
|
461
|
+
* builds — or reuses — the cached delivery encode. */
|
|
462
|
+
probeVideo?: typeof probe;
|
|
463
|
+
ensureDelivery?: typeof ensureDeliveryFile;
|
|
464
|
+
} = {},
|
|
465
|
+
): Promise<void> {
|
|
466
|
+
const config = deps.config ?? loadConfig();
|
|
467
|
+
const configured = publishConfigured(config, deps.env ?? process.env);
|
|
468
|
+
if (!configured.ok) throw new Error(configured.message);
|
|
469
|
+
|
|
470
|
+
const cmd = await readRecordedCommand(workdir);
|
|
471
|
+
const out = cmd ? recordedOutPath(cmd) : null;
|
|
472
|
+
if (out === null || !existsSync(out)) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`no finished render in ${workdir} — run \`ossclip produce\` (or re-render in the editor) first`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const pack = await loadPublishPack(workdir);
|
|
478
|
+
if (pack === null) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
"no YouTube pack in this workdir — publish reads its captions from the pack. " +
|
|
481
|
+
"Run produce with --youtube (or approve a pack in the editor) first",
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const receipt = await readPublishReceipt(workdir);
|
|
486
|
+
if (receipt !== null && flags.force !== true) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`this workdir already published on ${receipt.publishedAt} to ` +
|
|
489
|
+
`${receipt.targets.map((t) => t.provider).join(", ")} — pass --force to publish again`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const provider =
|
|
494
|
+
deps.provider ??
|
|
495
|
+
createPostizProvider({ baseUrl: configured.baseUrl, apiKey: configured.apiKey });
|
|
496
|
+
const targets = await provider.listTargets();
|
|
497
|
+
|
|
498
|
+
let picked = selectTargets(targets, {
|
|
499
|
+
platforms: flags.platforms,
|
|
500
|
+
accounts: flags.accounts,
|
|
501
|
+
all: flags.all,
|
|
502
|
+
});
|
|
503
|
+
const nothingExplicit = !flags.accounts && !flags.all;
|
|
504
|
+
if (nothingExplicit) {
|
|
505
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
506
|
+
if (!isInteractive()) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
"no accounts selected — pass --all, --accounts <ids> or --platforms <list>. Connected:\n" +
|
|
509
|
+
targets.map((t) => ` ${t.id} ${t.provider} ${t.name}`).join("\n"),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const { multiselect, unwrap } = await import("./interactive/prompts");
|
|
513
|
+
picked = unwrap(
|
|
514
|
+
await multiselect({
|
|
515
|
+
message: "Publish to which accounts?",
|
|
516
|
+
options: picked.map((t) => ({ value: t, label: `${t.provider} — ${t.name}` })),
|
|
517
|
+
required: true,
|
|
518
|
+
}),
|
|
519
|
+
) as PublishTarget[];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Probe ONCE, before the confirm prompt: the duration caps and the upload
|
|
523
|
+
// summary both need it, and a channel this video can never land on must be
|
|
524
|
+
// refused before the user says yes — not after minutes of x264 (2026-08-29
|
|
525
|
+
// handoff: Threads' 5:00 cap vs a 5:20 take).
|
|
526
|
+
const tools = { ffmpegPath: config.ffmpegPath, ffprobePath: config.ffprobePath };
|
|
527
|
+
const masterProbe = await (deps.probeVideo ?? probe)(tools, out);
|
|
528
|
+
const violations = checkDurationCaps(picked, masterProbe.duration);
|
|
529
|
+
if (violations.length > 0) {
|
|
530
|
+
for (const line of durationCapMessages(violations, masterProbe.duration)) console.log(line);
|
|
531
|
+
const over = new Set(violations.map((v) => v.target.id));
|
|
532
|
+
picked = picked.filter((t) => !over.has(t.id));
|
|
533
|
+
if (picked.length === 0) {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`every selected channel refuses a ${formatMinSec(masterProbe.duration)} video — ` +
|
|
536
|
+
"nothing to publish (shorten the cut, or pick channels without a duration cap)",
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// The plans are recomputed pure here so the confirm prompt can NAME the
|
|
541
|
+
// files that will upload; ensureDeliveryFile re-derives them (and re-probes,
|
|
542
|
+
// one cheap ffprobe each) after the "yes" to keep its cache logic
|
|
543
|
+
// self-contained.
|
|
544
|
+
const deliveryMode = flags.delivery ?? "auto";
|
|
545
|
+
const masterSizeBytes = (await stat(out)).size;
|
|
546
|
+
const src = {
|
|
547
|
+
width: masterProbe.width,
|
|
548
|
+
height: masterProbe.height,
|
|
549
|
+
fps: masterProbe.fps,
|
|
550
|
+
duration: masterProbe.duration,
|
|
551
|
+
sizeBytes: masterSizeBytes,
|
|
552
|
+
};
|
|
553
|
+
// Size-capped platforms get their own smaller encode (2026-08-29, live:
|
|
554
|
+
// Instagram's URL-fetch ingest bounced the 409MB file with 2207077 twice,
|
|
555
|
+
// then published the same take at 88MB — PLATFORM_SIZE_CAP_BYTES). A cap
|
|
556
|
+
// the video cannot fit above the quality floor drops the channel HERE,
|
|
557
|
+
// before the confirm and before a wasted encode, with the duration caps'
|
|
558
|
+
// drop-and-continue semantics.
|
|
559
|
+
let capGroups = sizeCapGroups(picked);
|
|
560
|
+
const cappedPlans = new Map<number, DeliveryPlan | null>();
|
|
561
|
+
if (deliveryMode === "auto") {
|
|
562
|
+
for (const [capBytes, group] of capGroups) {
|
|
563
|
+
const capped = deliveryEncodePlan(src, { sizeCapBytes: capBytes });
|
|
564
|
+
if (capped !== null && "unattainable" in capped) {
|
|
565
|
+
for (const line of sizeCapUnattainableMessages(
|
|
566
|
+
group,
|
|
567
|
+
capBytes,
|
|
568
|
+
capped.fittedKbps,
|
|
569
|
+
masterProbe.duration,
|
|
570
|
+
)) {
|
|
571
|
+
console.log(line);
|
|
572
|
+
}
|
|
573
|
+
const over = new Set(group.map((t) => t.id));
|
|
574
|
+
picked = picked.filter((t) => !over.has(t.id));
|
|
575
|
+
} else {
|
|
576
|
+
cappedPlans.set(capBytes, capped);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (picked.length === 0) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`every selected channel's size cap is unattainable for a ` +
|
|
582
|
+
`${formatMinSec(masterProbe.duration)} video — nothing to publish ` +
|
|
583
|
+
"(shorten the cut, or publish it manually)",
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
capGroups = sizeCapGroups(picked);
|
|
587
|
+
}
|
|
588
|
+
const plan = deliveryMode === "master" ? null : deliveryEncodePlan(src);
|
|
589
|
+
|
|
590
|
+
const when: PublishWhen = flags.at ? { kind: "at", iso: flags.at } : { kind: "now" };
|
|
591
|
+
const posts = buildPublishPosts(pack, picked, { youtubePrivacy: flags.youtubePrivacy });
|
|
592
|
+
console.log(summarizePosts(posts, when));
|
|
593
|
+
console.log(`▸ upload: ${describeUpload(deliveryMode, plan)}`);
|
|
594
|
+
for (const [capBytes, group] of capGroups) {
|
|
595
|
+
const label = [...new Set(group.map((t) => t.provider))].join(", ");
|
|
596
|
+
console.log(
|
|
597
|
+
`▸ upload (${label}): ${describeUpload(deliveryMode, cappedPlans.get(capBytes) ?? null)}`,
|
|
598
|
+
);
|
|
599
|
+
if (deliveryMode === "master" && masterSizeBytes > capBytes) {
|
|
600
|
+
console.log(masterOverCapWarning(group, capBytes, masterSizeBytes));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (flags.dryRun === true) {
|
|
605
|
+
const payload = buildPostsPayload({
|
|
606
|
+
posts,
|
|
607
|
+
when,
|
|
608
|
+
dateIso: new Date().toISOString(),
|
|
609
|
+
media: { id: "<uploaded-media-id>", path: "<uploaded-media-path>" },
|
|
610
|
+
});
|
|
611
|
+
console.log(`\n▸ dry run — the exact /posts payload (media uploads first, then this):`);
|
|
612
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (flags.yes !== true) {
|
|
617
|
+
const { isInteractive } = await import("./interactive/tty");
|
|
618
|
+
if (!isInteractive()) {
|
|
619
|
+
throw new Error("not a TTY — pass --yes to publish without the confirmation prompt");
|
|
620
|
+
}
|
|
621
|
+
const { confirm, unwrap } = await import("./interactive/prompts");
|
|
622
|
+
const go = unwrap(await confirm({ message: "Send it?" }));
|
|
623
|
+
if (go !== true) {
|
|
624
|
+
console.log("▸ publish cancelled — nothing sent");
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// The encodes run AFTER the confirm — 1–3 minutes of x264 each is a bad
|
|
630
|
+
// price for a "no" — and before the upload, which takes the delivery paths.
|
|
631
|
+
let uploadPath = out;
|
|
632
|
+
const cappedPaths = new Map<number, string>();
|
|
633
|
+
if (deliveryMode === "auto") {
|
|
634
|
+
// Live progress (2026-08-29): a TTY gets one \r-rewritten line; anything
|
|
635
|
+
// else (CI, a pipe) gets a plain line per 10% step — a 5-minute encode at
|
|
636
|
+
// 2 blocks/sec would otherwise write ~600 lines into the log. The state
|
|
637
|
+
// is per encode: the size-capped variant restarts the decile counter, and
|
|
638
|
+
// the onStart line names each file so the two encodes stay
|
|
639
|
+
// distinguishable in the log.
|
|
640
|
+
const isTty = process.stdout.isTTY === true;
|
|
641
|
+
const runEnsure = async (sizeCapBytes?: number): ReturnType<typeof ensureDeliveryFile> => {
|
|
642
|
+
let progressShown = false;
|
|
643
|
+
let lastDecile = -1;
|
|
644
|
+
const ensured = await (deps.ensureDelivery ?? ensureDeliveryFile)(tools, workdir, out, {
|
|
645
|
+
...(sizeCapBytes !== undefined ? { sizeCapBytes } : {}),
|
|
646
|
+
onStart: (name) => console.log(`▸ encoding delivery file ${name} (cached in workdir)`),
|
|
647
|
+
onProgress: (p) => {
|
|
648
|
+
const line = encodeProgressLine(masterProbe.duration, p);
|
|
649
|
+
if (isTty) {
|
|
650
|
+
progressShown = true;
|
|
651
|
+
process.stdout.write(`\r${line}`);
|
|
652
|
+
} else {
|
|
653
|
+
const decile =
|
|
654
|
+
p.outTimeSec !== undefined && masterProbe.duration > 0
|
|
655
|
+
? Math.floor((p.outTimeSec / masterProbe.duration) * 10)
|
|
656
|
+
: 0;
|
|
657
|
+
if (decile > lastDecile) {
|
|
658
|
+
lastDecile = decile;
|
|
659
|
+
console.log(line);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
// The \r line never newline-terminated itself — without this the next
|
|
665
|
+
// line would overwrite it mid-sentence.
|
|
666
|
+
if (progressShown) process.stdout.write("\n");
|
|
667
|
+
return ensured;
|
|
668
|
+
};
|
|
669
|
+
const ensured = await runEnsure();
|
|
670
|
+
uploadPath = ensured.path;
|
|
671
|
+
if (!ensured.encoded && ensured.path !== out) {
|
|
672
|
+
console.log(`▸ delivery file already cached — reusing ${ensured.path}`);
|
|
673
|
+
}
|
|
674
|
+
// Size-capped variants next, sequentially — two ffmpegs racing for cores
|
|
675
|
+
// would slow BOTH encodes down, and the unattainable groups were already
|
|
676
|
+
// dropped above so ensureDeliveryFile's throw cannot fire here.
|
|
677
|
+
for (const capBytes of capGroups.keys()) {
|
|
678
|
+
cappedPaths.set(capBytes, (await runEnsure(capBytes)).path);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
console.log(`▸ uploading ${uploadPath} to Postiz...`);
|
|
682
|
+
const result = await provider.publish({
|
|
683
|
+
videoPath: uploadPath,
|
|
684
|
+
posts: attachDeliveryMedia(posts, uploadPath, cappedPaths),
|
|
685
|
+
when,
|
|
686
|
+
});
|
|
687
|
+
await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
|
|
688
|
+
const where = configured.baseUrl.replace(/\/+$/, "");
|
|
689
|
+
console.log(
|
|
690
|
+
when.kind === "now"
|
|
691
|
+
? `✓ published to ${picked.length} account(s)`
|
|
692
|
+
: `✓ scheduled for ${when.iso} on ${picked.length} account(s)`,
|
|
693
|
+
);
|
|
694
|
+
if (result.postIds.length > 0) console.log(` posts: ${result.postIds.join(", ")}`);
|
|
695
|
+
console.log(` track it: ${where}/launches`);
|
|
696
|
+
}
|