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/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 { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
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). Default is cpus-2 with a floor of 2: the render is decode-bound —
453
- * every tab waits on OffthreadVideo's ffmpeg extract workers — so saturating
454
- * all cores with tabs starves the very processes the tabs block on. The
455
- * config's `renderConcurrency` overrides for machines where that guess is
456
- * wrong; validated here at the consumer (the `dictionary` posture: the value
457
- * comes from hand-editable JSON loadConfig doesn't zod-parse), a positive
458
- * integer or one warning and the default — never a coerced tab count. Pure
459
- * so the config × cpu matrix is testable without a config file or real
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
- return originalInput.replace(/(\.[^.]+)?$/, ".ossclip.mp4");
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
- const isFolder = statSync(input).isDirectory();
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
- repairs = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
1897
- transcript = applyRepairs(
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
- repairs.filter((r) => r.applied),
1900
- // The vouched set must survive the cache: a dictionary-vouched
1901
- // correction re-runs applyRepairs' guards here, and without the
1902
- // dictionary the phonetic gate would refuse on replay what it
1903
- // accepted on the first run.
2129
+ cached.map(({ startWord, endWord, heard, correction }) => ({
2130
+ startWord,
2131
+ endWord,
2132
+ heard,
2133
+ correction,
2134
+ })),
1904
2135
  { dictionary },
1905
- ).transcript;
1906
- console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
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
- if (cutResult.changed || captionKeysReanchored) {
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("▸ rendering…");
3952
+ console.log(`▸ rendering… (${renderConcurrency.concurrency} parallel tabs)`);
3677
3953
  }
3678
3954
  let lastPct = -10;
3679
- // cpus-2 with a floor of 2 (resolveRenderConcurrency has the why: leave
3680
- // cores for the ffmpeg decode workers every tab waits on). Resolved here,
3681
- // next to the call it feeds, so the warning prints once per run.
3682
- const renderConcurrency = resolveRenderConcurrency(cfg.renderConcurrency, cpus().length);
3683
- if (renderConcurrency.warning) console.log(renderConcurrency.warning);
3684
- await phases.time("render", () =>
3685
- renderProduction(props, {
3686
- publicDir: dirname(renderVideo),
3687
- outPath: rawPath,
3688
- browserExecutable: cfg.browserExecutable,
3689
- concurrency: renderConcurrency.concurrency,
3690
- onProgress: (p) => {
3691
- if (renderHud) {
3692
- renderHud.setProgress(p);
3693
- } else {
3694
- const pct = Math.floor(p * 100);
3695
- if (pct >= lastPct + 10) {
3696
- lastPct = pct;
3697
- process.stdout.write(` ${pct}%\n`);
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 coverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
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 = "cover-frame.png";
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
- if (sourceTitled) {
3790
- console.log(" ▸ source already has a title in this frame — shipping it without a banner");
3791
- } else if (pick.face) {
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 = sourceTitled ? "" : coverText;
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
- await renderCover(
3807
- {
3808
- frameFileName: frameName,
3809
- text: bannerText,
3810
- // The RESOLVED theme — so the cover's banner already carries the
3811
- // config theme (F6) via resolveTheme's base, no separate wiring.
3812
- theme,
3813
- face: pick.face,
3814
- // The cover is the OUTPUT's thumbnail — a landscape render gets a
3815
- // landscape cover (R16 §76). The still was already extracted at
3816
- // this size; only the composition disagreed.
3817
- frame: { width: frame.width, height: frame.height },
3818
- },
3819
- { publicDir: work, outPath: coverPath, browserExecutable: cfg.browserExecutable },
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
  }