ossclip 0.1.33 → 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/editor-dist/assets/index-Dn813PGp.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/caption-regen-panel.ts +74 -0
- package/src/edit.ts +632 -9
- package/src/produce.ts +128 -4
- package/src/program.ts +57 -2
- package/src/publish.ts +350 -6
- package/src/render-replay-args.ts +50 -0
- package/editor-dist/assets/index-DA7vDWn_.js +0 -166
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(
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The argv the editor's Render button replays (2026-08-29).
|
|
3
|
+
*
|
|
4
|
+
* A render started FROM the editor must reproduce what the user just
|
|
5
|
+
* reviewed. Replaying `command.json` verbatim did not: it carried
|
|
6
|
+
* `--produce`, so every render re-called the LLM, and the beat-sheet cache
|
|
7
|
+
* key covers the repaired words and the MEASURED framing — both of which the
|
|
8
|
+
* user's own editor work changes. The plan came back renumbered, edits
|
|
9
|
+
* anchored to scenes it no longer had were orphaned, and an approved cut was
|
|
10
|
+
* silently rewritten (the failure `produce.ts`' §143 note already named once,
|
|
11
|
+
* 2026-08-23; hit again through a different door).
|
|
12
|
+
*
|
|
13
|
+
* So the editor pins the plan on screen with `--scenes` (no LLM in the loop)
|
|
14
|
+
* and drops `--produce`. The two paths stay distinct on purpose:
|
|
15
|
+
*
|
|
16
|
+
* - CLI/CI: `--produce` plans fresh. The command line is the authority.
|
|
17
|
+
* - Editor: the reviewed plan is the authority; `replan` opts back in.
|
|
18
|
+
*
|
|
19
|
+
* Pure, so the whole matrix is testable without spawning a produce.
|
|
20
|
+
*/
|
|
21
|
+
/** The reviewed plan the editor pins for a render (see the module docstring).
|
|
22
|
+
* Its own name, never `scenes-<key>.json`: those are keyed to a beat sheet
|
|
23
|
+
* this render may no longer match. */
|
|
24
|
+
export const REVIEWED_SCENES_BASENAME = "scenes-reviewed.json";
|
|
25
|
+
|
|
26
|
+
export function renderReplayArgs(
|
|
27
|
+
recorded: readonly string[],
|
|
28
|
+
opts: { scenesPath?: string; replan?: boolean },
|
|
29
|
+
): string[] {
|
|
30
|
+
// A deliberate re-plan replays exactly what was recorded — including
|
|
31
|
+
// `--produce` when it was there, since that IS the fresh-plan request.
|
|
32
|
+
if (opts.replan === true || opts.scenesPath === undefined) return [...recorded];
|
|
33
|
+
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (let i = 0; i < recorded.length; i++) {
|
|
36
|
+
const arg = recorded[i]!;
|
|
37
|
+
// `--produce` is a boolean flag: drop it alone, never a following value.
|
|
38
|
+
if (arg === "--produce") continue;
|
|
39
|
+
// An existing `--scenes <path>` is dropped WITH its value: two of them
|
|
40
|
+
// would let commander pick one, and the stale one is precisely the plan
|
|
41
|
+
// the editor is not showing.
|
|
42
|
+
if (arg === "--scenes") {
|
|
43
|
+
i++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
out.push(arg);
|
|
47
|
+
}
|
|
48
|
+
out.push("--scenes", opts.scenesPath);
|
|
49
|
+
return out;
|
|
50
|
+
}
|