ossclip 0.1.25 → 0.1.27
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-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-CWIUcpJC.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/caption-report.ts +141 -3
- package/src/cover.ts +846 -0
- package/src/edit.ts +232 -39
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/open.ts +38 -0
- package/src/produce.ts +491 -67
- package/src/program.ts +143 -22
- package/editor-dist/assets/index-C9n6EfII.js +0 -163
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
package/src/produce.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
3
|
import { copyFile, mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
} from "node:fs";
|
|
5
13
|
import { cpus } from "node:os";
|
|
6
14
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
15
|
import { z } from "zod/v4";
|
|
@@ -29,8 +37,13 @@ import {
|
|
|
29
37
|
concatFolder,
|
|
30
38
|
folderManifestKey,
|
|
31
39
|
listFolderVideos,
|
|
40
|
+
outInsideInputFolderMessage,
|
|
41
|
+
outPathInsideInput,
|
|
42
|
+
COVER_PROVENANCE_BASENAME,
|
|
32
43
|
coverDecision,
|
|
33
44
|
coverHeadline,
|
|
45
|
+
readCoverProvenance,
|
|
46
|
+
writeCoverProvenance,
|
|
34
47
|
cropFilter,
|
|
35
48
|
detectContentRect,
|
|
36
49
|
letterboxedSeconds,
|
|
@@ -79,6 +92,7 @@ import {
|
|
|
79
92
|
thumbnailDecision,
|
|
80
93
|
thumbnailImageCacheName,
|
|
81
94
|
applyUserCuts,
|
|
95
|
+
pruneHidesInsideCuts,
|
|
82
96
|
loadConfig,
|
|
83
97
|
loudnorm,
|
|
84
98
|
MAX_NORMALIZE_UPSCALE,
|
|
@@ -132,6 +146,7 @@ import {
|
|
|
132
146
|
type Layout,
|
|
133
147
|
type LlmProvider,
|
|
134
148
|
type Production,
|
|
149
|
+
ossclipOutputPathFor,
|
|
135
150
|
type ProviderName,
|
|
136
151
|
type Scene,
|
|
137
152
|
type SceneComponentId,
|
|
@@ -153,6 +168,13 @@ import {
|
|
|
153
168
|
workdirBaseName,
|
|
154
169
|
} from "./stranded-overrides";
|
|
155
170
|
import { editHint } from "./interactive/edit-hint";
|
|
171
|
+
import {
|
|
172
|
+
COVER_FRAME_BASENAME,
|
|
173
|
+
buildCoverRender,
|
|
174
|
+
coverBannerText,
|
|
175
|
+
coverTextHold,
|
|
176
|
+
provenanceVideoPath,
|
|
177
|
+
} from "./cover";
|
|
156
178
|
import { artifactPath, ensureParentDir, expandHome, moveFile } from "./paths";
|
|
157
179
|
import { portraitOverridePath, resolvePortrait } from "./portrait-override";
|
|
158
180
|
import { approveThumbnailConcept, thumbnailRetryLoop } from "./interactive/thumbnail-approve";
|
|
@@ -161,7 +183,8 @@ import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from
|
|
|
161
183
|
import { reconcileCaptionEdits } from "./caption-report";
|
|
162
184
|
import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
|
|
163
185
|
import { recordedProduceArgs } from "./replay-argv";
|
|
164
|
-
import { renderCover, renderProduction } from "@ossclip/renderer";
|
|
186
|
+
import { makeCancelSignal, renderCover, renderProduction } from "@ossclip/renderer";
|
|
187
|
+
import type { RenderPhase } from "@ossclip/renderer";
|
|
165
188
|
import {
|
|
166
189
|
DEFAULT_FACE,
|
|
167
190
|
coverTextRect,
|
|
@@ -293,6 +316,12 @@ export interface ProduceOptions {
|
|
|
293
316
|
cover?: boolean;
|
|
294
317
|
/** Explicit cover output path, overriding <out>.cover.jpg. */
|
|
295
318
|
coverPath?: string;
|
|
319
|
+
/**
|
|
320
|
+
* `--cover-text-reset` — opt back into the GENERATED cover headline on a
|
|
321
|
+
* workdir whose `cover.json` holds a user-typed one (`coverTextHold`).
|
|
322
|
+
* Deleting `cover.json` does the same thing.
|
|
323
|
+
*/
|
|
324
|
+
coverTextReset?: boolean;
|
|
296
325
|
/** Treat the source as an already-edited reel with burned-in graphics. */
|
|
297
326
|
sourceIsEdited?: boolean;
|
|
298
327
|
/**
|
|
@@ -321,6 +350,13 @@ export interface ProduceOptions {
|
|
|
321
350
|
* platform chrome to dodge and a landscape source needs no cropping at all.
|
|
322
351
|
*/
|
|
323
352
|
aspect?: "9:16" | "16:9";
|
|
353
|
+
/**
|
|
354
|
+
* `--concurrency <n>`: how many browser tabs the render opens at once,
|
|
355
|
+
* beating the config's `renderConcurrency` and the cpus-2 default
|
|
356
|
+
* (`resolveRenderConcurrency`). Already validated by commander
|
|
357
|
+
* (`concurrencyFlag`), so a number here is always a positive integer.
|
|
358
|
+
*/
|
|
359
|
+
concurrency?: number;
|
|
324
360
|
/**
|
|
325
361
|
* `--clip <seconds>` (R19 §93): produce only the strongest ~N-second window
|
|
326
362
|
* of a long take, chosen by the producer in the same editorial call as the
|
|
@@ -449,21 +485,37 @@ export function resolveYoutube(
|
|
|
449
485
|
|
|
450
486
|
/**
|
|
451
487
|
* How many browser tabs the render runs in parallel (2026-08-17 render-speed
|
|
452
|
-
* pass).
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
488
|
+
* pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
|
|
489
|
+
* beats the cpus-2 default (floor 2). The default is cpus-2 because the
|
|
490
|
+
* render is decode-bound — every tab waits on OffthreadVideo's ffmpeg extract
|
|
491
|
+
* workers — so saturating all cores with tabs starves the very processes the
|
|
492
|
+
* tabs block on.
|
|
493
|
+
*
|
|
494
|
+
* The flag exists because cpus-2 is a CPU guess with no memory term in it
|
|
495
|
+
* (2026-08-19 field case): a 14-core / 36GB Mac resolved to 12 tabs on a
|
|
496
|
+
* 1080×1920 source and Chrome died WHOLE, twelve in-flight frames at a time.
|
|
497
|
+
* `offthreadVideoCacheSizeInBytes` bounds the cache side of that
|
|
498
|
+
* (render-options.ts); this is the hatch for the tab side, typed per run
|
|
499
|
+
* rather than edited into a config file mid-investigation.
|
|
500
|
+
*
|
|
501
|
+
* The flag arrives already validated by commander (`concurrencyFlag` in
|
|
502
|
+
* program.ts rejects a non-positive/non-integer at the front door, §93a), so
|
|
503
|
+
* only the config value is checked here — the `dictionary` posture, since it
|
|
504
|
+
* comes from hand-editable JSON loadConfig doesn't zod-parse: a positive
|
|
505
|
+
* integer, or one warning and the default, never a coerced tab count. Pure so
|
|
506
|
+
* the flag × config × cpu matrix is testable without a config file or real
|
|
460
507
|
* cpus().
|
|
461
508
|
*/
|
|
462
509
|
export function resolveRenderConcurrency(
|
|
510
|
+
flagValue: number | undefined,
|
|
463
511
|
configValue: unknown,
|
|
464
512
|
cpuCount: number,
|
|
465
513
|
): { concurrency: number; warning?: string } {
|
|
466
514
|
const fallback = Math.max(2, cpuCount - 2);
|
|
515
|
+
// Typed-beats-config, and typed also beats a MALFORMED config: the user
|
|
516
|
+
// asking for 4 tabs on the command line gets 4, not a warning about a
|
|
517
|
+
// config key they did not touch this run.
|
|
518
|
+
if (flagValue !== undefined) return { concurrency: flagValue };
|
|
467
519
|
if (configValue === undefined) return { concurrency: fallback };
|
|
468
520
|
if (typeof configValue === "number" && Number.isInteger(configValue) && configValue > 0) {
|
|
469
521
|
return { concurrency: configValue };
|
|
@@ -474,6 +526,119 @@ export function resolveRenderConcurrency(
|
|
|
474
526
|
};
|
|
475
527
|
}
|
|
476
528
|
|
|
529
|
+
/** What a signal that interrupted the render phase costs the caller. */
|
|
530
|
+
export interface RenderCancellation {
|
|
531
|
+
/** Partial render output to delete before exiting. */
|
|
532
|
+
removePaths: string[];
|
|
533
|
+
/** Shell convention: 128 + the signal's number (SIGINT 2, SIGTERM 15). */
|
|
534
|
+
exitCode: number;
|
|
535
|
+
message: string;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The decision half of Ctrl-C-cancels-the-render (2026-08-19 field report:
|
|
540
|
+
* "Cancelling rerendering doesn't work" — nothing in the CLI handled SIGINT,
|
|
541
|
+
* and no cancelSignal reached Remotion, so the browser and its ffmpeg
|
|
542
|
+
* children kept going after the process was told to stop). The signal wiring
|
|
543
|
+
* itself is I/O and lives at the render call site; what to delete and what to
|
|
544
|
+
* exit with is decided here so it can be tested without a real render.
|
|
545
|
+
*
|
|
546
|
+
* `rawPath` is the workdir's `render-raw.mp4`, which a finished run
|
|
547
|
+
* loudnorms and only THEN moves to the user's --out — so a cancel never has
|
|
548
|
+
* an output file to mistake for a finished render. The raw partial is deleted
|
|
549
|
+
* anyway: it is a truncated mp4 sitting in the workdir under the name the
|
|
550
|
+
* next run reads, and a stale one there is the kind of thing that gets picked
|
|
551
|
+
* up by hand and mailed to someone.
|
|
552
|
+
*
|
|
553
|
+
* Non-zero exit, because a cancelled render did not produce the video the
|
|
554
|
+
* caller asked for — but 130/143 rather than 1, so a script can tell a
|
|
555
|
+
* deliberate stop from a failure (the same distinction the editor's
|
|
556
|
+
* /api/render/cancel draws, R16 §60).
|
|
557
|
+
*/
|
|
558
|
+
export function renderCancellation(
|
|
559
|
+
signal: "SIGINT" | "SIGTERM",
|
|
560
|
+
rawPath: string,
|
|
561
|
+
): RenderCancellation {
|
|
562
|
+
return {
|
|
563
|
+
removePaths: [rawPath],
|
|
564
|
+
exitCode: signal === "SIGINT" ? 130 : 143,
|
|
565
|
+
message: "▸ cancelled — partial output discarded",
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Where the run is when a signal lands, collapsed to the three cases that
|
|
571
|
+
* behave differently. `RenderPhase`'s "bundling" and "selecting" both collapse
|
|
572
|
+
* to "pre-render": neither takes a cancelSignal, so neither can be stopped
|
|
573
|
+
* cooperatively. "post-render" is the window between `renderMedia` resolving
|
|
574
|
+
* and the handlers coming off in the `finally`.
|
|
575
|
+
*/
|
|
576
|
+
export type RenderSignalPhase = "pre-render" | "rendering" | "post-render";
|
|
577
|
+
|
|
578
|
+
/** Map the renderer's phase report onto what a signal can do about it. */
|
|
579
|
+
export function renderSignalPhaseOf(phase: RenderPhase): RenderSignalPhase {
|
|
580
|
+
return phase === "rendering" ? "rendering" : "pre-render";
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** What the SIGINT/SIGTERM handler should do about the signal it just got. */
|
|
584
|
+
export interface RenderSignalAction {
|
|
585
|
+
/** Fire the Remotion cancel signal. Free, and only `renderMedia` listens. */
|
|
586
|
+
cancel: boolean;
|
|
587
|
+
/**
|
|
588
|
+
* Tear down and `process.exit` from INSIDE the handler, because nothing
|
|
589
|
+
* downstream is going to stop on its own.
|
|
590
|
+
*/
|
|
591
|
+
exitNow: boolean;
|
|
592
|
+
/** Printed above the cancellation message when the exit needs explaining. */
|
|
593
|
+
note?: string;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The decision half of "Ctrl-C must never be a no-op" (2026-08-19 review of
|
|
598
|
+
* the cancel feature). Registering a SIGINT listener SUPPRESSES node's default
|
|
599
|
+
* terminate, so the cancel feature as first written made Ctrl-C *worse* than
|
|
600
|
+
* before it existed in every phase the cancelSignal does not reach:
|
|
601
|
+
*
|
|
602
|
+
* - "pre-render" — `bundle()` and `selectComposition()` take no cancelSignal
|
|
603
|
+
* in @remotion/renderer 4.0.499 (verified against the installed types; see
|
|
604
|
+
* RenderPhase in @ossclip/renderer). A cold bundle is tens of seconds, and
|
|
605
|
+
* minutes when Chrome is downloaded on first run, and for all of it Ctrl-C
|
|
606
|
+
* did NOTHING while the terminal looked hung. The handler must exit itself.
|
|
607
|
+
* That can orphan the Chrome `selectComposition` opened — but a bare Ctrl-C
|
|
608
|
+
* before the cancel feature did exactly the same, so it is not a
|
|
609
|
+
* regression, and a terminal that ignores Ctrl-C is worse than a stray
|
|
610
|
+
* browser process.
|
|
611
|
+
* - "rendering" — the one phase that IS cooperative: fire the signal and let
|
|
612
|
+
* Remotion tear the browser and its ffmpeg children down.
|
|
613
|
+
* - "post-render" — HONORED, not ignored: the caller stops before mastering
|
|
614
|
+
* and discards the raw render (see the tail check at the render call site).
|
|
615
|
+
*
|
|
616
|
+
* SECOND SIGNAL ALWAYS EXITS, in every phase. If Remotion's teardown wedges,
|
|
617
|
+
* the user's only remaining move must not be `kill -9` from another terminal.
|
|
618
|
+
*/
|
|
619
|
+
export function renderSignalAction(
|
|
620
|
+
phase: RenderSignalPhase,
|
|
621
|
+
signalCount: number,
|
|
622
|
+
): RenderSignalAction {
|
|
623
|
+
if (signalCount >= 2) {
|
|
624
|
+
return {
|
|
625
|
+
cancel: true,
|
|
626
|
+
exitNow: true,
|
|
627
|
+
note: "▸ second signal — exiting without waiting for the render to shut down",
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
if (phase === "pre-render") {
|
|
631
|
+
return {
|
|
632
|
+
cancel: true,
|
|
633
|
+
exitNow: true,
|
|
634
|
+
note:
|
|
635
|
+
"▸ cancelled while preparing the render — that phase cannot be interrupted " +
|
|
636
|
+
"cleanly, so stopping the process",
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return { cancel: true, exitNow: false };
|
|
640
|
+
}
|
|
641
|
+
|
|
477
642
|
// Moved to paths.ts (2026-08-17, editor thumbnail panel): the edit server
|
|
478
643
|
// derives `<out>.thumbnail.png` from command.json's recorded out and must not
|
|
479
644
|
// import this module — produce.ts imports edit.ts (recordRecentProject), so
|
|
@@ -1209,7 +1374,34 @@ function deriveWorkdir(
|
|
|
1209
1374
|
* file input's equivalent default already lands.
|
|
1210
1375
|
*/
|
|
1211
1376
|
export function defaultOutPath(originalInput: string): string {
|
|
1212
|
-
|
|
1377
|
+
// Delegates to core so the refusal message's suggestion and the actual
|
|
1378
|
+
// default can never drift apart (and both strip the tab-completed trailing
|
|
1379
|
+
// slash — the 2026-08-18 hidden-dotfile-inside-the-folder field case).
|
|
1380
|
+
return ossclipOutputPathFor(originalInput);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* The ⚠ line a REPLAYED produce prints when it keys to a different workdir
|
|
1385
|
+
* than the one the editor launched it for (2026-08-18 field cascade, part
|
|
1386
|
+
* 3): the edit server sets OSSCLIP_REPLAY_WORKDIR to the workdir whose
|
|
1387
|
+
* command.json it is replaying; if the run then derives another workdir —
|
|
1388
|
+
* the folder's content changed since the record — the edits saved in the
|
|
1389
|
+
* old workdir's overrides.json silently stop applying, and nothing else in
|
|
1390
|
+
* the run says so. Pure (drift decision in, line out) so the comparison is
|
|
1391
|
+
* testable without spawning a replay; null when this isn't a replay or
|
|
1392
|
+
* nothing drifted.
|
|
1393
|
+
*/
|
|
1394
|
+
export function replayWorkdirWarning(
|
|
1395
|
+
replayedWorkdir: string | undefined,
|
|
1396
|
+
derivedWorkdir: string,
|
|
1397
|
+
): string | null {
|
|
1398
|
+
if (replayedWorkdir === undefined || replayedWorkdir === "") return null;
|
|
1399
|
+
if (resolve(replayedWorkdir) === resolve(derivedWorkdir)) return null;
|
|
1400
|
+
return (
|
|
1401
|
+
`⚠ this run's workdir differs from the one the editor replayed — edits ` +
|
|
1402
|
+
`saved in ${join(replayedWorkdir, "overrides.json")} will NOT apply to ` +
|
|
1403
|
+
`this render (the input's content changed since that command was recorded)`
|
|
1404
|
+
);
|
|
1213
1405
|
}
|
|
1214
1406
|
|
|
1215
1407
|
/**
|
|
@@ -1415,6 +1607,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1415
1607
|
: resolve(baseCwd, expandHome(inputArg));
|
|
1416
1608
|
let input = originalInput;
|
|
1417
1609
|
if (!existsSync(input)) throw new Error(`input not found: ${input}`);
|
|
1610
|
+
// Decided once, here — the out-path gate below and the folder pipeline
|
|
1611
|
+
// further down must read the same answer to "is this a folder run".
|
|
1612
|
+
const isFolder = statSync(input).isDirectory();
|
|
1418
1613
|
|
|
1419
1614
|
// §93b: the window is an editorial judgement, and there is deliberately no
|
|
1420
1615
|
// heuristic fallback — an automatically-guessed 60 seconds reads as a bug,
|
|
@@ -1449,6 +1644,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1449
1644
|
? outArg
|
|
1450
1645
|
: resolve(baseCwd, outArg)
|
|
1451
1646
|
: resolve(defaultOutPath(originalInput));
|
|
1647
|
+
// 2026-08-18 field cascade: an --out pointed INSIDE the input folder became
|
|
1648
|
+
// a 7th source clip on the next run — new content hash, fresh workdir,
|
|
1649
|
+
// EMPTY overrides — so the render silently dropped the user's saved edits
|
|
1650
|
+
// and the output duration doubled, three runs in a row. Refused BEFORE
|
|
1651
|
+
// ensureParentDir so the gate can't first mkdir a stray subfolder inside
|
|
1652
|
+
// the very input it is about to refuse. (Unreachable via the default out —
|
|
1653
|
+
// defaultOutPath lands BESIDE the folder — so only a typed --out can trip
|
|
1654
|
+
// it.)
|
|
1655
|
+
if (isFolder && outPathInsideInput(outPath, originalInput)) {
|
|
1656
|
+
throw new Error(outInsideInputFolderMessage(originalInput));
|
|
1657
|
+
}
|
|
1452
1658
|
ensureParentDir(outPath);
|
|
1453
1659
|
|
|
1454
1660
|
await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
|
|
@@ -1489,7 +1695,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1489
1695
|
// video with captions transcribed against a different edit. Hashing the
|
|
1490
1696
|
// manifest content gives a folder input the same invariant a file input
|
|
1491
1697
|
// already has via `sha1File`: content changes ⇒ a fresh workdir.
|
|
1492
|
-
|
|
1698
|
+
// (`isFolder` itself is decided up top, beside the out-path gate.)
|
|
1493
1699
|
// Final-review fix wave, cheap minor c: --sort only means anything for a
|
|
1494
1700
|
// folder input; on a file it did nothing, silently. Gated on `sortExplicit`
|
|
1495
1701
|
// (whether the user TYPED it) rather than on `opts.sort` itself, since
|
|
@@ -1518,6 +1724,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1518
1724
|
);
|
|
1519
1725
|
await mkdir(work, { recursive: true });
|
|
1520
1726
|
console.log(`▸ workdir ${work}`);
|
|
1727
|
+
// See replayWorkdirWarning — set only by the edit server's /api/render
|
|
1728
|
+
// spawn, so a terminal run never sees it.
|
|
1729
|
+
const replayWarning = replayWorkdirWarning(process.env.OSSCLIP_REPLAY_WORKDIR, work);
|
|
1730
|
+
if (replayWarning !== null) console.log(replayWarning);
|
|
1521
1731
|
|
|
1522
1732
|
// §131 residue: a folder re-key (clips renamed/added/removed → new content
|
|
1523
1733
|
// hash) correctly lands in a fresh workdir, but any editor edits saved in
|
|
@@ -1566,6 +1776,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1566
1776
|
` ${result.nonVideoCount} non-video file${result.nonVideoCount === 1 ? "" : "s"} ignored`,
|
|
1567
1777
|
);
|
|
1568
1778
|
}
|
|
1779
|
+
// Loud, not folded into the non-video count (2026-08-18 field cascade):
|
|
1780
|
+
// an ossclip output sitting in the clips folder means an earlier run
|
|
1781
|
+
// wrote it there, and the user should learn that before it surprises
|
|
1782
|
+
// them elsewhere. The filter itself is pure (isOssclipOutputName) and
|
|
1783
|
+
// runs inside listFolderVideos, before the workdir hash is derived.
|
|
1784
|
+
if (result.ossclipOutputCount > 0) {
|
|
1785
|
+
console.log(
|
|
1786
|
+
`▸ folder: skipped ${result.ossclipOutputCount} ossclip output ` +
|
|
1787
|
+
`file${result.ossclipOutputCount === 1 ? "" : "s"}`,
|
|
1788
|
+
);
|
|
1789
|
+
}
|
|
1569
1790
|
// Order visible immediately (folder-input-brief.md) — a wrong order is a
|
|
1570
1791
|
// silent bug otherwise, invisible until someone watches the whole thing.
|
|
1571
1792
|
result.clips.forEach((c, i) => {
|
|
@@ -1893,17 +2114,37 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1893
2114
|
.slice(0, 8);
|
|
1894
2115
|
const repairCache = join(work, `repairs-${rawKey}.json`);
|
|
1895
2116
|
if (existsSync(repairCache)) {
|
|
1896
|
-
|
|
1897
|
-
|
|
2117
|
+
const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
|
|
2118
|
+
// Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
|
|
2119
|
+
// (field case 2026-08-18). What this cache exists to avoid is the LLM
|
|
2120
|
+
// CALL — the gates are code, and code gets fixed. Filtering to
|
|
2121
|
+
// `r.applied` here meant a gate fix could never reach a workdir that had
|
|
2122
|
+
// already cached a refusal: the Urdu run whose 11 correct repairs stayed
|
|
2123
|
+
// refused after the Latin-only `norm` was fixed, because produce replayed
|
|
2124
|
+
// the old verdicts instead of recomputing them. The vouched set rides
|
|
2125
|
+
// along for the same reason it always did — a dictionary-vouched
|
|
2126
|
+
// correction must clear the phonetic gate on replay too.
|
|
2127
|
+
const decided = applyRepairs(
|
|
1898
2128
|
rawTranscript,
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
2129
|
+
cached.map(({ startWord, endWord, heard, correction }) => ({
|
|
2130
|
+
startWord,
|
|
2131
|
+
endWord,
|
|
2132
|
+
heard,
|
|
2133
|
+
correction,
|
|
2134
|
+
})),
|
|
1904
2135
|
{ dictionary },
|
|
1905
|
-
)
|
|
1906
|
-
|
|
2136
|
+
);
|
|
2137
|
+
repairs = decided.applied;
|
|
2138
|
+
transcript = decided.transcript;
|
|
2139
|
+
const now = repairs.filter((r) => r.applied).length;
|
|
2140
|
+
const before = cached.filter((r) => r.applied).length;
|
|
2141
|
+
// Re-decided verdicts are the truth from here on: persist them so the
|
|
2142
|
+
// report, the next run and this run cannot disagree about what applied.
|
|
2143
|
+
if (now !== before) await writeFile(repairCache, JSON.stringify(repairs, null, 2));
|
|
2144
|
+
console.log(
|
|
2145
|
+
`▸ repairs cached (${now} applied of ${cached.length} proposed` +
|
|
2146
|
+
`${now === before ? "" : `, re-decided from ${before}`})`,
|
|
2147
|
+
);
|
|
1907
2148
|
} else {
|
|
1908
2149
|
const repairAnim = isInteractive()
|
|
1909
2150
|
? new StageAnimator(
|
|
@@ -2431,6 +2672,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2431
2672
|
// beside `render-props.json`'s own write (see the comment there for why).
|
|
2432
2673
|
for (const r of cutResult.reports) console.log(` ⚠ ${r}`);
|
|
2433
2674
|
|
|
2675
|
+
// Retire hides whose words this run's FINAL cutlist removes (§59b
|
|
2676
|
+
// revisited): the "captions + video" delete writes a hide (instant
|
|
2677
|
+
// preview) plus a cut (this run), and once the cut lands the cut
|
|
2678
|
+
// supersedes the hide — see `pruneHidesInsideCuts`. Pruned HERE, before
|
|
2679
|
+
// `reconcileCaptionEdits` applies the hide layer, so the retired keys
|
|
2680
|
+
// never surface as "the cut removed it" drop lines on this or any later
|
|
2681
|
+
// run. The doc write itself goes through the one sanctioned overrides.json
|
|
2682
|
+
// write further down, gated alongside `cutResult.changed`.
|
|
2683
|
+
const hidePrune = pruneHidesInsideCuts(overrideDoc, cutlist);
|
|
2684
|
+
overrideDoc = hidePrune.doc;
|
|
2685
|
+
const hidesPruned = hidePrune.pruned.length > 0;
|
|
2686
|
+
if (hidesPruned) {
|
|
2687
|
+
console.log(
|
|
2688
|
+
`▸ captions: ${hidePrune.pruned.length} hidden-word override(s) retired — their words are cut`,
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2434
2692
|
const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
|
|
2435
2693
|
for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
|
|
2436
2694
|
|
|
@@ -3537,7 +3795,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3537
3795
|
// recovered from (`legacySplitId`). Repairing the captions would destroy the
|
|
3538
3796
|
// evidence for the split. `writeOverrideDoc` carries the full argument for
|
|
3539
3797
|
// why a caption-only write has nothing worth backing up.
|
|
3540
|
-
|
|
3798
|
+
// `hidesPruned` joins the gate for the same reason `captionKeysReanchored`
|
|
3799
|
+
// did: a hide retired by its own cut (`pruneHidesInsideCuts`) changes the
|
|
3800
|
+
// doc without changing the cut entries, and skipping the write would
|
|
3801
|
+
// re-report "the cut removed it" on every later run. It does NOT spend the
|
|
3802
|
+
// `.bak` — retiring a redundant key is not the cut re-anchoring the backup
|
|
3803
|
+
// exists to survive.
|
|
3804
|
+
if (cutResult.changed || captionKeysReanchored || hidesPruned) {
|
|
3541
3805
|
await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
|
|
3542
3806
|
console.log(overridesWriteLine(cutResult.changed));
|
|
3543
3807
|
}
|
|
@@ -3664,6 +3928,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3664
3928
|
}
|
|
3665
3929
|
}
|
|
3666
3930
|
|
|
3931
|
+
// --concurrency, else config renderConcurrency, else cpus-2 with a floor of
|
|
3932
|
+
// 2 (resolveRenderConcurrency has the precedence and the why: leave cores
|
|
3933
|
+
// for the ffmpeg decode workers every tab waits on). Resolved BEFORE the log
|
|
3934
|
+
// line below so the count can go INTO it — the 2026-08-19 whole-browser OOM
|
|
3935
|
+
// took a machine spec and arithmetic to diagnose, because no line of the
|
|
3936
|
+
// render's own output ever said how many tabs it opened.
|
|
3937
|
+
const renderConcurrency = resolveRenderConcurrency(
|
|
3938
|
+
opts.concurrency,
|
|
3939
|
+
cfg.renderConcurrency,
|
|
3940
|
+
cpus().length,
|
|
3941
|
+
);
|
|
3942
|
+
if (renderConcurrency.warning) console.log(renderConcurrency.warning);
|
|
3667
3943
|
let renderHud: RenderTimelineHUD | null = null;
|
|
3668
3944
|
if (interactive) {
|
|
3669
3945
|
renderHud = new RenderTimelineHUD({
|
|
@@ -3673,33 +3949,109 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3673
3949
|
aspect: landscape ? "16:9" : "9:16",
|
|
3674
3950
|
}).start();
|
|
3675
3951
|
} else {
|
|
3676
|
-
console.log(
|
|
3952
|
+
console.log(`▸ rendering… (${renderConcurrency.concurrency} parallel tabs)`);
|
|
3677
3953
|
}
|
|
3678
3954
|
let lastPct = -10;
|
|
3679
|
-
//
|
|
3680
|
-
//
|
|
3681
|
-
//
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3955
|
+
// Ctrl-C must actually stop the render (2026-08-19 field report). Remotion
|
|
3956
|
+
// owns a browser and ffmpeg children that outlive a bare process death, so
|
|
3957
|
+
// stopping means handing renderMedia a cancelSignal and firing it — node's
|
|
3958
|
+
// default SIGINT handling would leave those orphaned.
|
|
3959
|
+
//
|
|
3960
|
+
// Registered around the RENDER PHASE ONLY, and removed in the finally: a
|
|
3961
|
+
// handler that outlived this phase would swallow Ctrl-C during the LLM and
|
|
3962
|
+
// whisper phases, which exit promptly today and must keep doing so.
|
|
3963
|
+
//
|
|
3964
|
+
// SIGTERM is wired for the same reason as SIGINT, plus one of its own: the
|
|
3965
|
+
// editor's /api/render/cancel (edit.ts) kills this process as a child, and
|
|
3966
|
+
// before this handler that kill left the browser behind. That path is
|
|
3967
|
+
// otherwise untouched — it already reports its own cancel. It also inherits
|
|
3968
|
+
// the dead-window fix below: the editor's Cancel button now kills the child
|
|
3969
|
+
// DURING bundling too, where the SIGTERM used to be swallowed and
|
|
3970
|
+
// /api/render kept answering 409 until the bundle finished on its own.
|
|
3971
|
+
const renderCancel = makeCancelSignal();
|
|
3972
|
+
// An array, not a `let`: the handler assigns from inside a closure, and TS's
|
|
3973
|
+
// flow analysis would still read a `let` as null in the catch below (it
|
|
3974
|
+
// narrowed the branch to `never`). First signal wins — hammering Ctrl-C must
|
|
3975
|
+
// not rewrite the verdict while the teardown is already running.
|
|
3976
|
+
const cancellations: RenderCancellation[] = [];
|
|
3977
|
+
// Where renderProduction is, so the handler knows whether the cancel signal
|
|
3978
|
+
// has anyone listening (renderSignalAction has the whole reasoning).
|
|
3979
|
+
// "pre-render" from the start: the handlers go on before the call.
|
|
3980
|
+
let signalPhase: RenderSignalPhase = "pre-render";
|
|
3981
|
+
let signalCount = 0;
|
|
3982
|
+
// Shared by all three places a cancel is finalised — the handler, the
|
|
3983
|
+
// rejected-render catch, and the post-render tail check.
|
|
3984
|
+
const finishCancel = (c: RenderCancellation, note?: string): never => {
|
|
3985
|
+
if (renderHud) renderHud.stop();
|
|
3986
|
+
if (note) console.log(note);
|
|
3987
|
+
// rmSync, not fs/promises rm: the handler path calls this and exits on the
|
|
3988
|
+
// next statement, and an awaited unlink would never get its turn.
|
|
3989
|
+
for (const path of c.removePaths) rmSync(path, { force: true });
|
|
3990
|
+
console.log(c.message);
|
|
3991
|
+
// Exits here rather than throwing: program.ts's catch would record this as
|
|
3992
|
+
// produce_failed and print "✗ <message>", dressing a deliberate stop as a
|
|
3993
|
+
// bug (R16 §60's distinction).
|
|
3994
|
+
process.exit(c.exitCode);
|
|
3995
|
+
};
|
|
3996
|
+
const onCancelSignal = (signal: "SIGINT" | "SIGTERM") => {
|
|
3997
|
+
signalCount += 1;
|
|
3998
|
+
if (cancellations.length === 0) cancellations.push(renderCancellation(signal, rawPath));
|
|
3999
|
+
const action = renderSignalAction(signalPhase, signalCount);
|
|
4000
|
+
if (action.cancel) renderCancel.cancel();
|
|
4001
|
+
if (action.exitNow) finishCancel(cancellations[0]!, action.note);
|
|
4002
|
+
};
|
|
4003
|
+
const onSigint = () => onCancelSignal("SIGINT");
|
|
4004
|
+
const onSigterm = () => onCancelSignal("SIGTERM");
|
|
4005
|
+
process.on("SIGINT", onSigint);
|
|
4006
|
+
process.on("SIGTERM", onSigterm);
|
|
4007
|
+
try {
|
|
4008
|
+
await phases.time("render", () =>
|
|
4009
|
+
renderProduction(props, {
|
|
4010
|
+
publicDir: dirname(renderVideo),
|
|
4011
|
+
outPath: rawPath,
|
|
4012
|
+
browserExecutable: cfg.browserExecutable,
|
|
4013
|
+
concurrency: renderConcurrency.concurrency,
|
|
4014
|
+
cancelSignal: renderCancel.cancelSignal,
|
|
4015
|
+
onPhase: (phase: RenderPhase) => {
|
|
4016
|
+
signalPhase = renderSignalPhaseOf(phase);
|
|
4017
|
+
},
|
|
4018
|
+
onProgress: (p) => {
|
|
4019
|
+
if (renderHud) {
|
|
4020
|
+
renderHud.setProgress(p);
|
|
4021
|
+
} else {
|
|
4022
|
+
const pct = Math.floor(p * 100);
|
|
4023
|
+
if (pct >= lastPct + 10) {
|
|
4024
|
+
lastPct = pct;
|
|
4025
|
+
process.stdout.write(` ${pct}%\n`);
|
|
4026
|
+
}
|
|
3698
4027
|
}
|
|
3699
|
-
}
|
|
3700
|
-
},
|
|
3701
|
-
|
|
3702
|
-
|
|
4028
|
+
},
|
|
4029
|
+
}),
|
|
4030
|
+
);
|
|
4031
|
+
// renderMedia resolved, so nothing is left to cancel cooperatively. Not a
|
|
4032
|
+
// phase the handler can exit from either — see the tail check below.
|
|
4033
|
+
signalPhase = "post-render";
|
|
4034
|
+
} catch (err) {
|
|
4035
|
+
// A cancelled renderMedia rejects like any other failure; only the
|
|
4036
|
+
// handler above can tell the two apart.
|
|
4037
|
+
const cancellation = cancellations[0];
|
|
4038
|
+
if (!cancellation) throw err;
|
|
4039
|
+
finishCancel(cancellation);
|
|
4040
|
+
} finally {
|
|
4041
|
+
process.off("SIGINT", onSigint);
|
|
4042
|
+
process.off("SIGTERM", onSigterm);
|
|
4043
|
+
}
|
|
4044
|
+
// The TAIL CASE (2026-08-19 review): a signal landing after renderMedia
|
|
4045
|
+
// resolved but before the `finally` above took the handlers off used to be
|
|
4046
|
+
// swallowed outright — nothing threw, the run went on to loudnorm and
|
|
4047
|
+
// mastering, and the user got a complete video having pressed Ctrl-C. We
|
|
4048
|
+
// HONOR it: the user asked to stop, and stopping here costs only the
|
|
4049
|
+
// mastering pass, whereas ignoring it hands them the file they just said
|
|
4050
|
+
// they did not want. Nothing has been written to --out yet (moveFile is
|
|
4051
|
+
// below), so honoring here is still "no output", the same promise every
|
|
4052
|
+
// other cancel makes.
|
|
4053
|
+
const tailCancellation = cancellations[0];
|
|
4054
|
+
if (tailCancellation) finishCancel(tailCancellation);
|
|
3703
4055
|
if (renderHud) renderHud.stop();
|
|
3704
4056
|
|
|
3705
4057
|
const masterAnim = interactive
|
|
@@ -3725,7 +4077,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3725
4077
|
{
|
|
3726
4078
|
// §35's cap applies here too: a cached beat sheet from before the fix, or
|
|
3727
4079
|
// the hook fallback, must not slip a 13-word paragraph onto a thumbnail.
|
|
3728
|
-
const
|
|
4080
|
+
const generatedCoverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
|
|
4081
|
+
// A headline someone typed (`ossclip cover --text`, or the editor) is a
|
|
4082
|
+
// user-owned file, exactly like overrides.json and the approved thumbnail
|
|
4083
|
+
// concept: this run does NOT quietly replace it with a fresh beat sheet's
|
|
4084
|
+
// coverText. Read before the decision below, because it decides the text
|
|
4085
|
+
// the decision is made about.
|
|
4086
|
+
const priorCover = await readCoverProvenance(work);
|
|
4087
|
+
const heldCover = coverTextHold({
|
|
4088
|
+
generated: generatedCoverText,
|
|
4089
|
+
persisted: priorCover,
|
|
4090
|
+
reset: opts.coverTextReset === true,
|
|
4091
|
+
});
|
|
4092
|
+
if (heldCover.message) console.log(heldCover.message);
|
|
4093
|
+
const coverText = heldCover.text;
|
|
3729
4094
|
// Urdu field run 2026-08-05: a run without --produce has no hook text,
|
|
3730
4095
|
// and skipping the cover for that threw away the part that never needed
|
|
3731
4096
|
// text — the sharpness-scored face frame. No headline now means a bare
|
|
@@ -3752,7 +4117,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3752
4117
|
if (!pick) {
|
|
3753
4118
|
console.log("▸ no usable cover frame found — skipping cover");
|
|
3754
4119
|
} else {
|
|
3755
|
-
const frameName =
|
|
4120
|
+
const frameName = COVER_FRAME_BASENAME;
|
|
3756
4121
|
await run(cfg.ffmpegPath, [
|
|
3757
4122
|
"-v", "error",
|
|
3758
4123
|
"-ss", pick.timeSec.toFixed(3),
|
|
@@ -3786,16 +4151,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3786
4151
|
`▸ cover from ${pick.timeSec.toFixed(1)}s ` +
|
|
3787
4152
|
`(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)})…`,
|
|
3788
4153
|
);
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
4154
|
+
// …unless the headline is the user's own, which §34 does not get to
|
|
4155
|
+
// erase (cover.ts's coverBannerText has the reasoning). Unchanged
|
|
4156
|
+
// for a generated headline, including the line it prints.
|
|
4157
|
+
const banner = coverBannerText({
|
|
4158
|
+
text: coverText,
|
|
4159
|
+
textSource: heldCover.textSource,
|
|
4160
|
+
sourceTitled,
|
|
4161
|
+
});
|
|
4162
|
+
if (banner.note) console.log(banner.note);
|
|
4163
|
+
// The band log is about routing a banner around the face, so it
|
|
4164
|
+
// follows whether there IS a banner — for a §34-suppressed cover
|
|
4165
|
+
// there is none, for a surviving user headline there is.
|
|
4166
|
+
if (banner.text !== "" && pick.face) {
|
|
3792
4167
|
const band = coverTextRect(pick.face, frame);
|
|
3793
4168
|
console.log(
|
|
3794
4169
|
` ▸ banner in the ${band.y + band.h / 2 < pick.face.centerYFrac ? "band above" : "band below"} ` +
|
|
3795
4170
|
`the face (${(band.y * 100).toFixed(0)}-${((band.y + band.h) * 100).toFixed(0)}%)`,
|
|
3796
4171
|
);
|
|
3797
4172
|
}
|
|
3798
|
-
bannerText =
|
|
4173
|
+
bannerText = banner.text;
|
|
3799
4174
|
} else {
|
|
3800
4175
|
console.log(
|
|
3801
4176
|
`▸ cover from ${pick.timeSec.toFixed(1)}s ` +
|
|
@@ -3803,22 +4178,71 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3803
4178
|
`— no banner text (run --produce for one)`,
|
|
3804
4179
|
);
|
|
3805
4180
|
}
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
4181
|
+
// The shared builder, not an inline props literal: `ossclip cover`
|
|
4182
|
+
// and the editor's regenerate endpoint call the same one, and two
|
|
4183
|
+
// spellings of these arguments drifting apart is the defect that
|
|
4184
|
+
// whole feature exists to prevent (cover.ts).
|
|
4185
|
+
const coverRender = buildCoverRender({
|
|
4186
|
+
frameFileName: frameName,
|
|
4187
|
+
text: bannerText,
|
|
4188
|
+
// The RESOLVED theme — so the cover's banner already carries the
|
|
4189
|
+
// config theme (F6) via resolveTheme's base, no separate wiring.
|
|
4190
|
+
theme,
|
|
4191
|
+
face: pick.face,
|
|
4192
|
+
// The cover is the OUTPUT's thumbnail — a landscape render gets a
|
|
4193
|
+
// landscape cover (R16 §76). The still was already extracted at
|
|
4194
|
+
// this size; only the composition disagreed.
|
|
4195
|
+
frame: { width: frame.width, height: frame.height },
|
|
4196
|
+
publicDir: work,
|
|
4197
|
+
outPath: coverPath,
|
|
4198
|
+
browserExecutable: cfg.browserExecutable,
|
|
4199
|
+
});
|
|
4200
|
+
await renderCover(coverRender.props, coverRender.opts);
|
|
3821
4201
|
console.log(`✓ cover → ${coverPath}`);
|
|
4202
|
+
// Provenance, so the next headline change costs seconds instead of a
|
|
4203
|
+
// full re-render. Written AFTER the render succeeded and describing
|
|
4204
|
+
// what that render actually used — `pick.face` is the cover-crop
|
|
4205
|
+
// geometry nothing else on disk carries, and `cropVf` is not
|
|
4206
|
+
// reconstructible from the workdir either.
|
|
4207
|
+
//
|
|
4208
|
+
// Additive by contract (§112, the posture the YouTube pack block
|
|
4209
|
+
// below states): the video and the cover are already on disk, so a
|
|
4210
|
+
// failed sidecar write is one loud line, never a dead run.
|
|
4211
|
+
try {
|
|
4212
|
+
await writeCoverProvenance(work, {
|
|
4213
|
+
version: 1,
|
|
4214
|
+
text: bannerText,
|
|
4215
|
+
// "user" only when THIS run kept a headline someone typed
|
|
4216
|
+
// (coverTextHold above) — produce itself never authors one.
|
|
4217
|
+
textSource: heldCover.textSource,
|
|
4218
|
+
frame: {
|
|
4219
|
+
// produce keeps picking from the SOURCE — zero perturbation for
|
|
4220
|
+
// existing users. `ossclip cover` is the one that defaults to
|
|
4221
|
+
// the finished render.
|
|
4222
|
+
source: "source",
|
|
4223
|
+
timeSec: pick.timeSec,
|
|
4224
|
+
face: pick.face ?? null,
|
|
4225
|
+
hasFace: pick.hasFace,
|
|
4226
|
+
sharpness: pick.sharpness,
|
|
4227
|
+
fileName: frameName,
|
|
4228
|
+
// Workdir-relative when the video IS a workdir intermediate (a
|
|
4229
|
+
// folder run's concat mezzanine), absolute otherwise — the rule
|
|
4230
|
+
// `ossclip cover` reads it back with.
|
|
4231
|
+
sourceVideo: provenanceVideoPath(work, input),
|
|
4232
|
+
// cropFilter returns "" for an uncropped source; null says "no
|
|
4233
|
+
// crop" without a caller having to know that convention.
|
|
4234
|
+
cropVf: cropVf || null,
|
|
4235
|
+
},
|
|
4236
|
+
size: { width: frame.width, height: frame.height },
|
|
4237
|
+
out: coverPath,
|
|
4238
|
+
});
|
|
4239
|
+
} catch (err) {
|
|
4240
|
+
console.log(
|
|
4241
|
+
` ⚠ could not write ${COVER_PROVENANCE_BASENAME} ` +
|
|
4242
|
+
`(${err instanceof Error ? err.message : String(err)}) — ` +
|
|
4243
|
+
`the cover shipped; a later headline change will re-pick the frame`,
|
|
4244
|
+
);
|
|
4245
|
+
}
|
|
3822
4246
|
}
|
|
3823
4247
|
}
|
|
3824
4248
|
}
|