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/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
+ }