ossclip 0.1.33 → 0.1.35

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/program.ts CHANGED
@@ -3,14 +3,21 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { Command, InvalidArgumentError } from "commander";
5
5
  import { z } from "zod/v4";
6
- import { CleanupLevelSchema, COVER_MAX_WORDS, SceneComponentIdSchema } from "@ossclip/core";
6
+ import {
7
+ CleanupLevelSchema,
8
+ COVER_MAX_WORDS,
9
+ RESOLUTION_CHOICES,
10
+ ResolutionChoiceSchema,
11
+ SceneComponentIdSchema,
12
+ SfxLevelSchema,
13
+ } from "@ossclip/core";
7
14
  import { STUDIO_ENTRY } from "@ossclip/renderer";
8
15
  import { loadEnvFiles } from "./env";
9
16
  import { ExportFormatSchema, runAnalyze } from "./analyze";
10
17
  import { expandHome } from "./paths";
11
18
  import { phaseBucketProps } from "./phase-timing";
12
- import { dictionaryFlag, jumpCutsFlag, produce, reviewFlag } from "./produce";
13
- import { accountsFlag, atFlag, platformsFlag } from "./publish";
19
+ import { dictionaryFlag, jumpCutsFlag, produce, reviewFlag, sfxFlag } from "./produce";
20
+ import { accountsFlag, atFlag, deliveryFlag, platformsFlag, youtubePrivacyFlag } from "./publish";
14
21
  // The one interactive import that is STATIC rather than `await import()`: the
15
22
  // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
16
23
  // while the program is being built, and `buildProgram` cannot await. The graph
@@ -300,6 +307,22 @@ export function buildProgram(): Command {
300
307
  "output shape: 9:16 (vertical, default) or 16:9 (landscape, 1920x1080)",
301
308
  "9:16",
302
309
  )
310
+ .option(
311
+ "--resolution <height>",
312
+ "output height: 1080 (default), 1440, 2160, or auto (keep what the source has, " +
313
+ "capped at 2160). Config key: \"resolution\"",
314
+ (v: string) => {
315
+ // Parse, never coerce (CLAUDE.md): a typo'd `--resolution 2610` must
316
+ // not silently render 1080p, and `Number()` on "auto" is NaN.
317
+ const parsed = ResolutionChoiceSchema.safeParse(v.trim());
318
+ if (!parsed.success) {
319
+ throw new InvalidArgumentError(
320
+ `--resolution wants one of ${RESOLUTION_CHOICES.join(", ")}, got "${v}"`,
321
+ );
322
+ }
323
+ return parsed.data;
324
+ },
325
+ )
303
326
  .option("--produce", "run the LLM producer brain to plan title cards & graphics", false)
