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/editor-dist/assets/index-DSB_SCmp.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/edit-health.ts +51 -0
- package/src/edit-port.ts +296 -0
- package/src/edit.ts +905 -10
- package/src/interactive/offer-editor.ts +29 -4
- package/src/interactive/produce-argv.ts +18 -1
- package/src/interactive/produce-wizard.ts +40 -3
- package/src/produce.ts +595 -6
- package/src/program.ts +119 -8
- package/src/publish.ts +350 -6
- package/src/render-replay-args.ts +50 -0
- package/src/replay-argv.ts +30 -0
- package/editor-dist/assets/index-DA7vDWn_.js +0 -166
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 {
|
|
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, {
|
|
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
|
-
|
|
941
|
-
|
|
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(
|
|
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(
|
|
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"
|
|
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
|
-
|
|
342
|
-
|
|
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(
|