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/editor-dist/assets/index-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-DVI51_2u.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/caption-report.ts +141 -3
- package/src/edit.ts +84 -8
- package/src/open.ts +38 -0
- package/src/produce.ts +382 -46
- package/src/program.ts +38 -0
- 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,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).
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1897
|
-
|
|
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
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
2113
|
+
cached.map(({ startWord, endWord, heard, correction }) => ({
|
|
2114
|
+
startWord,
|
|
2115
|
+
endWord,
|
|
2116
|
+
heard,
|
|
2117
|
+
correction,
|
|
2118
|
+
})),
|
|
1904
2119
|
{ dictionary },
|
|
1905
|
-
)
|
|
1906
|
-
|
|
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
|
-
|
|
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(
|
|
3936
|
+
console.log(`▸ rendering… (${renderConcurrency.concurrency} parallel tabs)`);
|
|
3677
3937
|
}
|
|
3678
3938
|
let lastPct = -10;
|
|
3679
|
-
//
|
|
3680
|
-
//
|
|
3681
|
-
//
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
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
|