304
327
  .option(
305
328
  "--clip <seconds>",
@@ -321,6 +344,31 @@ export function buildProgram(): Command {
321
344
  "runs so the editor's Render replays the same window without an LLM call",
322
345
  )
323
346
  .option("--intent <text>", "what the video should be ('educational video about agents…')")
347
+ .option(
348
+ "--sfx",
349
+ // No commander default, the `--watermark` contract: untyped must stay
350
+ // UNDEFINED so the config's `sfx` key can supply it — a `false` default
351
+ // here would make every run's flag beat the config it was written for.
352
+ "place sound effects from the bundled pack (and any pack in ~/.ossclip/sfx) " +
353
+ "on the beats the producer planned. Requires --produce. Config key: \"sfx\"",
354
+ )
355
+ .option(
356
+ "--sfx-level <level>",
357
+ "how much sound design: subtle | normal (default) | meme (unlocks the " +
358
+ "meme-tagged sounds). Implies --sfx. Config key: \"sfxLevel\"",
359
+ (v: string) => {
360
+ // Parse, never coerce (CLAUDE.md): a typo'd `--sfx-level mem` must not
361
+ // silently fall back to `normal` — the level decides whether a vine
362
+ // boom can land in the video at all.
363
+ const parsed = SfxLevelSchema.safeParse(v.trim());
364
+ if (!parsed.success) {
365
+ throw new InvalidArgumentError(
366
+ `--sfx-level wants one of ${SfxLevelSchema.options.join(", ")}, got "${v}"`,
367
+ );
368
+ }
369
+ return parsed.data;
370
+ },
371
+ )
324
372
  .option(
325
373
  "--llm <provider>",
326
374
  // Must state `defaultProviderName`'s real order — the old text omitted
@@ -357,6 +405,12 @@ export function buildProgram(): Command {
357
405
  "--whisper-language <code>",
358
406
  "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
359
407
  )
408
+ .option(
409
+ "--whisper-translate",
410
+ "translate the speech to ENGLISH captions instead of transcribing it verbatim " +
411
+ "(whisper's -tr; pair with --whisper-language for the SOURCE language)",
412
+ false,
413
+ )
360
414
  // COMMA-SEPARATED in one value, not variadic: a variadic option swallows
361
415
  // the optional positional [input] whenever the flag precedes the path,
362
416
  // and commander offers no way to give the positional priority.
@@ -633,6 +687,12 @@ export function buildProgram(): Command {
633
687
  noiseDb: opts.noiseDb,
634
688
  produce: opts.produce,
635
689
  intent: opts.intent,
690
+ // `--sfx-level` implies `--sfx` (sfxFlag), resolved HERE so the
691
+ // implication is one pure function rather than a condition produce
692
+ // has to remember. Untyped stays undefined, which is what lets the
693
+ // config's `sfx` key decide (`resolveSfx` at the use site).
694
+ sfx: sfxFlag(opts.sfx, opts.sfxLevel),
695
+ sfxLevel: opts.sfxLevel,
636
696
  provider,
637
697
  llmModel: opts.llmModel,
638
698
  llmFastModel: opts.llmFastModel,
@@ -644,6 +704,7 @@ export function buildProgram(): Command {
644
704
  repair: opts.repair,
645
705
  whisperModel: opts.whisperModel,
646
706
  whisperLanguage,
707
+ whisperTranslate: opts.whisperTranslate === true,
647
708
  // Split/trim/drop-empties (dictionaryFlag) — undefined stays
648
709
  // undefined so the config's dictionary can supply the default.
649
710
  dictionary: dictionaryFlag(opts.dictionary),
@@ -687,6 +748,9 @@ export function buildProgram(): Command {
687
748
  // Validated by concurrencyFlag at parse time; undefined = "not
688
749
  // typed", which is what lets the config supply it.
689
750
  concurrency: opts.concurrency,
751
+ // Same contract: validated by the flag's own parser, and undefined
752
+ // means "not typed" so the config's `resolution` can supply it.
753
+ resolution: opts.resolution,
690
754
  });
691
755
  // Counts, buckets and names only — the duration crosses the wire as a
692
756
  // bucket, and nothing here can carry a path (assertSafeProps enforces
@@ -729,7 +793,13 @@ export function buildProgram(): Command {
729
793
  // run does write. Reusing this path is what gives --review the edit
730
794
  // command's whole posture (resolveEditorPageDir's loud "run pnpm
731
795
  // build" degrade, startEditServer, openInBrowser) for free.
732
- await offerEditor(result, { flag: openEditor, port: opts.editorPort });
796
+ await offerEditor(result, {
797
+ flag: openEditor,
798
+ port: opts.editorPort,
799
+ // Typed-only, like `edit`'s --port: commander's 5174 default is not
800
+ // a choice, so a busy port still attaches or bumps for it.
801
+ portPinned: command.getOptionValueSource("editorPort") === "cli",
802
+ });
733
803
  // Deliberately LAST — after the render summary and the editor offer —
734
804
  // so the one question ossclip ever asks is the last thing on screen.
735
805
  await maybeAskRating(telemetry);
@@ -758,6 +828,12 @@ export function buildProgram(): Command {
758
828
  "--whisper-language <code>",
759
829
  "transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
760
830
  )
831
+ .option(
832
+ "--whisper-translate",
833
+ "translate the speech to ENGLISH captions instead of transcribing it verbatim " +
834
+ "(whisper's -tr; pair with --whisper-language for the SOURCE language)",
835
+ false,
836
+ )
761
837
  .action(async (input: string, opts) => {
762
838
  const cleanup = CleanupLevelSchema.parse(opts.cleanup);
763
839
  const result = await produce(input, {
@@ -769,6 +845,7 @@ export function buildProgram(): Command {
769
845
  noiseDb: opts.noiseDb,
770
846
  whisperModel: opts.whisperModel,
771
847
  // Same guard as produce's: empty must error, not become a bare `-l`.
848
+ whisperTranslate: opts.whisperTranslate === true,
772
849
  whisperLanguage:
773
850
  opts.whisperLanguage !== undefined
774
851
  ? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
@@ -917,7 +994,7 @@ export function buildProgram(): Command {
917
994
  .argument("[workdir]", "a work directory containing render-props.json")
918
995
  .option("--port <n>", "port to listen on", (v) => Number.parseInt(v, 10), 5174)
919
996
  .option("--no-open", "do not open a browser")
920
- .action(async (workdir: string | undefined, opts) => {
997
+ .action(async (workdir: string | undefined, opts, command: Command) => {
921
998
  const { startEditServer, resolveEditorPageDir } = await import("./edit");
922
999
  // An npm install ships the page prebuilt (editor-dist/); a clone builds
923
1000
  // it once with `pnpm build`. A server that starts fine but 404s every
@@ -937,11 +1014,29 @@ export function buildProgram(): Command {
937
1014
  const target =
938
1015
  workdir === undefined ? undefined : await resolveWorkdirArgument(workdir, "edit");
939
1016
 
940
- const server = await startEditServer(target, { port: opts.port, pageDir });
941
- console.log(`▸ editor at ${server.url}`);
1017
+ // A busy port is not a crash. `openEditServer` owns the whole ladder —
1018
+ // attach to our own editor already serving THIS project, prompt about
1019
+ // one serving another, step around a stranger — and it needs to know
1020
+ // whether the port was TYPED: someone who passes `--port` has a reason
1021
+ // for that number (a bookmark, a tunnel), so their run refuses rather
1022
+ // than silently landing somewhere else. `getOptionValueSource` is the
1023
+ // same "did the user type it" test `--sort` uses.
1024
+ const { openEditServer, liveEditPortDeps } = await import("./edit-port");
1025
+ const opened = await openEditServer(
1026
+ target ?? null,
1027
+ { port: opts.port, pinned: command.getOptionValueSource("port") === "cli" },
1028
+ liveEditPortDeps((port) => startEditServer(target, { port, pageDir })),
1029
+ );
1030
+ // The user picked "cancel" at the conflict prompt: nothing started,
1031
+ // nothing to open, exit 0.
1032
+ if (opened.kind === "cancelled") return;
1033
+ const url = opened.kind === "attached" ? opened.url : opened.server.url;
1034
+ // The attach path already printed "▸ already open at …" — saying
1035
+ // "editor at" underneath it would read as a second server.
1036
+ if (opened.kind === "started") console.log(`▸ editor at ${url}`);
942
1037
  if (opts.open) {
943
1038
  const { openInBrowser } = await import("./open");
944
- openInBrowser(server.url);
1039
+ openInBrowser(url);
945
1040
  }
946
1041
  // Fire-and-forget on purpose: the server keeps the process alive for
947
1042
  // the request's lifetime, and awaiting here would put a metrics POST
@@ -1035,6 +1130,18 @@ export function buildProgram(): Command {
1035
1130
  .option("--dry-run", "print the targets and the exact payload; send nothing", false)
1036
1131
  .option("-y, --yes", "skip the confirmation prompt", false)
1037
1132
  .option("--force", "publish again even though this workdir already has a publish receipt", false)
1133
+ .option(
1134
+ "--youtube-privacy <level>",
1135
+ "YouTube visibility: private (default), unlisted or public",
1136
+ youtubePrivacyFlag,
1137
+ )
1138
+ .option(
1139
+ "--delivery <mode>",
1140
+ "what to upload — auto: a ≤1080p ~10 Mbps delivery encode, built once and " +
1141
+ "cached in the workdir; master: the untouched render",
1142
+ deliveryFlag,
1143
+ "auto",
1144
+ )
1038
1145
  .action(async (workdir: string | undefined, opts) => {
1039
1146
  const { runPublish } = await import("./publish");
1040
1147
  const target = await resolveWorkdirArgument(workdir ?? ".", "publish");
@@ -1046,6 +1153,10 @@ export function buildProgram(): Command {
1046
1153
  dryRun: opts.dryRun,
1047
1154
  yes: opts.yes,
1048
1155
  force: opts.force,
1156
+ // Validated by youtubePrivacyFlag at parse time; undefined means the
1157
+ // payload's own safe default (private).
1158
+ youtubePrivacy: opts.youtubePrivacy,
1159
+ delivery: opts.delivery,
1049
1160
  });
1050
1161
  telemetry.record("publish_run", {
1051
1162
  scheduled: opts.at !== undefined,
package/src/publish.ts CHANGED
@@ -1,14 +1,24 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readFile, writeFile } from "node:fs/promises";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { InvalidArgumentError } from "commander";
5
+ import { z } from "zod/v4";
5
6
  import {
7
+ PLATFORM_SIZE_CAP_BYTES,
6
8
  YOUTUBE_APPROVED_BASENAME,
7
9
  YoutubePackSchema,
8
10
  buildPostsPayload,
9
11
  captionForProvider,
12
+ checkDurationCaps,
10
13
  createPostizProvider,
14
+ deliveryEncodePlan,
15
+ encodeEta,
16
+ ensureDeliveryFile,
17
+ formatMinSec,
11
18
  loadConfig,
19
+ probe,
20
+ type DeliveryPlan,
21
+ type DurationViolation,
12
22
  type OssclipConfig,
13
23
  type PublishPost,
14
24
  type PublishProvider,
@@ -155,14 +165,204 @@ export function selectTargets(
155
165
  * the pack's first title — the one required settings field Postiz won't
156
166
  * default.
157
167
  */
158
- export function buildPublishPosts(pack: YoutubePack, targets: PublishTarget[]): PublishPost[] {
168
+ export function buildPublishPosts(
169
+ pack: YoutubePack,
170
+ targets: PublishTarget[],
171
+ opts: { youtubePrivacy?: YoutubePrivacy } = {},
172
+ ): PublishPost[] {
159
173
  return targets.map((target) => ({
160
174
  target,
161
175
  caption: captionForProvider(pack, target.provider),
162
- ...(target.provider === "youtube" ? { title: pack.titles[0] } : {}),
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
+ : {}),
163
186
  }));
164
187
  }
165
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
+
166
366
  /**
167
367
  * The pack this publish reads: the APPROVED file when the editor wrote one
168
368
  * (an edited pack is the user's decision), else the provider-keyed cache
@@ -237,6 +437,10 @@ export interface PublishFlags {
237
437
  dryRun?: boolean;
238
438
  yes?: boolean;
239
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;
240
444
  }
241
445
 
242
446
  /**
@@ -251,6 +455,12 @@ export async function runPublish(
251
455
  provider?: PublishProvider;
252
456
  config?: OssclipConfig;
253
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;
254
464
  } = {},
255
465
  ): Promise<void> {
256
466
  const config = deps.config ?? loadConfig();
@@ -309,9 +519,87 @@ export async function runPublish(
309
519
  ) as PublishTarget[];
310
520
  }
311
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
+
312
590
  const when: PublishWhen = flags.at ? { kind: "at", iso: flags.at } : { kind: "now" };
313
- const posts = buildPublishPosts(pack, picked);
591
+ const posts = buildPublishPosts(pack, picked, { youtubePrivacy: flags.youtubePrivacy });
314
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
+ }
315
603
 
316
604
  if (flags.dryRun === true) {
317
605
  const payload = buildPostsPayload({
@@ -338,8 +626,64 @@ export async function runPublish(
338
626
  }
339
627
  }
340
628
 
341
- console.log(`▸ uploading ${out} to Postiz...`);
342
- const result = await provider.publish({ videoPath: out, posts, when });
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
+ });
343
687
  await writeFile(publishReceiptPath(workdir), `${JSON.stringify(result, null, 2)}\n`);
344
688
  const where = configured.baseUrl.replace(/\/+$/, "");
345
689
  console.log(