ossclip 0.1.25 → 0.1.26

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,6 +37,8 @@ import {
29
37
  concatFolder,
30
38
  folderManifestKey,
31
39
  listFolderVideos,
40
+ outInsideInputFolderMessage,
41
+ outPathInsideInput,
32
42
  coverDecision,
33
43
  coverHeadline,
34
44
  cropFilter,
@@ -79,6 +89,7 @@ import {
79
89
  thumbnailDecision,
80
90
  thumbnailImageCacheName,
81
91
  applyUserCuts,
92
+ pruneHidesInsideCuts,
82
93
  loadConfig,
83
94
  loudnorm,
84
95
  MAX_NORMALIZE_UPSCALE,
@@ -132,6 +143,7 @@ import {
132
143
  type Layout,
133
144
  type LlmProvider,
134
145
  type Production,
146
+ ossclipOutputPathFor,
135
147
  type ProviderName,
136
148
  type Scene,
137
149
  type SceneComponentId,
@@ -161,7 +173,8 @@ import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from
161
173
  import { reconcileCaptionEdits } from "./caption-report";
162
174
  import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
163
175
  import { recordedProduceArgs } from "./replay-argv";
164
- import { renderCover, renderProduction } from "@ossclip/renderer";
176
+ import { makeCancelSignal, renderCover, renderProduction } from "@ossclip/renderer";
177
+ import type { RenderPhase } from "@ossclip/renderer";
165
178
  import {
166
179
  DEFAULT_FACE,
167
180
  coverTextRect,
@@ -321,6 +334,13 @@ export interface ProduceOptions {
321
334
  * platform chrome to dodge and a landscape source needs no cropping at all.
322
335
  */
323
336
  aspect?: "9:16" | "16:9";
337
+ /**
338
+ * `--concurrency <n>`: how many browser tabs the render opens at once,
339
+ * beating the config's `renderConcurrency` and the cpus-2 default
340
+ * (`resolveRenderConcurrency`). Already validated by commander
341
+ * (`concurrencyFlag`), so a number here is always a positive integer.
342
+ */
343
+ concurrency?: number;
324
344
  /**
325
345
  * `--clip <seconds>` (R19 §93): produce only the strongest ~N-second window
326
346
  * of a long take, chosen by the producer in the same editorial call as the
@@ -449,21 +469,37 @@ export function resolveYoutube(
449
469
 
450
470
  /**
451
471
  * 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
472
+ * pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
473
+ * beats the cpus-2 default (floor 2). The default is cpus-2 because the
474
+ * render is decode-bound — every tab waits on OffthreadVideo's ffmpeg extract
475
+ * workers — so saturating all cores with tabs starves the very processes the
476
+ * tabs block on.
477
+ *
478
+ * The flag exists because cpus-2 is a CPU guess with no memory term in it
479
+ * (2026-08-19 field case): a 14-core / 36GB Mac resolved to 12 tabs on a
480
+ * 1080×1920 source and Chrome died WHOLE, twelve in-flight frames at a time.
481
+ * `offthreadVideoCacheSizeInBytes` bounds the cache side of that
482
+ * (render-options.ts); this is the hatch for the tab side, typed per run
483
+ * rather than edited into a config file mid-investigation.
484
+ *
485
+ * The flag arrives already validated by commander (`concurrencyFlag` in
486
+ * program.ts rejects a non-positive/non-integer at the front door, §93a), so
487
+ * only the config value is checked here — the `dictionary` posture, since it
488
+ * comes from hand-editable JSON loadConfig doesn't zod-parse: a positive
489
+ * integer, or one warning and the default, never a coerced tab count. Pure so
490
+ * the flag × config × cpu matrix is testable without a config file or real
460
491
  * cpus().
461
492
  */
462
493
  export function resolveRenderConcurrency(
494
+ flagValue: number | undefined,
463
495
  configValue: unknown,
464
496
  cpuCount: number,
465
497
  ): { concurrency: number; warning?: string } {
466
498
  const fallback = Math.max(2, cpuCount - 2);
499
+ // Typed-beats-config, and typed also beats a MALFORMED config: the user
500
+ // asking for 4 tabs on the command line gets 4, not a warning about a
501
+ // config key they did not touch this run.
502
+ if (flagValue !== undefined) return { concurrency: flagValue };
467
503
  if (configValue === undefined) return { concurrency: fallback };
468
504
  if (typeof configValue === "number" && Number.isInteger(configValue) && configValue > 0) {
469
505
  return { concurrency: configValue };
@@ -474,6 +510,119 @@ export function resolveRenderConcurrency(
474
510
  };
475
511
  }
476
512
 
513
+ /** What a signal that interrupted the render phase costs the caller. */
514
+ export interface RenderCancellation {
515
+ /** Partial render output to delete before exiting. */
516
+ removePaths: string[];
517
+ /** Shell convention: 128 + the signal's number (SIGINT 2, SIGTERM 15). */
518
+ exitCode: number;
519
+ message: string;
520
+ }
521
+
522
+ /**
523
+ * The decision half of Ctrl-C-cancels-the-render (2026-08-19 field report:
524
+ * "Cancelling rerendering doesn't work" — nothing in the CLI handled SIGINT,
525
+ * and no cancelSignal reached Remotion, so the browser and its ffmpeg
526
+ * children kept going after the process was told to stop). The signal wiring
527
+ * itself is I/O and lives at the render call site; what to delete and what to
528
+ * exit with is decided here so it can be tested without a real render.
529
+ *
530
+ * `rawPath` is the workdir's `render-raw.mp4`, which a finished run
531
+ * loudnorms and only THEN moves to the user's --out — so a cancel never has
532
+ * an output file to mistake for a finished render. The raw partial is deleted
533
+ * anyway: it is a truncated mp4 sitting in the workdir under the name the
534
+ * next run reads, and a stale one there is the kind of thing that gets picked
535
+ * up by hand and mailed to someone.
536
+ *
537
+ * Non-zero exit, because a cancelled render did not produce the video the
538
+ * caller asked for — but 130/143 rather than 1, so a script can tell a
539
+ * deliberate stop from a failure (the same distinction the editor's
540
+ * /api/render/cancel draws, R16 §60).
541
+ */
542
+ export function renderCancellation(
543
+ signal: "SIGINT" | "SIGTERM",
544
+ rawPath: string,
545
+ ): RenderCancellation {
546
+ return {
547
+ removePaths: [rawPath],
548
+ exitCode: signal === "SIGINT" ? 130 : 143,
549
+ message: "▸ cancelled — partial output discarded",
550
+ };
551
+ }
552
+
553
+ /**
554
+ * Where the run is when a signal lands, collapsed to the three cases that
555
+ * behave differently. `RenderPhase`'s "bundling" and "selecting" both collapse
556
+ * to "pre-render": neither takes a cancelSignal, so neither can be stopped
557
+ * cooperatively. "post-render" is the window between `renderMedia` resolving
558
+ * and the handlers coming off in the `finally`.
559
+ */
560
+ export type RenderSignalPhase = "pre-render" | "rendering" | "post-render";
561
+
562
+ /** Map the renderer's phase report onto what a signal can do about it. */
563
+ export function renderSignalPhaseOf(phase: RenderPhase): RenderSignalPhase {
564
+ return phase === "rendering" ? "rendering" : "pre-render";
565
+ }
566
+
567
+ /** What the SIGINT/SIGTERM handler should do about the signal it just got. */
568
+ export interface RenderSignalAction {
569
+ /** Fire the Remotion cancel signal. Free, and only `renderMedia` listens. */
570
+ cancel: boolean;
571
+ /**
572
+ * Tear down and `process.exit` from INSIDE the handler, because nothing
573
+ * downstream is going to stop on its own.
574
+ */
575
+ exitNow: boolean;
576
+ /** Printed above the cancellation message when the exit needs explaining. */
577
+ note?: string;
578
+ }
579
+
580
+ /**
581
+ * The decision half of "Ctrl-C must never be a no-op" (2026-08-19 review of
582
+ * the cancel feature). Registering a SIGINT listener SUPPRESSES node's default
583
+ * terminate, so the cancel feature as first written made Ctrl-C *worse* than
584
+ * before it existed in every phase the cancelSignal does not reach:
585
+ *
586
+ * - "pre-render" — `bundle()` and `selectComposition()` take no cancelSignal
587
+ * in @remotion/renderer 4.0.499 (verified against the installed types; see
588
+ * RenderPhase in @ossclip/renderer). A cold bundle is tens of seconds, and
589
+ * minutes when Chrome is downloaded on first run, and for all of it Ctrl-C
590
+ * did NOTHING while the terminal looked hung. The handler must exit itself.
591
+ * That can orphan the Chrome `selectComposition` opened — but a bare Ctrl-C
592
+ * before the cancel feature did exactly the same, so it is not a
593
+ * regression, and a terminal that ignores Ctrl-C is worse than a stray
594
+ * browser process.
595
+ * - "rendering" — the one phase that IS cooperative: fire the signal and let
596
+ * Remotion tear the browser and its ffmpeg children down.
597
+ * - "post-render" — HONORED, not ignored: the caller stops before mastering
598
+ * and discards the raw render (see the tail check at the render call site).
599
+ *
600
+ * SECOND SIGNAL ALWAYS EXITS, in every phase. If Remotion's teardown wedges,
601
+ * the user's only remaining move must not be `kill -9` from another terminal.
602
+ */
603
+ export function renderSignalAction(
604
+ phase: RenderSignalPhase,
605
+ signalCount: number,
606
+ ): RenderSignalAction {
607
+ if (signalCount >= 2) {
608
+ return {
609
+ cancel: true,
610
+ exitNow: true,
611
+ note: "▸ second signal — exiting without waiting for the render to shut down",
612
+ };
613
+ }
614
+ if (phase === "pre-render") {
615
+ return {
616
+ cancel: true,
617
+ exitNow: true,
618
+ note:
619
+ "▸ cancelled while preparing the render — that phase cannot be interrupted " +
620
+ "cleanly, so stopping the process",
621
+ };
622
+ }
623
+ return { cancel: true, exitNow: false };
624
+ }
625
+
477
626
  // Moved to paths.ts (2026-08-17, editor thumbnail panel): the edit server
478
627
  // derives `<out>.thumbnail.png` from command.json's recorded out and must not
479
628
  // import this module — produce.ts imports edit.ts (recordRecentProject), so
@@ -1209,7 +1358,34 @@ function deriveWorkdir(
1209
1358
  * file input's equivalent default already lands.
1210
1359
  */
1211
1360
  export function defaultOutPath(originalInput: string): string {
1212
- return originalInput.replace(/(\.[^.]+)?$/, ".ossclip.mp4");
1361
+ // Delegates to core so the refusal message's suggestion and the actual
1362
+ // default can never drift apart (and both strip the tab-completed trailing
1363
+ // slash — the 2026-08-18 hidden-dotfile-inside-the-folder field case).
1364
+ return ossclipOutputPathFor(originalInput);
1365
+ }
1366
+
1367
+ /**
1368
+ * The ⚠ line a REPLAYED produce prints when it keys to a different workdir
1369
+ * than the one the editor launched it for (2026-08-18 field cascade, part
1370
+ * 3): the edit server sets OSSCLIP_REPLAY_WORKDIR to the workdir whose
1371
+ * command.json it is replaying; if the run then derives another workdir —
1372
+ * the folder's content changed since the record — the edits saved in the
1373
+ * old workdir's overrides.json silently stop applying, and nothing else in
1374
+ * the run says so. Pure (drift decision in, line out) so the comparison is
1375
+ * testable without spawning a replay; null when this isn't a replay or
1376
+ * nothing drifted.
1377
+ */
1378
+ export function replayWorkdirWarning(
1379
+ replayedWorkdir: string | undefined,
1380
+ derivedWorkdir: string,
1381
+ ): string | null {
1382
+ if (replayedWorkdir === undefined || replayedWorkdir === "") return null;
1383
+ if (resolve(replayedWorkdir) === resolve(derivedWorkdir)) return null;
1384
+ return (
1385
+ `⚠ this run's workdir differs from the one the editor replayed — edits ` +
1386
+ `saved in ${join(replayedWorkdir, "overrides.json")} will NOT apply to ` +
1387
+ `this render (the input's content changed since that command was recorded)`
1388
+ );
1213
1389
  }
1214
1390
 
1215
1391
  /**
@@ -1415,6 +1591,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1415
1591
  : resolve(baseCwd, expandHome(inputArg));
1416
1592
  let input = originalInput;
1417
1593
  if (!existsSync(input)) throw new Error(`input not found: ${input}`);
1594
+ // Decided once, here — the out-path gate below and the folder pipeline
1595
+ // further down must read the same answer to "is this a folder run".
1596
+ const isFolder = statSync(input).isDirectory();
1418
1597
 
1419
1598
  // §93b: the window is an editorial judgement, and there is deliberately no
1420
1599
  // heuristic fallback — an automatically-guessed 60 seconds reads as a bug,
@@ -1449,6 +1628,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1449
1628
  ? outArg
1450
1629
  : resolve(baseCwd, outArg)
1451
1630
  : resolve(defaultOutPath(originalInput));
1631
+ // 2026-08-18 field cascade: an --out pointed INSIDE the input folder became
1632
+ // a 7th source clip on the next run — new content hash, fresh workdir,
1633
+ // EMPTY overrides — so the render silently dropped the user's saved edits
1634
+ // and the output duration doubled, three runs in a row. Refused BEFORE
1635
+ // ensureParentDir so the gate can't first mkdir a stray subfolder inside
1636
+ // the very input it is about to refuse. (Unreachable via the default out —
1637
+ // defaultOutPath lands BESIDE the folder — so only a typed --out can trip
1638
+ // it.)
1639
+ if (isFolder && outPathInsideInput(outPath, originalInput)) {
1640
+ throw new Error(outInsideInputFolderMessage(originalInput));
1641
+ }
1452
1642
  ensureParentDir(outPath);
1453
1643
 
1454
1644
  await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
@@ -1489,7 +1679,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1489
1679
  // video with captions transcribed against a different edit. Hashing the
1490
1680
  // manifest content gives a folder input the same invariant a file input
1491
1681
  // already has via `sha1File`: content changes ⇒ a fresh workdir.
1492
- const isFolder = statSync(input).isDirectory();
1682
+ // (`isFolder` itself is decided up top, beside the out-path gate.)
1493
1683
  // Final-review fix wave, cheap minor c: --sort only means anything for a
1494
1684
  // folder input; on a file it did nothing, silently. Gated on `sortExplicit`
1495
1685
  // (whether the user TYPED it) rather than on `opts.sort` itself, since
@@ -1518,6 +1708,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1518
1708
  );
1519
1709
  await mkdir(work, { recursive: true });
1520
1710
  console.log(`▸ workdir ${work}`);
1711
+ // See replayWorkdirWarning — set only by the edit server's /api/render
1712
+ // spawn, so a terminal run never sees it.
1713
+ const replayWarning = replayWorkdirWarning(process.env.OSSCLIP_REPLAY_WORKDIR, work);
1714
+ if (replayWarning !== null) console.log(replayWarning);
1521
1715
 
1522
1716
  // §131 residue: a folder re-key (clips renamed/added/removed → new content
1523
1717
  // hash) correctly lands in a fresh workdir, but any editor edits saved in
@@ -1566,6 +1760,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1566
1760
  ` ${result.nonVideoCount} non-video file${result.nonVideoCount === 1 ? "" : "s"} ignored`,
1567
1761
  );
1568
1762
  }
1763
+ // Loud, not folded into the non-video count (2026-08-18 field cascade):
1764
+ // an ossclip output sitting in the clips folder means an earlier run
1765
+ // wrote it there, and the user should learn that before it surprises
1766
+ // them elsewhere. The filter itself is pure (isOssclipOutputName) and
1767
+ // runs inside listFolderVideos, before the workdir hash is derived.
1768
+ if (result.ossclipOutputCount > 0) {
1769
+ console.log(
1770
+ `▸ folder: skipped ${result.ossclipOutputCount} ossclip output ` +
1771
+ `file${result.ossclipOutputCount === 1 ? "" : "s"}`,
1772
+ );
1773
+ }
1569
1774
  // Order visible immediately (folder-input-brief.md) — a wrong order is a
1570
1775
  // silent bug otherwise, invisible until someone watches the whole thing.
1571
1776
  result.clips.forEach((c, i) => {
@@ -1893,17 +2098,37 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1893
2098
  .slice(0, 8);
1894
2099
  const repairCache = join(work, `repairs-${rawKey}.json`);
1895
2100
  if (existsSync(repairCache)) {
1896
- repairs = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
1897
- transcript = applyRepairs(
2101
+ const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
2102
+ // Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
2103
+ // (field case 2026-08-18). What this cache exists to avoid is the LLM
2104
+ // CALL — the gates are code, and code gets fixed. Filtering to
2105
+ // `r.applied` here meant a gate fix could never reach a workdir that had
2106
+ // already cached a refusal: the Urdu run whose 11 correct repairs stayed
2107
+ // refused after the Latin-only `norm` was fixed, because produce replayed
2108
+ // the old verdicts instead of recomputing them. The vouched set rides
2109
+ // along for the same reason it always did — a dictionary-vouched
2110
+ // correction must clear the phonetic gate on replay too.
2111
+ const decided = applyRepairs(
1898
2112
  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.
2113
+ cached.map(({ startWord, endWord, heard, correction }) => ({
2114
+ startWord,
2115
+ endWord,
2116
+ heard,
2117
+ correction,
2118
+ })),
1904
2119
  { dictionary },
1905
- ).transcript;
1906
- console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
2120
+ );
2121
+ repairs = decided.applied;
2122
+ transcript = decided.transcript;
2123
+ const now = repairs.filter((r) => r.applied).length;
2124
+ const before = cached.filter((r) => r.applied).length;
2125
+ // Re-decided verdicts are the truth from here on: persist them so the
2126
+ // report, the next run and this run cannot disagree about what applied.
2127
+ if (now !== before) await writeFile(repairCache, JSON.stringify(repairs, null, 2));
2128
+ console.log(
2129
+ `▸ repairs cached (${now} applied of ${cached.length} proposed` +
2130
+ `${now === before ? "" : `, re-decided from ${before}`})`,
2131
+ );
1907
2132
  } else {
1908
2133
  const repairAnim = isInteractive()
1909
2134
  ? new StageAnimator(
@@ -2431,6 +2656,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2431
2656
  // beside `render-props.json`'s own write (see the comment there for why).
2432
2657
  for (const r of cutResult.reports) console.log(` ⚠ ${r}`);
2433
2658
 
2659
+ // Retire hides whose words this run's FINAL cutlist removes (§59b
2660
+ // revisited): the "captions + video" delete writes a hide (instant
2661
+ // preview) plus a cut (this run), and once the cut lands the cut
2662
+ // supersedes the hide — see `pruneHidesInsideCuts`. Pruned HERE, before
2663
+ // `reconcileCaptionEdits` applies the hide layer, so the retired keys
2664
+ // never surface as "the cut removed it" drop lines on this or any later
2665
+ // run. The doc write itself goes through the one sanctioned overrides.json
2666
+ // write further down, gated alongside `cutResult.changed`.
2667
+ const hidePrune = pruneHidesInsideCuts(overrideDoc, cutlist);
2668
+ overrideDoc = hidePrune.doc;
2669
+ const hidesPruned = hidePrune.pruned.length > 0;
2670
+ if (hidesPruned) {
2671
+ console.log(
2672
+ `▸ captions: ${hidePrune.pruned.length} hidden-word override(s) retired — their words are cut`,
2673
+ );
2674
+ }
2675
+
2434
2676
  const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
2435
2677
  for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
2436
2678
 
@@ -3537,7 +3779,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3537
3779
  // recovered from (`legacySplitId`). Repairing the captions would destroy the
3538
3780
  // evidence for the split. `writeOverrideDoc` carries the full argument for
3539
3781
  // why a caption-only write has nothing worth backing up.
3540
- if (cutResult.changed || captionKeysReanchored) {
3782
+ // `hidesPruned` joins the gate for the same reason `captionKeysReanchored`
3783
+ // did: a hide retired by its own cut (`pruneHidesInsideCuts`) changes the
3784
+ // doc without changing the cut entries, and skipping the write would
3785
+ // re-report "the cut removed it" on every later run. It does NOT spend the
3786
+ // `.bak` — retiring a redundant key is not the cut re-anchoring the backup
3787
+ // exists to survive.
3788
+ if (cutResult.changed || captionKeysReanchored || hidesPruned) {
3541
3789
  await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
3542
3790
  console.log(overridesWriteLine(cutResult.changed));
3543
3791
  }
@@ -3664,6 +3912,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3664
3912
  }
3665
3913
  }
3666
3914
 
3915
+ // --concurrency, else config renderConcurrency, else cpus-2 with a floor of
3916
+ // 2 (resolveRenderConcurrency has the precedence and the why: leave cores
3917
+ // for the ffmpeg decode workers every tab waits on). Resolved BEFORE the log
3918
+ // line below so the count can go INTO it — the 2026-08-19 whole-browser OOM
3919
+ // took a machine spec and arithmetic to diagnose, because no line of the
3920
+ // render's own output ever said how many tabs it opened.
3921
+ const renderConcurrency = resolveRenderConcurrency(
3922
+ opts.concurrency,
3923
+ cfg.renderConcurrency,
3924
+ cpus().length,
3925
+ );
3926
+ if (renderConcurrency.warning) console.log(renderConcurrency.warning);
3667
3927
  let renderHud: RenderTimelineHUD | null = null;
3668
3928
  if (interactive) {
3669
3929
  renderHud = new RenderTimelineHUD({
@@ -3673,33 +3933,109 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3673
3933
  aspect: landscape ? "16:9" : "9:16",
3674
3934
  }).start();
3675
3935
  } else {
3676
- console.log("▸ rendering…");
3936
+ console.log(`▸ rendering… (${renderConcurrency.concurrency} parallel tabs)`);
3677
3937
  }
3678
3938
  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`);
3939
+ // Ctrl-C must actually stop the render (2026-08-19 field report). Remotion
3940
+ // owns a browser and ffmpeg children that outlive a bare process death, so
3941
+ // stopping means handing renderMedia a cancelSignal and firing it — node's
3942
+ // default SIGINT handling would leave those orphaned.
3943
+ //
3944
+ // Registered around the RENDER PHASE ONLY, and removed in the finally: a
3945
+ // handler that outlived this phase would swallow Ctrl-C during the LLM and
3946
+ // whisper phases, which exit promptly today and must keep doing so.
3947
+ //
3948
+ // SIGTERM is wired for the same reason as SIGINT, plus one of its own: the
3949
+ // editor's /api/render/cancel (edit.ts) kills this process as a child, and
3950
+ // before this handler that kill left the browser behind. That path is
3951
+ // otherwise untouched — it already reports its own cancel. It also inherits
3952
+ // the dead-window fix below: the editor's Cancel button now kills the child
3953
+ // DURING bundling too, where the SIGTERM used to be swallowed and
3954
+ // /api/render kept answering 409 until the bundle finished on its own.
3955
+ const renderCancel = makeCancelSignal();
3956
+ // An array, not a `let`: the handler assigns from inside a closure, and TS's
3957
+ // flow analysis would still read a `let` as null in the catch below (it
3958
+ // narrowed the branch to `never`). First signal wins — hammering Ctrl-C must
3959
+ // not rewrite the verdict while the teardown is already running.
3960
+ const cancellations: RenderCancellation[] = [];
3961
+ // Where renderProduction is, so the handler knows whether the cancel signal
3962
+ // has anyone listening (renderSignalAction has the whole reasoning).
3963
+ // "pre-render" from the start: the handlers go on before the call.
3964
+ let signalPhase: RenderSignalPhase = "pre-render";
3965
+ let signalCount = 0;
3966
+ // Shared by all three places a cancel is finalised — the handler, the
3967
+ // rejected-render catch, and the post-render tail check.
3968
+ const finishCancel = (c: RenderCancellation, note?: string): never => {
3969
+ if (renderHud) renderHud.stop();
3970
+ if (note) console.log(note);
3971
+ // rmSync, not fs/promises rm: the handler path calls this and exits on the
3972
+ // next statement, and an awaited unlink would never get its turn.
3973
+ for (const path of c.removePaths) rmSync(path, { force: true });
3974
+ console.log(c.message);
3975
+ // Exits here rather than throwing: program.ts's catch would record this as
3976
+ // produce_failed and print "✗ <message>", dressing a deliberate stop as a
3977
+ // bug (R16 §60's distinction).
3978
+ process.exit(c.exitCode);
3979
+ };
3980
+ const onCancelSignal = (signal: "SIGINT" | "SIGTERM") => {
3981
+ signalCount += 1;
3982
+ if (cancellations.length === 0) cancellations.push(renderCancellation(signal, rawPath));
3983
+ const action = renderSignalAction(signalPhase, signalCount);
3984
+ if (action.cancel) renderCancel.cancel();
3985
+ if (action.exitNow) finishCancel(cancellations[0]!, action.note);
3986
+ };
3987
+ const onSigint = () => onCancelSignal("SIGINT");
3988
+ const onSigterm = () => onCancelSignal("SIGTERM");
3989
+ process.on("SIGINT", onSigint);
3990
+ process.on("SIGTERM", onSigterm);
3991
+ try {
3992
+ await phases.time("render", () =>
3993
+ renderProduction(props, {
3994
+ publicDir: dirname(renderVideo),
3995
+ outPath: rawPath,
3996
+ browserExecutable: cfg.browserExecutable,
3997
+ concurrency: renderConcurrency.concurrency,
3998
+ cancelSignal: renderCancel.cancelSignal,
3999
+ onPhase: (phase: RenderPhase) => {
4000
+ signalPhase = renderSignalPhaseOf(phase);
4001
+ },
4002
+ onProgress: (p) => {
4003
+ if (renderHud) {
4004
+ renderHud.setProgress(p);
4005
+ } else {
4006
+ const pct = Math.floor(p * 100);
4007
+ if (pct >= lastPct + 10) {
4008
+ lastPct = pct;
4009
+ process.stdout.write(` ${pct}%\n`);
4010
+ }
3698
4011
  }
3699
- }
3700
- },
3701
- }),
3702
- );
4012
+ },
4013
+ }),
4014
+ );
4015
+ // renderMedia resolved, so nothing is left to cancel cooperatively. Not a
4016
+ // phase the handler can exit from either — see the tail check below.
4017
+ signalPhase = "post-render";
4018
+ } catch (err) {
4019
+ // A cancelled renderMedia rejects like any other failure; only the
4020
+ // handler above can tell the two apart.
4021
+ const cancellation = cancellations[0];
4022
+ if (!cancellation) throw err;
4023
+ finishCancel(cancellation);
4024
+ } finally {
4025
+ process.off("SIGINT", onSigint);
4026
+ process.off("SIGTERM", onSigterm);
4027
+ }
4028
+ // The TAIL CASE (2026-08-19 review): a signal landing after renderMedia
4029
+ // resolved but before the `finally` above took the handlers off used to be
4030
+ // swallowed outright — nothing threw, the run went on to loudnorm and
4031
+ // mastering, and the user got a complete video having pressed Ctrl-C. We
4032
+ // HONOR it: the user asked to stop, and stopping here costs only the
4033
+ // mastering pass, whereas ignoring it hands them the file they just said
4034
+ // they did not want. Nothing has been written to --out yet (moveFile is
4035
+ // below), so honoring here is still "no output", the same promise every
4036
+ // other cancel makes.
4037
+ const tailCancellation = cancellations[0];
4038
+ if (tailCancellation) finishCancel(tailCancellation);
3703
4039
  if (renderHud) renderHud.stop();
3704
4040
 
3705
4041
  const masterAnim = interactive
package/src/program.ts CHANGED
@@ -39,6 +39,27 @@ import {
39
39
  // order in `defaultProviderName`, which decides which model runs.
40
40
  const envFiles = loadEnvFiles();
41
41
 
42
+ /**
43
+ * `--concurrency <n>` → a positive whole number of browser tabs (§93a: reject
44
+ * rather than coerce, the `--clip` idiom). A typo'd `--concurrency 4x` must
45
+ * not become NaN and reach Remotion as "however many you like" — the flag
46
+ * exists precisely because the automatic count killed a browser (2026-08-19
47
+ * field case; `resolveRenderConcurrency` has it). `Number`, not `parseInt`,
48
+ * so "4.5" and "" are errors rather than a silent 4 and a silent 0.
49
+ *
50
+ * Exported so the rejection matrix is testable without commander's exit
51
+ * behaviour in the way.
52
+ */
53
+ export function concurrencyFlag(v: string): number {
54
+ const n = Number(v);
55
+ if (!Number.isInteger(n) || n <= 0) {
56
+ throw new InvalidArgumentError(
57
+ `--concurrency wants a positive whole number of browser tabs, got "${v}"`,
58
+ );
59
+ }
60
+ return n;
61
+ }
62
+
42
63
  /**
43
64
  * Every command this CLI has, built onto a fresh instance.
44
65
  *
@@ -418,6 +439,20 @@ export function buildProgram(): Command {
418
439
  )
419
440
  .option("--editor-port <n>", "port for the editor started by --open-editor",
420
441
  (v) => Number.parseInt(v, 10), 5174)
442
+ // No default: undefined = "not typed" is what lets the config's
443
+ // renderConcurrency (and then the cpus-2 guess) supply the value —
444
+ // resolveRenderConcurrency owns the precedence. Recorded runs need nothing
445
+ // special to replay it: command.json stores the argv verbatim
446
+ // (recordedProduceArgs), so a typed --concurrency is already in there, and
447
+ // the editor's Render replays it through THIS parse.
448
+ .option(
449
+ "--concurrency <n>",
450
+ "how many browser tabs render frames in parallel (default: CPU cores - 2, " +
451
+ "floor 2). Turn it DOWN if the render logs 'The browser crashed while " +
452
+ "rendering frame N' — that is the whole browser running out of memory, " +
453
+ "not one frame failing",
454
+ concurrencyFlag,
455
+ )
421
456
  .action(async (input: string | undefined, opts, command: Command) => {
422
457
  if (input === undefined) {
423
458
  // commander 12's parseAsync does not reset option state between calls,
@@ -553,6 +588,9 @@ export function buildProgram(): Command {
553
588
  coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
554
589
  clip: opts.clip,
555
590
  clipWindow: opts.clipWindow,
591
+ // Validated by concurrencyFlag at parse time; undefined = "not
592
+ // typed", which is what lets the config supply it.
593
+ concurrency: opts.concurrency,
556
594
  });
557
595
  // Counts, buckets and names only — the duration crosses the wire as a
558
596
  // bucket, and nothing here can carry a path (assertSafeProps enforces