ossclip 0.1.24 → 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,16 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
4
- import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { copyFile, mkdir, readFile, writeFile, rm } from "node:fs/promises";
4
+ import {
5
+ copyFileSync,
6
+ existsSync,
7
+ mkdirSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ statSync,
12
+ } from "node:fs";
13
+ import { cpus } from "node:os";
5
14
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
15
  import { z } from "zod/v4";
7
16
  import {
@@ -17,12 +26,19 @@ import {
17
26
  assembleScenes,
18
27
  buildCaptionLines,
19
28
  buildCutlist,
29
+ canonicalizeDictionaryCasing,
30
+ captionsNeedNastaliq,
31
+ NASTALIQ_FONT_NAME,
32
+ NASTALIQ_FONT_REL,
33
+ nastaliqFontFile,
20
34
  buildZoomPlan,
21
35
  checkGrounding,
22
36
  rejectCtaKeyword,
23
37
  concatFolder,
24
38
  folderManifestKey,
25
39
  listFolderVideos,
40
+ outInsideInputFolderMessage,
41
+ outPathInsideInput,
26
42
  coverDecision,
27
43
  coverHeadline,
28
44
  cropFilter,
@@ -48,18 +64,51 @@ import {
48
64
  formatBloopSpan,
49
65
  findRetakeGroups,
50
66
  formatRetakeGroup,
67
+ RESTART_PREFIX_CONFIDENCE,
68
+ type RetakeGroup,
51
69
  formatUsageLine,
52
70
  formatUsageReport,
71
+ formatYoutubeMarkdown,
72
+ generateYoutubePack,
73
+ stampedTranscript,
74
+ YOUTUBE_APPROVED_BASENAME,
75
+ YOUTUBE_PROMPT_VERSION,
76
+ YoutubePackSchema,
77
+ type YoutubePack,
78
+ THUMBNAIL_APPROVED_BASENAME,
79
+ THUMBNAIL_MODEL_DEFAULT,
80
+ ThumbnailConceptApprovedSchema,
81
+ ThumbnailConceptSchema,
82
+ type ThumbnailConcept,
83
+ type GenerateThumbnailImageOptions,
84
+ approvedOverlayText,
85
+ buildThumbnailPrompt,
86
+ generateThumbnailConcept,
87
+ generateThumbnailImage,
88
+ portraitMimeType,
89
+ thumbnailDecision,
90
+ thumbnailImageCacheName,
53
91
  applyUserCuts,
92
+ pruneHidesInsideCuts,
54
93
  loadConfig,
55
94
  loudnorm,
56
95
  MAX_NORMALIZE_UPSCALE,
96
+ MAX_MEAN_AREA_DISCARD,
97
+ FACE_ONLY_MIN_FRAC,
98
+ FACE_MIN_DETECTION_RATIO,
57
99
  ZOOM_MAX_SCALE,
58
100
  assessCueFraming,
59
- bakeNormalizedSource,
60
101
  planNormalization,
102
+ segmentIsFaceOnly,
103
+ type WindowFace,
104
+ type FaceBox,
105
+ type FramingSegment,
61
106
  type NormalizePlan,
62
107
  makeMezzanine,
108
+ mezzanineFileName,
109
+ mezzanineScale,
110
+ scaleContentTimeline,
111
+ scaleFramingWindows,
63
112
  measureFace,
64
113
  measureFaceInWindows,
65
114
  pickCoverFrame,
@@ -72,7 +121,10 @@ import {
72
121
  resolveTheme,
73
122
  run,
74
123
  runWhisper,
124
+ whisperPromptFor,
75
125
  scanSourceText,
126
+ ThemeSchema,
127
+ type Theme,
76
128
  appendUsageRun,
77
129
  OverrideDocSchema,
78
130
  CLIP_SNAP_TOLERANCE,
@@ -88,8 +140,10 @@ import {
88
140
  type BeatsValidationIssue,
89
141
  type CleanupLevel,
90
142
  type ClipWindow,
143
+ type Layout,
91
144
  type LlmProvider,
92
145
  type Production,
146
+ ossclipOutputPathFor,
93
147
  type ProviderName,
94
148
  type Scene,
95
149
  type SceneComponentId,
@@ -98,6 +152,12 @@ import {
98
152
  } from "@ossclip/core";
99
153
  import { recordRecentProject } from "./edit";
100
154
  import { binOnPath, detectionLine } from "./llm-detect";
155
+ import {
156
+ modelImpliedLanguage,
157
+ modelUrl,
158
+ validModelSources,
159
+ whisperModelPath,
160
+ } from "./setup/manifest";
101
161
  import { PhaseTimer, formatPhaseLine, type PhaseTimings } from "./phase-timing";
102
162
  import {
103
163
  strandedOverrideSiblings,
@@ -105,13 +165,18 @@ import {
105
165
  workdirBaseName,
106
166
  } from "./stranded-overrides";
107
167
  import { editHint } from "./interactive/edit-hint";
168
+ import { artifactPath, ensureParentDir, expandHome, moveFile } from "./paths";
169
+ import { portraitOverridePath, resolvePortrait } from "./portrait-override";
170
+ import { approveThumbnailConcept, thumbnailRetryLoop } from "./interactive/thumbnail-approve";
108
171
  import { isInteractive } from "./interactive/tty";
109
172
  import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from "./ui/animation";
110
173
  import { reconcileCaptionEdits } from "./caption-report";
111
174
  import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
112
175
  import { recordedProduceArgs } from "./replay-argv";
113
- import { renderCover, renderProduction } from "@ossclip/renderer";
176
+ import { makeCancelSignal, renderCover, renderProduction } from "@ossclip/renderer";
177
+ import type { RenderPhase } from "@ossclip/renderer";
114
178
  import {
179
+ DEFAULT_FACE,
115
180
  coverTextRect,
116
181
  layoutSlots,
117
182
  regionsDuring,
@@ -155,6 +220,13 @@ export interface ProduceResult {
155
220
  export const TranscriptKeySchema = z.object({
156
221
  model: z.string(),
157
222
  language: z.string().optional(),
223
+ /**
224
+ * The dictionary the whisper `--prompt` was biased with (F4, 2026-08-16) —
225
+ * a changed vocabulary changes what whisper decodes, so it re-keys the
226
+ * cache exactly like the model does. Absent (old key files, no-dictionary
227
+ * runs) means "no biasing".
228
+ */
229
+ dictionary: z.array(z.string()).optional(),
158
230
  });
159
231
  export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
160
232
 
@@ -178,7 +250,14 @@ export function transcriptCacheReusable(
178
250
  effective.model === requested.model &&
179
251
  // "" and absent both mean whisper's en default — program.ts rejects an
180
252
  // empty code, but a key file predating that guard must not wedge.
181
- (effective.language ?? "") === (requested.language ?? ""),
253
+ (effective.language ?? "") === (requested.language ?? "") &&
254
+ // ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
255
+ // text verbatim, so a reordered list genuinely is a different decoder
256
+ // input — treating it as equal would serve a transcript biased by a
257
+ // prompt this run never sent. Absent and [] compare equal (both mean
258
+ // "no biasing"), so pre-dictionary key files reuse under a
259
+ // no-dictionary request.
260
+ JSON.stringify(effective.dictionary ?? []) === JSON.stringify(requested.dictionary ?? []),
182
261
  recorded: effective,
183
262
  };
184
263
  }
@@ -214,6 +293,13 @@ export interface ProduceOptions {
214
293
  * decodes garbage (Urdu field test 2026-08-05).
215
294
  */
216
295
  whisperLanguage?: string;
296
+ /**
297
+ * Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
298
+ * split/trimmed by the action. Wholesale beats the config's `dictionary`
299
+ * — typed-beats-config like the watermark, and never merged: a per-run
300
+ * list is a deliberate substitution, not an addition.
301
+ */
302
+ dictionary?: string[];
217
303
  /** Debug: force every graphic moment to this component. */
218
304
  forceComponent?: SceneComponentId;
219
305
  /** Write a cover image beside the video (default on). */
@@ -228,11 +314,11 @@ export interface ProduceOptions {
228
314
  */
229
315
  blooperMarker?: string;
230
316
  /**
231
- * `--collapse-retakes` (R27 §128): deterministically collapse consecutive
232
- * near-identical sentences — the flub the speaker did NOT mark out loud.
233
- * Opt-in, default off for v1: the promotion criterion is clean field runs
234
- * recorded in this same report appendix, the mechanism this whole findings
235
- * doc uses to decide when an opt-in flag has earned default-on.
317
+ * `--collapse-retakes` — legacy no-op (2026-08-16). Retake collapse (R27
318
+ * §128) now runs automatically whenever `--blooper-marker` is given and
319
+ * never otherwise (`inferredRetakesEnabled` quotes the user's rule). The
320
+ * flag stays parseable so old command.json replays don't error; typing it
321
+ * without a marker earns a notice instead of a silent ignore.
236
322
  */
237
323
  collapseRetakes?: boolean;
238
324
  /**
@@ -248,6 +334,13 @@ export interface ProduceOptions {
248
334
  * platform chrome to dodge and a landscape source needs no cropping at all.
249
335
  */
250
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;
251
344
  /**
252
345
  * `--clip <seconds>` (R19 §93): produce only the strongest ~N-second window
253
346
  * of a long take, chosen by the producer in the same editorial call as the
@@ -278,6 +371,33 @@ export interface ProduceOptions {
278
371
  * a free-tier limitation; this is voluntary attribution.
279
372
  */
280
373
  watermark?: boolean;
374
+ /**
375
+ * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
376
+ * true/false when TYPED, undefined when not — undefined lets the config's
377
+ * `youtube` key supply the default (`resolveYoutube`). One flag covers the
378
+ * whole pack (SEO metadata + AI thumbnail) by user decision 2026-08-16.
379
+ */
380
+ youtube?: boolean;
381
+ /**
382
+ * `--portrait <path>`: the creator's portrait photo, the likeness
383
+ * reference for the `--youtube` AI thumbnail. Typed-beats-config like the
384
+ * dictionary; validated at USE (the thumbnail step), where an absent file
385
+ * is a loud skip and the frame-grab cover stands.
386
+ */
387
+ portrait?: string;
388
+ /**
389
+ * `--audience <text>`: who watches the channel, steering BOTH the youtube
390
+ * pack's titles/tags and the thumbnail concept. Typed-beats-config like
391
+ * `--portrait`; the config's `audience` supplies the default, validated
392
+ * with `typeof === "string"` at use.
393
+ */
394
+ audience?: string;
395
+ /**
396
+ * `--thumbnail-brief <text>`: the durable thumbnail steer, fed to the
397
+ * concept call as a must-honor creator brief. Same typed-beats-config
398
+ * contract as `audience` (config key `thumbnailBrief`).
399
+ */
400
+ thumbnailBrief?: string;
281
401
  /**
282
402
  * `--captions` / `--no-captions` tri-state: true/false when TYPED,
283
403
  * undefined when not. Unlike `watermark` above there is no config key —
@@ -287,6 +407,15 @@ export interface ProduceOptions {
287
407
  * record differently.
288
408
  */
289
409
  captions?: boolean;
410
+ /**
411
+ * `--add-jump-cuts` / `--no-jump-cuts` tri-state: true/false when TYPED,
412
+ * undefined when not ("auto", the default — punch, face-only). Resolved by
413
+ * `resolveJumpCuts`; scope is the cut punch-in ONLY, narrower than `zoom`,
414
+ * which kills every motion driver at once. Note `true` does NOT override
415
+ * the face-only guard (`punchPlanFor` has the why) — it exists to beat a
416
+ * future config-off, nothing else.
417
+ */
418
+ jumpCuts?: boolean;
290
419
  /**
291
420
  * `<input>` a DIRECTORY: order its clips before concatenating them into the
292
421
  * source produce runs on (folder-input-brief.md). `name` (default) is a
@@ -321,6 +450,817 @@ export function resolveWatermark(
321
450
  return flag ?? configValue === true;
322
451
  }
323
452
 
453
+ /**
454
+ * The effective `--youtube` switch — resolveWatermark's semantics verbatim:
455
+ * a TYPED flag always wins (so `--no-youtube` beats a config-on), and only
456
+ * then does the config supply the default. The config side is `=== true`,
457
+ * never truthiness, for the same parse-don't-coerce reason: the value comes
458
+ * from a hand-editable JSON file loadConfig doesn't zod-parse, and a typo'd
459
+ * `"youtube": "no"` must not switch a metadata+thumbnail pipeline ON. Off is
460
+ * the only safe reading of anything malformed for an opt-in extra. Pure so
461
+ * the flag × config matrix is testable without a config file on disk.
462
+ */
463
+ export function resolveYoutube(
464
+ flag: boolean | undefined,
465
+ configValue: boolean | undefined,
466
+ ): boolean {
467
+ return flag ?? configValue === true;
468
+ }
469
+
470
+ /**
471
+ * How many browser tabs the render runs in parallel (2026-08-17 render-speed
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
491
+ * cpus().
492
+ */
493
+ export function resolveRenderConcurrency(
494
+ flagValue: number | undefined,
495
+ configValue: unknown,
496
+ cpuCount: number,
497
+ ): { concurrency: number; warning?: string } {
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 };
503
+ if (configValue === undefined) return { concurrency: fallback };
504
+ if (typeof configValue === "number" && Number.isInteger(configValue) && configValue > 0) {
505
+ return { concurrency: configValue };
506
+ }
507
+ return {
508
+ concurrency: fallback,
509
+ warning: "⚠ config renderConcurrency ignored — expected a positive integer",
510
+ };
511
+ }
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
+
626
+ // Moved to paths.ts (2026-08-17, editor thumbnail panel): the edit server
627
+ // derives `<out>.thumbnail.png` from command.json's recorded out and must not
628
+ // import this module — produce.ts imports edit.ts (recordRecentProject), so
629
+ // the reverse edge would be a cycle, and this module's import graph drags the
630
+ // whole renderer into a server that is deliberately dependency-free.
631
+ // Re-exported so existing importers (tests) keep their path.
632
+ export { artifactPath } from "./paths";
633
+
634
+ /**
635
+ * `--dictionary "JSON, ossclip"` → `["JSON", "ossclip"]`. Comma-separated in
636
+ * ONE value because a variadic option fights the optional positional
637
+ * `[input]` (see program.ts); split/trim/drop-empties here so a trailing
638
+ * comma or doubled space never becomes an empty term in the whisper prompt.
639
+ * `undefined` in, `undefined` out — "not typed" must survive to let the
640
+ * config supply the dictionary. Pure so the split matrix is testable without
641
+ * commander.
642
+ */
643
+ export function dictionaryFlag(value: string | undefined): string[] | undefined {
644
+ if (value === undefined) return undefined;
645
+ return value
646
+ .split(",")
647
+ .map((t) => t.trim())
648
+ .filter(Boolean);
649
+ }
650
+
651
+ /**
652
+ * Consumer-side validation for the config's `dictionary` key — the
653
+ * `watermark` posture applied to an array: the value comes from a
654
+ * hand-editable JSON file loadConfig doesn't zod-parse, so a non-array, a
655
+ * number in the list, or a term that trims to nothing means the whole key is
656
+ * ignored (`undefined`) and the call site prints one warning naming the
657
+ * problem. All-or-nothing on purpose: silently keeping the salvageable half
658
+ * of a typo'd list would bias whisper with a vocabulary the user never
659
+ * reviewed. Pure so the matrix is testable without a config file on disk.
660
+ */
661
+ export function validDictionary(value: unknown): string[] | undefined {
662
+ if (!Array.isArray(value) || value.length === 0) return undefined;
663
+ if (!value.every((t) => typeof t === "string" && t.trim().length > 0)) return undefined;
664
+ return value.map((t: string) => t.trim());
665
+ }
666
+
667
+ /**
668
+ * The effective whisper `-l` for a run — typed-beats-config precedence like
669
+ * `--dictionary`, with a third rung under both: the curated model table's
670
+ * implied language (`modelImpliedLanguage`), so `--whisper-model medium-urdu`
671
+ * alone decodes Urdu instead of silently decoding English garbage (the Urdu
672
+ * field test's exact first-run failure, 2026-08-05). The config side is
673
+ * typeof+trim, never truthiness — `language` comes from a hand-editable JSON
674
+ * file loadConfig doesn't zod-parse, and a malformed value earns one warning
675
+ * and falls through, never a coerced `-l`. `source` rides along so the call
676
+ * site can say where a non-flag language came from. Pure so the whole
677
+ * flag × config × model matrix is testable without a config file on disk.
678
+ */
679
+ export function resolveWhisperLanguage(
680
+ flag: string | undefined,
681
+ configValue: unknown,
682
+ modelImplied: string | undefined,
683
+ ): { language: string | undefined; source: "flag" | "config" | "model" | null; warning?: string } {
684
+ if (flag !== undefined) return { language: flag, source: "flag" };
685
+ const configOk = typeof configValue === "string" && configValue.trim().length > 0;
686
+ const warning =
687
+ configValue !== undefined && !configOk
688
+ ? "⚠ config language ignored — expected a non-empty language code string"
689
+ : undefined;
690
+ if (configOk) return { language: (configValue as string).trim(), source: "config" };
691
+ if (modelImplied !== undefined) return { language: modelImplied, source: "model", ...(warning ? { warning } : {}) };
692
+ return { language: undefined, source: null, ...(warning ? { warning } : {}) };
693
+ }
694
+
695
+ /**
696
+ * The BASE theme a run starts from: the config's `theme` merged over
697
+ * `defaultTheme` (F6, 2026-08-16). Precedence overall is overrides.json >
698
+ * config theme > defaultTheme — this helper builds the bottom two layers,
699
+ * and it must feed BOTH `resolveTheme`'s base and props.baseTheme: the
700
+ * editor re-applies overrides onto `baseTheme`, so a reset there must fall
701
+ * back to the user's global colors, not to factory defaults.
702
+ *
703
+ * All-or-nothing: `ThemeSchema.partial().safeParse` — one malformed key (a
704
+ * numeric `accent`, an unknown-shaped value) voids the WHOLE config theme
705
+ * with a warning naming the issue, because half-applying a palette the
706
+ * schema rejected would render colors the user never chose. The warning is
707
+ * RETURNED, not printed — pure, so the precedence matrix is testable without
708
+ * a config file or a captured console.
709
+ */
710
+ export function configuredBaseTheme(cfgTheme: unknown): { theme: Theme; warning?: string } {
711
+ if (cfgTheme === undefined) return { theme: defaultTheme };
712
+ const parsed = ThemeSchema.partial().strict().safeParse(cfgTheme);
713
+ if (!parsed.success) {
714
+ return {
715
+ theme: defaultTheme,
716
+ warning:
717
+ `⚠ config theme ignored — ${parsed.error.issues
718
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
719
+ .join("; ")}`,
720
+ };
721
+ }
722
+ // Re-parse the merge so zod's defaults fill anything the partial left out —
723
+ // the same construction defaultTheme itself uses.
724
+ return { theme: ThemeSchema.parse({ ...defaultTheme, ...parsed.data }) };
725
+ }
726
+
727
+ /**
728
+ * The concept cache's filename: keyed on who is asked, with what steer,
729
+ * about which words (the Y2 pack-key shape) — audience/brief/titleAngle are
730
+ * steer, so a changed one regenerates. ONE function shared by thumbnailStep
731
+ * and the pre-render approval step, so the approval's cache seed can never
732
+ * drift from the file the step would write. Pure so the key's inputs are
733
+ * pinned by a test.
734
+ */
735
+ export function thumbnailConceptCacheName(parts: {
736
+ providerName: string;
737
+ llmModel?: string;
738
+ intent?: string;
739
+ hook?: string;
740
+ audience?: string;
741
+ brief?: string;
742
+ titleAngle?: string;
743
+ transcriptWords: readonly string[];
744
+ }): string {
745
+ const key = createHash("sha1")
746
+ .update(
747
+ JSON.stringify([
748
+ parts.providerName,
749
+ parts.llmModel ?? "",
750
+ parts.intent ?? "",
751
+ parts.hook ?? "",
752
+ parts.audience ?? "",
753
+ parts.brief ?? "",
754
+ parts.titleAngle ?? "",
755
+ parts.transcriptWords,
756
+ ]),
757
+ )
758
+ .digest("hex")
759
+ .slice(0, 8);
760
+ return `thumbnail-concept-${key}.json`;
761
+ }
762
+
763
+ /**
764
+ * The workdir's approved YouTube pack, or undefined. The Y2 block checks
765
+ * this FIRST (editor SEO panel, 2026-08-17 — thumbnailStep's approval-file
766
+ * contract applied to the pack): once the editor persisted an edited pack,
767
+ * a cache lookup or a fresh LLM call would silently discard the user's
768
+ * words. Exported so the honor/leniency matrix is testable with a temp dir.
769
+ *
770
+ * Read-side leniency, unlike thumbnailStep's hard `.parse`: a corrupt
771
+ * decision file here warns and falls through to the generate path — the pack
772
+ * is a sidecar on a render that must not die over it (§112), and the next
773
+ * editor save atomically replaces the file anyway.
774
+ */
775
+ export async function readApprovedYoutubePack(
776
+ work: string,
777
+ log: (line: string) => void = console.log,
778
+ ): Promise<YoutubePack | undefined> {
779
+ const path = join(work, YOUTUBE_APPROVED_BASENAME);
780
+ if (!existsSync(path)) return undefined;
781
+ try {
782
+ const parsed = YoutubePackSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
783
+ if (parsed.success) return parsed.data;
784
+ log(` ⚠ ${YOUTUBE_APPROVED_BASENAME} is not a valid pack — regenerating instead`);
785
+ } catch {
786
+ log(` ⚠ ${YOUTUBE_APPROVED_BASENAME} is not valid JSON — regenerating instead`);
787
+ }
788
+ return undefined;
789
+ }
790
+
791
+ /** Everything the AI thumbnail step (Y3) needs, gathered for testability. */
792
+ export interface ThumbnailStepArgs {
793
+ /** The resolved `--youtube` switch — off means the whole step is silent. */
794
+ youtube: boolean;
795
+ /** Resolved `--portrait` / config path; existence is checked HERE. */
796
+ portraitPath: string | undefined;
797
+ /** GEMINI_API_KEY — env-only, never config (env.ts secrets rule). */
798
+ apiKey: string | undefined;
799
+ /** The image model slug (config `thumbnailModel` or the default). */
800
+ model: string;
801
+ work: string;
802
+ outPath: string;
803
+ /** The run's text provider — the concept call rides it, tier editorial. */
804
+ provider: LlmProvider | undefined;
805
+ providerName: string;
806
+ llmModel: string | undefined;
807
+ intent: string | undefined;
808
+ hook: string | undefined;
809
+ /** Resolved `--audience` / config — who the channel is for. */
810
+ audience?: string;
811
+ /** Resolved `--thumbnail-brief` / config — the durable must-honor steer. */
812
+ brief?: string;
813
+ /**
814
+ * The youtube pack's first title, when the pack generated before this step
815
+ * — the thumbnail must tell the same story as the title it ships under.
816
+ */
817
+ titleAngle?: string;
818
+ transcriptWords: readonly string[];
819
+ /**
820
+ * The image-generation seam, pickCoverFrame's `detectFace` shape: tests
821
+ * inject a stub here and therefore never import @google/genai.
822
+ */
823
+ generate?: (opts: GenerateThumbnailImageOptions) => Promise<Uint8Array>;
824
+ /** Phase-timing wrapper for the concept LLM call; identity by default. */
825
+ time?: <T>(fn: () => Promise<T>) => Promise<T>;
826
+ log?: (line: string) => void;
827
+ }
828
+
829
+ /**
830
+ * What a successful thumbnail step hands back — more than the path, because
831
+ * the post-generation retry loop ("regenerate with a note", 2026-08-16
832
+ * thumbnail UX) reuses the exact concept, cache file and portrait bytes this
833
+ * step generated with. Re-deriving any of them in the loop would let the two
834
+ * drift (a different cache key regenerating a file the loop then never
835
+ * overwrites).
836
+ */
837
+ export interface ThumbnailStepResult {
838
+ /** The written `<out>.thumbnail.png`. */
839
+ path: string;
840
+ /** The concept the image was prompted with — unchanged across retries. */
841
+ concept: ThumbnailConcept;
842
+ /** The workdir image cache the retry loop overwrites in place. */
843
+ imageCachePath: string;
844
+ /** The portrait as the inlineData shape the generate seam takes. */
845
+ portrait: { data: string; mimeType: string };
846
+ }
847
+
848
+ /**
849
+ * The `--youtube` AI thumbnail orchestration (Y3, 2026-08-16): decide,
850
+ * concept, image, copy beside the output. Extracted from `produce()` so the
851
+ * cache/degrade matrix is testable with an injected `generate` and a temp
852
+ * dir — no SDK, no network.
853
+ *
854
+ * Additive to the cover pipeline by contract: EVERY exit short of success is
855
+ * one loud line and `undefined`, and the frame-grab cover stands. Returns
856
+ * the written `<out>.thumbnail.png` path (plus the retry loop's inputs) on
857
+ * success.
858
+ */
859
+ export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailStepResult | undefined> {
860
+ const {
861
+ generate = generateThumbnailImage,
862
+ time = <T>(fn: () => Promise<T>) => fn(),
863
+ log = console.log,
864
+ } = args;
865
+ const portraitExists = args.portraitPath ? existsSync(args.portraitPath) : false;
866
+ const decision = thumbnailDecision(
867
+ args.youtube,
868
+ args.portraitPath,
869
+ args.apiKey !== undefined && args.apiKey !== "",
870
+ portraitExists,
871
+ );
872
+ if (decision !== "generate") {
873
+ // youtube-off is the one silent exit: the user never opted in, so there
874
+ // is nothing to explain. Every other skip is a run the user configured
875
+ // for a thumbnail and didn't get one — say why, once.
876
+ if (decision !== "skip-no-youtube") {
877
+ const reason =
878
+ decision === "skip-no-portrait"
879
+ ? "no portrait — set `portrait` in ~/.ossclip/config.json or pass --portrait"
880
+ : decision === "skip-no-key"
881
+ ? "GEMINI_API_KEY not set"
882
+ : `portrait not found: ${args.portraitPath}`;
883
+ log(`▸ thumbnail: skipped (${reason}) — frame-grab cover stands`);
884
+ }
885
+ return undefined;
886
+ }
887
+ // The pre-render approval file, checked FIRST (2026-08-16 thumbnail UX):
888
+ // the user approved — or explicitly skipped — this exact concept before
889
+ // the render, so asking a model again here would discard their edit. The
890
+ // skip variant is a LOUD skip: unlike youtube-off, the user opted in and
891
+ // then declined this one thumbnail, and the line says how to revisit.
892
+ const approvedPath = join(args.work, THUMBNAIL_APPROVED_BASENAME);
893
+ let approved: ThumbnailConcept | undefined;
894
+ if (existsSync(approvedPath)) {
895
+ const parsed = ThumbnailConceptApprovedSchema.parse(
896
+ JSON.parse(await readFile(approvedPath, "utf8")),
897
+ );
898
+ if ("skip" in parsed) {
899
+ log(
900
+ `▸ thumbnail: skipped (declined at concept approval — delete ` +
901
+ `${THUMBNAIL_APPROVED_BASENAME} in the workdir to revisit) — frame-grab cover stands`,
902
+ );
903
+ return undefined;
904
+ }
905
+ approved = parsed;
906
+ }
907
+ const mimeType = portraitMimeType(args.portraitPath!);
908
+ if (!mimeType) {
909
+ log(
910
+ `▸ thumbnail: skipped (unsupported portrait format "${args.portraitPath}" — ` +
911
+ "use png, jpg, jpeg or webp) — frame-grab cover stands",
912
+ );
913
+ return undefined;
914
+ }
915
+ let concept: ThumbnailConcept;
916
+ if (approved) {
917
+ // No concept call, no concept cache — the approved file IS the concept.
918
+ concept = approved;
919
+ log("▸ thumbnail: using the approved concept");
920
+ } else {
921
+ if (!args.provider) {
922
+ // The concept call rides the run's text provider (Y2's exactly); the
923
+ // IMAGE key alone cannot write the concept, so no provider means no
924
+ // thumbnail — loud, because the youtube gate was on.
925
+ log("▸ thumbnail: skipped (no LLM provider for the concept) — frame-grab cover stands");
926
+ return undefined;
927
+ }
928
+
929
+ // Concept cache (thumbnailConceptCacheName has the key's rationale).
930
+ // Failures are never cached (§106).
931
+ const conceptCache = join(args.work, thumbnailConceptCacheName(args));
932
+ if (existsSync(conceptCache)) {
933
+ concept = ThumbnailConceptSchema.parse(JSON.parse(await readFile(conceptCache, "utf8")));
934
+ log("▸ thumbnail: concept cached");
935
+ } else {
936
+ try {
937
+ const fresh = await time(() =>
938
+ generateThumbnailConcept(args.provider!, {
939
+ hook: args.hook,
940
+ intent: args.intent,
941
+ audience: args.audience,
942
+ brief: args.brief,
943
+ titleAngle: args.titleAngle,
944
+ transcriptText: args.transcriptWords.join(" "),
945
+ }),
946
+ );
947
+ // The schema caps CHARACTERS; approvedOverlayText caps WORDS (§35 —
948
+ // overlay text at thumbnail size has a cover banner's 4-9 word
949
+ // ceiling). Capped BEFORE caching so the cache and the image key hold
950
+ // what is used.
951
+ concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
952
+ await writeFile(conceptCache, JSON.stringify(concept, null, 2));
953
+ } catch (err) {
954
+ log(
955
+ `▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
956
+ "— frame-grab cover stands",
957
+ );
958
+ return undefined;
959
+ }
960
+ }
961
+ }
962
+
963
+ // Image cache — thumbnailImageCacheName has the key's rationale, and it is
964
+ // shared with the editor's regenerate endpoint so the two callers can never
965
+ // cache past each other.
966
+ const portraitBytes = await readFile(args.portraitPath!);
967
+ const imageCache = join(
968
+ args.work,
969
+ thumbnailImageCacheName(
970
+ args.model,
971
+ concept,
972
+ createHash("sha1").update(portraitBytes).digest("hex"),
973
+ ),
974
+ );
975
+ if (existsSync(imageCache)) {
976
+ log("▸ thumbnail: image cached");
977
+ } else {
978
+ try {
979
+ const bytes = await generate({
980
+ apiKey: args.apiKey!,
981
+ model: args.model,
982
+ prompt: buildThumbnailPrompt(concept, true),
983
+ portrait: { data: portraitBytes.toString("base64"), mimeType },
984
+ });
985
+ await writeFile(imageCache, bytes);
986
+ } catch (err) {
987
+ // NEVER cache a failure (§106), never fail the produce that just
988
+ // rendered. The message rides VERBATIM — the model slug is
989
+ // user-specified, and an unknown-model rejection is deterministic, so
990
+ // no retry and no paraphrase (§132 posture).
991
+ log(
992
+ `▸ thumbnail: generation failed (${err instanceof Error ? err.message : String(err)}) ` +
993
+ "— frame-grab cover stands",
994
+ );
995
+ return undefined;
996
+ }
997
+ }
998
+ const dest = artifactPath(args.outPath, ".thumbnail.png");
999
+ await copyFile(imageCache, dest);
1000
+ log(`✓ thumbnail → ${dest}`);
1001
+ return {
1002
+ path: dest,
1003
+ concept,
1004
+ imageCachePath: imageCache,
1005
+ portrait: { data: portraitBytes.toString("base64"), mimeType },
1006
+ };
1007
+ }
1008
+
1009
+ /**
1010
+ * Whether inferred retake collapse (findRetakeGroups, R27 §128) runs at all.
1011
+ * Gated on the blooper marker, NOT on `--collapse-retakes` — user decision,
1012
+ * verbatim (2026-08-16): "Bloopers and retakes go hand-in-hand. Do not do
1013
+ * retakes without bloopers... If blooper is there, we do it, else we don't."
1014
+ * A marker the speaker says out loud is the signal that this recording style
1015
+ * leaves flubs in the take; without it, inferred cutting has no such
1016
+ * license. `--collapse-retakes` stays parseable (old command.json replays)
1017
+ * but inert. Trim-empty counts as absent: findBloopSpans refuses a blank
1018
+ * marker for the same reason. Pure so the gate matrix is testable without a
1019
+ * run.
1020
+ */
1021
+ export function inferredRetakesEnabled(blooperMarker: string | undefined): boolean {
1022
+ return typeof blooperMarker === "string" && blooperMarker.trim().length > 0;
1023
+ }
1024
+
1025
+ /**
1026
+ * RetakeGroup cuts → `buildCutlist`'s `retakes` entries. The exact-prefix
1027
+ * restart rule carries RESTART_PREFIX_CONFIDENCE (0.85) instead of the 0.9
1028
+ * default a similarity-matched retake earns — the group's `rule` is the only
1029
+ * place that distinction lives, and it's dropped by the flatMap, so the
1030
+ * confidence has to be attached here. Pure so the mapping is testable
1031
+ * without a run.
1032
+ */
1033
+ export function retakeCutsFor(
1034
+ groups: readonly RetakeGroup[],
1035
+ ): { startWord: number; endWord: number; startSec: number; endSec: number; confidence?: number }[] {
1036
+ return groups.flatMap((g) =>
1037
+ g.cuts.map((c) =>
1038
+ g.rule === "exact-prefix" ? { ...c, confidence: RESTART_PREFIX_CONFIDENCE } : c,
1039
+ ),
1040
+ );
1041
+ }
1042
+
1043
+ /**
1044
+ * How much of the source a cover crop into `frame` keeps, and on which axis.
1045
+ * Cover scales the picture until BOTH frame axes are filled, then trims
1046
+ * whichever source axis overflows: a source wider than the frame loses width
1047
+ * (kept = frameAspect / contentAspect), a narrower one loses height (the
1048
+ * inverse). `null` means either nothing is trimmed (matching aspects) or a
1049
+ * dimension is degenerate and no claim can be made. Orientation-neutral on
1050
+ * purpose: the old call-site warning was gated on `!landscape`, assuming a
1051
+ * 16:9 output never meaningfully crops a 16:9-ish source — and the
1052
+ * 2026-08-16 incident was exactly that, a 1.547:1 screen recording in a 16:9
1053
+ * frame with 13% of the height silently gone (28% post-normalization) and no
1054
+ * line in the log ever mentioning it. Pure so the whole orientation matrix
1055
+ * is testable without probing a real video.
1056
+ */
1057
+ export function coverKeepFraction(
1058
+ content: { width: number; height: number },
1059
+ frame: { width: number; height: number },
1060
+ ): { axis: "width" | "height"; kept: number } | null {
1061
+ if (content.width <= 0 || content.height <= 0 || frame.width <= 0 || frame.height <= 0) {
1062
+ return null;
1063
+ }
1064
+ const contentAspect = content.width / content.height;
1065
+ const frameAspect = frame.width / frame.height;
1066
+ if (contentAspect > frameAspect) return { axis: "width", kept: frameAspect / contentAspect };
1067
+ if (contentAspect < frameAspect) return { axis: "height", kept: contentAspect / frameAspect };
1068
+ return null;
1069
+ }
1070
+
1071
+ /**
1072
+ * What the whole-take face measurement says the frame's SUBJECT is — the
1073
+ * same rule `segmentIsFaceOnly` (core) applies per segment, here on the
1074
+ * global median box that feeds `face` in render-props. "screen" tells the
1075
+ * stage's cover bias to stay centered instead of chasing the face: in the
1076
+ * 2026-08-16 incident the global 9-sample median landed on the camera PiP
1077
+ * (sizeFrac 0.119, bottom-right) and pinned objectPosY to 1.0, cutting the
1078
+ * speaker's head off at the top of every full-frame stretch — a PiP-sized
1079
+ * face must not steer the cover. Accepts `measureFace`'s own return shape
1080
+ * (null = no face found at all), and reads null the way segmentIsFaceOnly
1081
+ * does: no face, one below FACE_ONLY_MIN_FRAC, or one seen in under
1082
+ * FACE_MIN_DETECTION_RATIO of the samples means the picture is the subject.
1083
+ * Pure so the classification matrix is testable without a video or the
1084
+ * detector.
1085
+ */
1086
+ export function faceSubject(faceBox: FaceBox | null): "face" | "screen" {
1087
+ if (!faceBox) return "screen";
1088
+ if (faceBox.sizeFrac < FACE_ONLY_MIN_FRAC) return "screen";
1089
+ return faceBox.framesSampled > 0 &&
1090
+ faceBox.framesDetected / faceBox.framesSampled >= FACE_MIN_DETECTION_RATIO
1091
+ ? "face"
1092
+ : "screen";
1093
+ }
1094
+
1095
+ /**
1096
+ * Reunites commander's two jump-cut keys into the one tri-state
1097
+ * `ProduceOptions.jumpCuts`. Unlike the watermark pair — one key, positive
1098
+ * declared first so the untyped default stays undefined — this pair's
1099
+ * positive is spelled `--add-jump-cuts` (bare "--jump-cuts" reads as adding
1100
+ * CUTS, not the zooms that conceal them), and commander only folds a
1101
+ * negative onto the key its exact positive spelling owns: `--no-jump-cuts`
1102
+ * alone creates `jumpCuts` defaulting TRUE, while `--add-jump-cuts` lands on
1103
+ * `addJumpCuts`. So "typed --no-jump-cuts" is indistinguishable from "not
1104
+ * typed" by value — the caller passes commander's getOptionValueSource
1105
+ * verdict instead. Both typed is a contradiction and must be a loud error,
1106
+ * never a precedence rule the user has to memorize. Pure so the whole
1107
+ * flag matrix is testable without commander in the loop.
1108
+ */
1109
+ export function jumpCutsFlag(
1110
+ addJumpCuts: boolean | undefined,
1111
+ noJumpCutsTyped: boolean,
1112
+ ): boolean | undefined {
1113
+ if (addJumpCuts === true && noJumpCutsTyped) {
1114
+ throw new Error("--add-jump-cuts contradicts --no-jump-cuts — pass at most one");
1115
+ }
1116
+ if (addJumpCuts === true) return true;
1117
+ return noJumpCutsTyped ? false : undefined;
1118
+ }
1119
+
1120
+ /**
1121
+ * The effective jump-cut punch mode from the tri-state flag. "auto" (not
1122
+ * typed) and "force" (--add-jump-cuts) punch identically TODAY — the split
1123
+ * exists so a future config key can turn the default off while a typed
1124
+ * --add-jump-cuts still beats it, resolveWatermark's flag-beats-config
1125
+ * precedence declared before the config side even exists. Pure so the
1126
+ * matrix is testable without a flag parse.
1127
+ */
1128
+ export type JumpCutsMode = "off" | "auto" | "force";
1129
+
1130
+ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
1131
+ if (flag === true) return "force";
1132
+ if (flag === false) return "off";
1133
+ return "auto";
1134
+ }
1135
+
1136
+ /**
1137
+ * The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
1138
+ * (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
1139
+ * screen content sideways at every cut on the incident's screen recording,
1140
+ * and even on a talking head a 7% lurch reads as the camera stumbling. Big
1141
+ * enough to break up the jump, small enough to pass as sensor noise.
1142
+ */
1143
+ export const FACE_PUNCH_SCALE = 1.015;
1144
+
1145
+ /**
1146
+ * The framing subject at a SOURCE time — which of the plan's segments owns
1147
+ * `srcSec`, with the same edge clamping as scenes' `framingWindowAtOutput`:
1148
+ * a time before the first segment reads as the first, after the last as the
1149
+ * last, so a span whose in-point rounds a hair past a boundary still gets a
1150
+ * segment's verdict rather than a hole. An empty timeline reads as "screen"
1151
+ * — no punch — because with no plan there is no evidence the frame is just
1152
+ * a face, and the guard's failure mode (sliding a screen share) is the
1153
+ * worse of the two. Pure so the lookup is testable against a fixture.
1154
+ */
1155
+ export function framingSubjectAt(
1156
+ timeline: readonly FramingSegment[],
1157
+ srcSec: number,
1158
+ ): "face" | "screen" {
1159
+ if (timeline.length === 0) return "screen";
1160
+ if (srcSec < timeline[0]!.startSec) return timeline[0]!.subject;
1161
+ for (const seg of timeline) {
1162
+ if (srcSec >= seg.startSec && srcSec < seg.endSec) return seg.subject;
1163
+ }
1164
+ return timeline[timeline.length - 1]!.subject;
1165
+ }
1166
+
1167
+ /**
1168
+ * The per-span jump-cut punch plan `render-props.punch` carries. THE
1169
+ * FACE-ONLY GUARD HOLDS IN EVERY MODE, "force" included: punching a screen
1170
+ * share slides its content — text visibly drifting is WORSE than the jump
1171
+ * the punch would conceal — so `--add-jump-cuts` overrides a (future)
1172
+ * config-off, never the guard. `spanIsFaceOnly` comes per span from the
1173
+ * framing timeline's subject at the span's source in-point, or from the
1174
+ * global `faceSubject` verdict when no plan exists. Mode "off" still emits
1175
+ * a full all-false mask rather than nothing: an ABSENT `punch` key is the
1176
+ * legacy 1.07-everywhere contract, the opposite of off. Pure so the
1177
+ * mode × subject matrix is testable without a produce run.
1178
+ */
1179
+ export function punchPlanFor(
1180
+ spans: readonly KeptSpan[],
1181
+ mode: JumpCutsMode,
1182
+ spanIsFaceOnly: readonly boolean[],
1183
+ ): { scale: number; allowed: boolean[] } {
1184
+ if (mode === "off") return { scale: 1, allowed: spans.map(() => false) };
1185
+ return {
1186
+ scale: FACE_PUNCH_SCALE,
1187
+ allowed: spans.map((_, i) => spanIsFaceOnly[i] === true),
1188
+ };
1189
+ }
1190
+
1191
+ /**
1192
+ * One face-only verdict per kept span, read where that span BEGINS — the
1193
+ * frame at the cut is what any motion driver scales. With a framing plan the
1194
+ * verdict is the plan's per-segment subject at the span's source in-point;
1195
+ * without one every span shares the global `faceSubject` verdict. Hoisted to
1196
+ * ONE mask because TWO motion drivers consume it — the jump-cut punch
1197
+ * (`punchPlanFor.allowed`) and the idle zoom (`buildZoomPlan.allowedClips`,
1198
+ * user decision 2026-08-16: "Face-only. If there's anything else, then no
1199
+ * zoom" — the idle push visibly SLID screen-recording content) — and they
1200
+ * must never disagree about who the subject is: a span the punch holds still
1201
+ * but the idle zoom pushes would slide the very content the guard exists to
1202
+ * protect. Pure so the timeline × subject matrix is testable without a
1203
+ * produce run.
1204
+ */
1205
+ export function spanFaceMask(
1206
+ spans: readonly KeptSpan[],
1207
+ framingTimeline: readonly FramingSegment[] | null,
1208
+ globalSubject: "face" | "screen",
1209
+ ): boolean[] {
1210
+ return spans.map(
1211
+ (sp) =>
1212
+ (framingTimeline ? framingSubjectAt(framingTimeline, sp.srcIn) : globalSubject) === "face",
1213
+ );
1214
+ }
1215
+
1216
+ /**
1217
+ * The measurement windows for a MEASURED per-span mask — each kept span's
1218
+ * SOURCE range over the full frame (`cropVf: ""`, the shape
1219
+ * `measureFaceInWindows` takes). This path only runs when no framing plan
1220
+ * exists, i.e. the content rects are uniform, so there is no per-segment
1221
+ * rect to crop to first. Pure so the span→window mapping is testable
1222
+ * without ffmpeg.
1223
+ */
1224
+ export function spanFaceWindows(
1225
+ spans: readonly KeptSpan[],
1226
+ ): Array<{ startSec: number; endSec: number; cropVf: string }> {
1227
+ return spans.map((sp) => ({ startSec: sp.srcIn, endSec: sp.srcOut, cropVf: "" }));
1228
+ }
1229
+
1230
+ /**
1231
+ * `spanFaceMask`'s sibling for the no-plan path, from MEASURED faces
1232
+ * (2026-08-16 v2 review): a screen recording with full-frame webcam
1233
+ * stretches has uniform content rects, so no framing plan exists and the
1234
+ * old fallback let the GLOBAL `faceSubject` verdict — "screen", because the
1235
+ * whole-take median landed on the 0.119 PiP — speak for every span. The
1236
+ * face-only stretches therefore got no punch concealment (raw jump cuts
1237
+ * visible on the face) and no idle zoom. With no plan to supply subjects,
1238
+ * the mask must be measured per span; the verdict rule is core's own
1239
+ * `segmentIsFaceOnly`, the same one the framing plan applies per segment.
1240
+ * `faces` is parallel to the spans that produced the windows. Pure so the
1241
+ * wiring is testable without spawning ffmpeg.
1242
+ */
1243
+ export function spanFaceMaskFromFaces(faces: ReadonlyArray<WindowFace | null>): boolean[] {
1244
+ return faces.map((f) => segmentIsFaceOnly(f));
1245
+ }
1246
+
1247
+ /**
1248
+ * Cache key for the measured per-span mask: the spans' SOURCE ranges plus
1249
+ * the source content hash. `measureFaceInWindows` itself does not cache
1250
+ * (its other caller feeds a bake output that is cached downstream), and a
1251
+ * ~55-span take is a few hundred single-frame ffmpeg spawns — too much to
1252
+ * repeat on every warm re-run. Keyed on source ranges so a re-cut
1253
+ * re-measures, and on the source identity so a same-shape cut of a
1254
+ * different take cannot borrow verdicts. Pure so the key's inputs are
1255
+ * pinned by a test.
1256
+ */
1257
+ export function spanFaceCacheKey(spans: readonly KeptSpan[], sourceHash: string): string {
1258
+ return createHash("sha1")
1259
+ .update(JSON.stringify([sourceHash, spans.map((sp) => [sp.srcIn, sp.srcOut])]))
1260
+ .digest("hex")
1261
+ .slice(0, 12);
1262
+ }
1263
+
324
1264
  /**
325
1265
  * Whether captions are hidden this run: the flag saying OFF, or the editor's
326
1266
  * doc-global `captionsHidden` override saying hidden. An OR, deliberately
@@ -341,6 +1281,28 @@ export function resolveCaptionsHidden(
341
1281
  return flag === false || overrideHidden === true;
342
1282
  }
343
1283
 
1284
+ /**
1285
+ * Caption packing per orientation (2026-08-16 v2 review, user screenshot:
1286
+ * "we can actually even have more letters at a time on the screen").
1287
+ * Landscape draws captions at 44px on a 1920px frame against portrait's
1288
+ * 64px on 1080px (`captionFontSizeFor`) — roughly 2.6× the horizontal text
1289
+ * budget (1920/44 ≈ 44 character-widths vs 1080/64 ≈ 17) — so the portrait
1290
+ * default's 3-word lines look sparse there; landscape packs 6 words over
1291
+ * 2.4s, double the core defaults. Portrait returns those defaults VERBATIM
1292
+ * — stated explicitly at the call site rather than changed in captions.ts,
1293
+ * because the core defaults are portrait's contract and its output must
1294
+ * stay byte-identical. Pure so the matrix is testable without a produce
1295
+ * run.
1296
+ */
1297
+ export function captionPackingFor(landscape: boolean): {
1298
+ maxWordsPerLine: number;
1299
+ maxLineDuration: number;
1300
+ } {
1301
+ return landscape
1302
+ ? { maxWordsPerLine: 6, maxLineDuration: 2.4 }
1303
+ : { maxWordsPerLine: 3, maxLineDuration: 1.2 };
1304
+ }
1305
+
344
1306
  function sha1File(path: string): Promise<string> {
345
1307
  return new Promise((res, rej) => {
346
1308
  const h = createHash("sha1");
@@ -396,7 +1358,34 @@ function deriveWorkdir(
396
1358
  * file input's equivalent default already lands.
397
1359
  */
398
1360
  export function defaultOutPath(originalInput: string): string {
399
- 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
+ );
400
1389
  }
401
1390
 
402
1391
  /**
@@ -411,9 +1400,12 @@ export function defaultOutPath(originalInput: string): string {
411
1400
  * directory (a folder run's clips folder, or — the reviewer's pre-existing
412
1401
  * "latent" case — a file run's own folder once a mezzanine gets built)
413
1402
  * passes the accept check and then 404s inside the render, after the run has
414
- * already spent the minutes getting there. `analysisInput` becomes something
415
- * other than `input` only via the framing bake, and that bake always writes
416
- * into `work`; the mezzanine build is the other path into `work`.
1403
+ * already spent the minutes getting there. The framing bake was the one path
1404
+ * that ever analysed a file other than `input` (always written into `work`);
1405
+ * since framing became render-props (2026-08-16) the caller passes
1406
+ * `inputIsAnalysisInput: true`, and the parameter survives as the contract —
1407
+ * any future non-input analysis file must live in `work` — with the
1408
+ * mezzanine build as the remaining path into `work`.
417
1409
  */
418
1410
  export function planRenderPublicDir(p: {
419
1411
  input: string;
@@ -538,6 +1530,33 @@ export function sideImageDestRel(src: string): string {
538
1530
  */
539
1531
  const PRIMARY_VIDEO_SLOT_AREA = 0.2;
540
1532
 
1533
+ /**
1534
+ * Every layout's video-slot shape for the producer's framing brief: aspect
1535
+ * in OUTPUT pixels, plus whether the slot is the SUBJECT (see
1536
+ * PRIMARY_VIDEO_SLOT_AREA above) rather than an inset. `frame` must reach
1537
+ * layoutSlots itself, not just the pixel multiply: layoutSlots defaults to
1538
+ * PORTRAIT_FRAME, and the R15 split layouts change GEOMETRY with orientation
1539
+ * — split-left is a {w:1, h:0.5} stack in portrait but a {w:0.5, h:1} side
1540
+ * panel in landscape — so omitting it fed portrait slot fractions times
1541
+ * landscape pixel dims to the brief, marking the wrong layouts UNAVAILABLE
1542
+ * on every 16:9 run (latent since R15 landscape support; surfaced by the
1543
+ * 2026-08-16 incident audit). Pure so both orientations are testable
1544
+ * without an LLM run.
1545
+ */
1546
+ export function layoutSlotAspects(frame: {
1547
+ width: number;
1548
+ height: number;
1549
+ }): { layout: Layout; slotAspect: number; primary: boolean }[] {
1550
+ return LayoutSchema.options.map((layout) => {
1551
+ const v = layoutSlots(layout, DEFAULT_FACE, [], frame).video;
1552
+ return {
1553
+ layout,
1554
+ slotAspect: (v.rect.w * frame.width) / (v.rect.h * frame.height),
1555
+ primary: v.opacity > 0 && v.rect.w * v.rect.h >= PRIMARY_VIDEO_SLOT_AREA,
1556
+ };
1557
+ });
1558
+ }
1559
+
541
1560
  async function preflight(bin: string, hint: string): Promise<void> {
542
1561
  try {
543
1562
  await run(bin, ["-version"], { allowNonZero: true });
@@ -564,9 +1583,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
564
1583
  // video" image lookup both used to read the REASSIGNED `input`, which for a
565
1584
  // folder run is a file inside the hidden workdir, not anything the user
566
1585
  // would recognise.
567
- const originalInput = isAbsolute(inputArg) ? inputArg : resolve(baseCwd, inputArg);
1586
+ // expandHome first (2026-08-16 incident, see paths.ts): a `~/` path that
1587
+ // reaches us unexpanded — the wizard's text prompts, a quoted argv — must
1588
+ // never be resolved against cwd.
1589
+ const originalInput = isAbsolute(inputArg)
1590
+ ? inputArg
1591
+ : resolve(baseCwd, expandHome(inputArg));
568
1592
  let input = originalInput;
569
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();
570
1597
 
571
1598
  // §93b: the window is an editorial judgement, and there is deliberately no
572
1599
  // heuristic fallback — an automatically-guessed 60 seconds reads as a bug,
@@ -588,6 +1615,32 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
588
1615
  throw new Error("--clip-window is recorded by --clip runs for replay — pass --clip too.");
589
1616
  }
590
1617
 
1618
+ // Resolved HERE, not at the render section (2026-08-16 field incident): a
1619
+ // bad out path must fail (or be healed) in the first second, not at the
1620
+ // rename after a 50-minute render — a wizard-typed `~/Downloads/...`
1621
+ // resolved against cwd and the end-of-run rename ENOENT'd because the
1622
+ // parent never existed. mkdir over refusal: the path is the user's explicit
1623
+ // intent and creating a folder is what they'd do by hand; a genuinely
1624
+ // un-creatable path (permissions) still fails loudly, now upfront.
1625
+ const outArg = opts.out !== undefined ? expandHome(opts.out) : undefined;
1626
+ const outPath = outArg
1627
+ ? isAbsolute(outArg)
1628
+ ? outArg
1629
+ : resolve(baseCwd, outArg)
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
+ }
1642
+ ensureParentDir(outPath);
1643
+
591
1644
  await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
592
1645
  await preflight(cfg.ffprobePath, "Run `ossclip setup`, install ffmpeg (provides ffprobe), or set OSSCLIP_FFPROBE.");
593
1646
 
@@ -598,6 +1651,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
598
1651
 
599
1652
  const tools = { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath };
600
1653
 
1654
+ // Resolved ONCE for the whole run — whisper biasing, repair vouching and
1655
+ // caption casing must all see the same list, or the passes disagree about
1656
+ // what a term is spelled like. A typed --dictionary wholesale beats the
1657
+ // config (resolveWatermark's typed-beats-config precedence, no merging).
1658
+ const configDictionary = validDictionary(cfg.dictionary);
1659
+ if (opts.dictionary === undefined && cfg.dictionary !== undefined && configDictionary === undefined) {
1660
+ console.log("⚠ config dictionary ignored — expected an array of non-empty strings");
1661
+ }
1662
+ const dictionary = opts.dictionary ?? configDictionary ?? [];
1663
+ if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
1664
+
1665
+ // The run's base theme (F6): config theme over defaultTheme, resolved once
1666
+ // and used for BOTH resolveTheme's base and props.baseTheme below — the
1667
+ // editor's reset must land on the user's global colors, not the factory's.
1668
+ const { theme: configBaseTheme, warning: themeWarning } = configuredBaseTheme(cfg.theme);
1669
+ if (themeWarning) console.log(themeWarning);
1670
+
601
1671
  // Folder input (folder-input-brief.md, 2026-08-05 field request). The
602
1672
  // workdir hash is derived from the folder's CONTENT — `folderManifestKey`
603
1673
  // over the enumerated clips — not the folder path. Review fix: a path-only
@@ -609,7 +1679,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
609
1679
  // video with captions transcribed against a different edit. Hashing the
610
1680
  // manifest content gives a folder input the same invariant a file input
611
1681
  // already has via `sha1File`: content changes ⇒ a fresh workdir.
612
- const isFolder = statSync(input).isDirectory();
1682
+ // (`isFolder` itself is decided up top, beside the out-path gate.)
613
1683
  // Final-review fix wave, cheap minor c: --sort only means anything for a
614
1684
  // folder input; on a file it did nothing, silently. Gated on `sortExplicit`
615
1685
  // (whether the user TYPED it) rather than on `opts.sort` itself, since
@@ -629,9 +1699,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
629
1699
  } else {
630
1700
  hash = (await sha1File(input)).slice(0, 8);
631
1701
  }
632
- const work = deriveWorkdir(input, hash, opts.workdir, landscape);
1702
+ // expandHome at the call site so deriveWorkdir stays homedir-free.
1703
+ const work = deriveWorkdir(
1704
+ input,
1705
+ hash,
1706
+ opts.workdir !== undefined ? expandHome(opts.workdir) : undefined,
1707
+ landscape,
1708
+ );
633
1709
  await mkdir(work, { recursive: true });
634
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);
635
1715
 
636
1716
  // §131 residue: a folder re-key (clips renamed/added/removed → new content
637
1717
  // hash) correctly lands in a fresh workdir, but any editor edits saved in
@@ -680,6 +1760,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
680
1760
  ` ${result.nonVideoCount} non-video file${result.nonVideoCount === 1 ? "" : "s"} ignored`,
681
1761
  );
682
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
+ }
683
1774
  // Order visible immediately (folder-input-brief.md) — a wrong order is a
684
1775
  // silent bug otherwise, invisible until someone watches the whole thing.
685
1776
  result.clips.forEach((c, i) => {
@@ -756,9 +1847,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
756
1847
  // the flag exists for — and equally defeated the model A/B the
757
1848
  // --whisper-model help text advertises.
758
1849
  const transcriptKeyPath = join(work, "transcript-key.json");
1850
+ const requestedModel = opts.whisperModel ?? cfg.model;
1851
+ // Flag > config > the curated table's model-implied language (a non-English
1852
+ // fine-tune without `-l` decodes garbage — Urdu field test 2026-08-05).
1853
+ // Resolved BEFORE the key so a config/model-sourced language re-keys the
1854
+ // cache exactly like the typed flag does.
1855
+ const whisperLang = resolveWhisperLanguage(
1856
+ opts.whisperLanguage,
1857
+ cfg.language,
1858
+ modelImpliedLanguage(requestedModel),
1859
+ );
1860
+ if (whisperLang.warning) console.log(whisperLang.warning);
1861
+ if (whisperLang.source === "config" || whisperLang.source === "model") {
1862
+ console.log(
1863
+ `▸ whisper language: ${whisperLang.language} ` +
1864
+ `(from ${whisperLang.source === "config" ? "config" : `model ${requestedModel}`}; ` +
1865
+ `--whisper-language overrides)`,
1866
+ );
1867
+ }
759
1868
  const requestedKey: TranscriptKey = {
760
- model: opts.whisperModel ?? cfg.model,
761
- ...(opts.whisperLanguage !== undefined ? { language: opts.whisperLanguage } : {}),
1869
+ model: requestedModel,
1870
+ ...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
1871
+ // Omitted when empty, not written as [] — pre-dictionary key files have
1872
+ // no `dictionary` at all, and transcriptCacheReusable reads absent and
1873
+ // empty as the same "no biasing", so old workdirs must not re-transcribe.
1874
+ ...(dictionary.length > 0 ? { dictionary } : {}),
762
1875
  };
763
1876
  let cacheVerdict: ReturnType<typeof transcriptCacheReusable> | null = null;
764
1877
  if (!opts.transcript && existsSync(transcriptCache)) {
@@ -768,7 +1881,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
768
1881
  cacheVerdict = transcriptCacheReusable(recorded, requestedKey, cfg.model);
769
1882
  }
770
1883
  if (opts.transcript) {
771
- transcript = TranscriptSchema.parse(JSON.parse(await readFile(resolve(opts.transcript), "utf8")));
1884
+ // expandHome before resolve — same 2026-08-16 rule as --out (paths.ts).
1885
+ transcript = TranscriptSchema.parse(
1886
+ JSON.parse(await readFile(resolve(expandHome(opts.transcript)), "utf8")),
1887
+ );
772
1888
  console.log(`▸ transcript injected from ${opts.transcript} (${transcript.words.length} words)`);
773
1889
  // An injected transcript came from no whisper run at all, so any key left
774
1890
  // by an earlier one would mislabel the cache this branch overwrites below.
@@ -791,12 +1907,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
791
1907
  "Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
792
1908
  );
793
1909
  const model = requestedKey.model;
794
- const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
1910
+ // whisperModelPath/modelUrl are THE resolution and URL sources (shared
1911
+ // with doctor and setup) — this error used to hold its own copy of the
1912
+ // ggerganov URL, which 404'd for curated/custom names and the suggested
1913
+ // `curl -L` then saved the 404 HTML as a fake model.
1914
+ const modelPath = whisperModelPath(model, cfg.modelDir);
795
1915
  if (!existsSync(modelPath)) {
796
1916
  throw new Error(
797
1917
  `whisper model not found at ${modelPath}.\n` +
798
1918
  `Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
799
- ` curl -L -o ${modelPath} https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${model}.bin`,
1919
+ ` curl -L -o ${modelPath} ${modelUrl(model, validModelSources(cfg.modelSources))}`,
800
1920
  );
801
1921
  }
802
1922
  const whisperAnim = isInteractive()
@@ -813,7 +1933,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
813
1933
  whisperPath: cfg.whisperPath,
814
1934
  modelPath,
815
1935
  outBase: join(work, "whisper"),
816
- language: opts.whisperLanguage,
1936
+ // The RESOLVED language, not the raw flag — a config/model-implied
1937
+ // code must reach the spawn exactly as it reached the cache key.
1938
+ language: requestedKey.language,
1939
+ // Vocabulary biasing (F4) — undefined for an empty dictionary, so
1940
+ // the spawned args stay byte-identical to every pre-dictionary run.
1941
+ prompt: whisperPromptFor(dictionary),
817
1942
  },
818
1943
  audioPath,
819
1944
  ),
@@ -849,7 +1974,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
849
1974
  // repair — the repair pass reads a bare "blooper." as an oddity and has
850
1975
  // already been observed proposing "break loop." for it. Detecting first
851
1976
  // means the marker cannot be rewritten out from under the detector.
852
- let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker) : [];
1977
+ // `silences` rides along so the cut can extend through the marker's own
1978
+ // trailing dead air — §18 stamp-stretch put the stamped end of a spoken
1979
+ // "blooper." 0.4s before its acoustic end (2026-08-16 incident, see
1980
+ // MAX_MARKER_BLEED_SEC in blooper.ts).
1981
+ let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker, silences) : [];
853
1982
  if (opts.blooperMarker) {
854
1983
  console.log(
855
1984
  bloops.length > 0
@@ -861,24 +1990,30 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
861
1990
  // Deterministic retake collapse (R27 §128) — the flub the speaker did NOT
862
1991
  // mark. Same RAW-transcript-before-repair ordering as the blooper marker
863
1992
  // above and for the same reason: repair reading a stray restart as an
864
- // oddity would rewrite the exact pattern this looks for.
865
- let retakeGroups = opts.collapseRetakes
1993
+ // oddity would rewrite the exact pattern this looks for. Gated on the
1994
+ // marker, not --collapse-retakes (inferredRetakesEnabled has the user's
1995
+ // verbatim rule); the legacy flag typed alone earns a notice, not silence.
1996
+ const retakesEnabled = inferredRetakesEnabled(opts.blooperMarker);
1997
+ if (opts.collapseRetakes && !retakesEnabled) {
1998
+ console.log("▸ collapse-retakes: skipped — retake detection runs only with --blooper-marker");
1999
+ }
2000
+ let retakeGroups = retakesEnabled
866
2001
  ? findRetakeGroups(transcript, analysis, { transparentMarker: opts.blooperMarker })
867
2002
  : [];
868
- let retakes = retakeGroups.flatMap((g) => g.cuts);
869
- if (opts.collapseRetakes) {
2003
+ let retakes = retakeCutsFor(retakeGroups);
2004
+ if (retakesEnabled) {
870
2005
  // `exact` never cuts anything — buildCutlist's own early return collapses
871
2006
  // to one whole-duration `keep` regardless of what's in `retakes` — so
872
2007
  // "N group(s), M take(s) cut" here was a claim the run never honored.
873
2008
  // Same fact `valveFired` below already checks; gated the same way
874
2009
  // (final-review fix wave, cheap minor b).
875
2010
  if (opts.cleanup === "exact") {
876
- console.log("▸ collapse-retakes: --cleanup exact wins — nothing cut");
2011
+ console.log("▸ retakes: --cleanup exact wins — nothing cut");
877
2012
  } else {
878
2013
  console.log(
879
2014
  retakeGroups.length > 0
880
- ? `▸ collapse-retakes: ${retakeGroups.length} group(s), ${retakes.length} take(s) cut`
881
- : "▸ collapse-retakes: no retakes found",
2015
+ ? `▸ retakes: ${retakeGroups.length} group(s), ${retakes.length} take(s) cut`
2016
+ : "▸ retakes: none found",
882
2017
  );
883
2018
  for (const g of retakeGroups) {
884
2019
  for (const line of formatRetakeGroup(transcript, g).split("\n")) console.log(` ▸ ${line}`);
@@ -910,7 +2045,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
910
2045
  const valveFired = opts.cleanup !== "exact" && cutlist.length === 1 && cutlist[0]!.kind === "keep";
911
2046
  if (retakes.length > 0 && valveFired) {
912
2047
  console.log(
913
- " ⚠ collapse-retakes found a retake, but the sanity valve reset the whole cutlist — nothing was cut",
2048
+ " ⚠ retake collapse found a retake, but the sanity valve reset the whole cutlist — nothing was cut",
914
2049
  );
915
2050
  }
916
2051
 
@@ -922,7 +2057,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
922
2057
  // mishearing can't reach the screen twice in two different spellings.
923
2058
  const providerName = opts.provider ?? defaultProviderName(process.env, binOnPath);
924
2059
  let provider: LlmProvider | null = null;
925
- const needsLlm = opts.produce === true;
2060
+ // --youtube brings its own provider (field gap, 2026-08-16): the user's
2061
+ // real command was `--youtube --llm antigravity` WITHOUT --produce, and the
2062
+ // pack skipped with "needs an LLM provider" — a flag that exists to call an
2063
+ // LLM must count as opting into one. This also turns transcript repair on
2064
+ // for such runs, which is the dictionary's caption-side fix ("Jason" →
2065
+ // "JSON") — biasing whisper alone does not correct what ASR already heard.
2066
+ const needsLlm = opts.produce === true || resolveYoutube(opts.youtube, cfg.youtube);
926
2067
  if (needsLlm) {
927
2068
  // Only when auto-detected: a typed --llm needs no explanation. The line
928
2069
  // itself lives in llm-detect.ts so a drift test covers every provider —
@@ -947,6 +2088,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
947
2088
  opts.llmModel,
948
2089
  opts.llmFastModel ?? cfg.fastModel,
949
2090
  opts.speaker ?? cfg.speaker,
2091
+ // The dictionary changes both the prompt and the vouched set (F4),
2092
+ // so cached repairs from a different vocabulary must not be reused.
2093
+ dictionary,
950
2094
  rawTranscript.words.map((w) => w.text),
951
2095
  ]),
952
2096
  )
@@ -954,12 +2098,37 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
954
2098
  .slice(0, 8);
955
2099
  const repairCache = join(work, `repairs-${rawKey}.json`);
956
2100
  if (existsSync(repairCache)) {
957
- repairs = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
958
- 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(
959
2112
  rawTranscript,
960
- repairs.filter((r) => r.applied),
961
- ).transcript;
962
- console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
2113
+ cached.map(({ startWord, endWord, heard, correction }) => ({
2114
+ startWord,
2115
+ endWord,
2116
+ heard,
2117
+ correction,
2118
+ })),
2119
+ { dictionary },
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
+ );
963
2132
  } else {
964
2133
  const repairAnim = isInteractive()
965
2134
  ? new StageAnimator(
@@ -971,6 +2140,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
971
2140
  const result = await phases.time("llm", () =>
972
2141
  repairTranscript(provider!, rawTranscript, {
973
2142
  speaker: opts.speaker ?? cfg.speaker,
2143
+ // Vouched terms (F4): named in the prompt AND exempt, when a
2144
+ // correction is built entirely of them, from the phonetic gate.
2145
+ dictionary,
974
2146
  // A repair may not merge words across a cut.
975
2147
  isCut: (startSec, endSec) =>
976
2148
  cutlist.some(
@@ -1009,12 +2181,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1009
2181
  // ---- Framing measurement (PLAN Tasks A+B) --------------------------------
1010
2182
  /**
1011
2183
  * Mixed framing (option (a), decided with the author 2026-07-28): a source
1012
- * that alternates framings is NORMALIZED — every segment cropped to the
1013
- * tightest field of view the take ever shows, placed on that segment's own
1014
- * measured face, and baked into one uniform file. The PLAN is computed here,
1015
- * before the producer, because the producer needs the framing brief: which
1016
- * word ranges are close shots, and which layouts those rule out. The BAKE
1017
- * itself runs after the scenes exist.
2184
+ * that alternates framings gets ONE field of view — every segment windowed
2185
+ * to the tightest framing the take ever shows, placed on that segment's own
2186
+ * measured face. The PLAN is computed here, before the producer, because
2187
+ * the producer needs the framing brief: which word ranges are close shots,
2188
+ * and which layouts those rule out. The plan used to be BAKED into a
2189
+ * re-encoded file after the scenes existed; since 2026-08-16 it is emitted
2190
+ * as `framingTimeline` render-props instead (see the props assembly below).
1018
2191
  */
1019
2192
  let framingPlan: NormalizePlan | null = null;
1020
2193
  if (!detection.uniform) {
@@ -1071,9 +2244,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1071
2244
  * `production.json`, the report, and the command.json pin below. */
1072
2245
  let clipWindow: ClipWindow | null = null;
1073
2246
  if (opts.scenes) {
1074
- scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(resolve(opts.scenes), "utf8")));
2247
+ // expandHome before resolve — same 2026-08-16 rule as --out (paths.ts).
2248
+ scenes = z.array(SceneSchema).parse(
2249
+ JSON.parse(await readFile(resolve(expandHome(opts.scenes)), "utf8")),
2250
+ );
1075
2251
  console.log(`▸ scenes injected from ${opts.scenes} (${scenes.length})`);
1076
- } else if (provider) {
2252
+ } else if (provider && opts.produce === true) {
2253
+ // `opts.produce`, not bare `provider` (2026-08-16): --youtube now brings
2254
+ // a provider for its metadata/repair, and the bare-provider gate silently
2255
+ // turned GRAPHICS on for a run that never asked for them — 12 surprise
2256
+ // scenes on a plain-cut video. A provider is a capability; --produce is
2257
+ // the consent.
1077
2258
  // Keyed on the repaired transcript's TEXT, not its word count: a repair
1078
2259
  // that swaps "coach and" for "code churn" leaves the count identical, and
1079
2260
  // a count-keyed cache would silently replan from the stale wording.
@@ -1090,14 +2271,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1090
2271
  faceFracOfCanvas: framingPlan.faceFracOfCanvas[i] ?? 0,
1091
2272
  })),
1092
2273
  canvasAspect: framingPlan.canvas.width / framingPlan.canvas.height,
1093
- layouts: LayoutSchema.options.map((layout) => {
1094
- const v = layoutSlots(layout).video;
1095
- return {
1096
- layout,
1097
- slotAspect: (v.rect.w * frame.width) / (v.rect.h * frame.height),
1098
- primary: v.opacity > 0 && v.rect.w * v.rect.h >= PRIMARY_VIDEO_SLOT_AREA,
1099
- };
1100
- }),
2274
+ layouts: layoutSlotAspects(frame),
1101
2275
  zoom: ZOOM_MAX_SCALE,
1102
2276
  }
1103
2277
  : undefined;
@@ -1166,11 +2340,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1166
2340
  analysis = analyze(rawTranscript, silences, sourceProbe.duration, levels);
1167
2341
  // Re-detect on the SLICE: word indices moved, so the spans found against
1168
2342
  // the full take no longer address the same words.
1169
- bloops = opts.blooperMarker ? findBloopSpans(rawTranscript, opts.blooperMarker) : [];
1170
- retakeGroups = opts.collapseRetakes
2343
+ // Full-source `silences` on a sliced transcript is correct on purpose:
2344
+ // sliceRawTranscript keeps SOURCE seconds on every word stamp, so the
2345
+ // bleed extension compares like with like.
2346
+ bloops = opts.blooperMarker
2347
+ ? findBloopSpans(rawTranscript, opts.blooperMarker, silences)
2348
+ : [];
2349
+ retakeGroups = retakesEnabled
1171
2350
  ? findRetakeGroups(rawTranscript, analysis, { transparentMarker: opts.blooperMarker })
1172
2351
  : [];
1173
- retakes = retakeGroups.flatMap((g) => g.cuts);
2352
+ retakes = retakeCutsFor(retakeGroups);
1174
2353
  cutlist = boundCutlistToWindow(
1175
2354
  buildCutlist({
1176
2355
  transcript: rawTranscript,
@@ -1358,6 +2537,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1358
2537
  }
1359
2538
  }
1360
2539
 
2540
+ // Deterministic dictionary casing (F4) — LAST word edit before the caption
2541
+ // build: after the repair reassignment and reconcileCopy above, so nothing
2542
+ // can lower-case a term back after this. Exact-token matches only ("json."
2543
+ // → "JSON."; "Jason" stays — phonetic judgement is the repair pass's job,
2544
+ // see dictionary.ts). `rawTranscript` stays untouched on purpose:
2545
+ // production.json stores the RAW words that `analysis`/`cutlist` index.
2546
+ transcript = canonicalizeDictionaryCasing(transcript, dictionary);
2547
+
1361
2548
  // Landscape keeps the frame whole (R15): the split-screen layouts are
1362
2549
  // vertical-format answers, and applying them to 16:9 crops the picture into
1363
2550
  // a letterbox for no gain. Remapped here — before assembly — so cues,
@@ -1469,48 +2656,69 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1469
2656
  // beside `render-props.json`'s own write (see the comment there for why).
1470
2657
  for (const r of cutResult.reports) console.log(` ⚠ ${r}`);
1471
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
+
1472
2676
  const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
1473
2677
  for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
1474
2678
 
1475
- // ---- Framing bake (plan step C / option (a)) ----------------------------
1476
- // The MEASUREMENT ran before the producer (Tasks A+B need it in the beat-
1477
- // sheet prompt); the BAKE stays here, after the scenes exist, so a future
1478
- // scene-aware bake has the cues in scope. Moving measurement up changes no
1479
- // edit decision — the cut is still computed on raw ASR above.
1480
- let analysisInput = input;
1481
- let analysisProbe = sourceProbe;
1482
- let analysisCropVf = cropVf;
1483
- let cacheTag = "";
2679
+ // ---- Framing plan → props (2026-08-16 incident) --------------------------
2680
+ // The plan used to be BAKED here: every window cropped, scaled and
2681
+ // re-encoded into a content-<hash>.mp4 that replaced the source for the
2682
+ // whole rest of the pipeline. That was irreversible — a bad crop's only
2683
+ // remedy was deleting the baked file — and invisible: the bake path also
2684
+ // suppressed the `sourceSize` keys in render-props, so the editor could
2685
+ // not even see the crop it was fighting. The plan now travels as DATA
2686
+ // (`framingTimeline`, emitted with the props below) and the renderer
2687
+ // applies each window as a transform the editor can see and counteract.
2688
+ // Old workdirs' baked content-*.mp4 stay on disk, inert: their own
2689
+ // render-props reference them by name and must keep rendering.
1484
2690
  let fitFallback = false;
1485
2691
  if (framingPlan) {
1486
2692
  const plan = framingPlan;
1487
2693
  if (plan.ok) {
1488
- const planHash = createHash("sha1").update(JSON.stringify(plan)).digest("hex").slice(0, 8);
1489
- const baked = join(work, `content-${planHash}.mp4`);
1490
- if (!existsSync(baked)) {
1491
- console.log(
1492
- `▸ normalizing framing: one ${plan.canvas.width}×${plan.canvas.height} field of view ` +
1493
- `across ${plan.segments.length} segments (upscale ×${plan.coverUpscale.toFixed(2)})…`,
1494
- );
1495
- await bakeNormalizedSource(tools, input, plan, baked);
1496
- } else {
1497
- console.log(`▸ normalized framing cached (${basename(baked)})`);
1498
- }
1499
- analysisInput = baked;
1500
- analysisProbe = await probe(tools, baked);
1501
- analysisCropVf = "";
1502
- cacheTag = basename(baked);
2694
+ console.log(
2695
+ `▸ framing: ${plan.segments.length} windows rendered from props (no re-encode)`,
2696
+ );
1503
2697
  } else {
1504
2698
  fitFallback = true;
2699
+ // A refusal names the number that tripped the gate. The upscale bound
2700
+ // is softness; the discard bound is picture loss — the 2026-08-16
2701
+ // incident's plan discarded 37% of the frame area and the old log
2702
+ // (upscale-only) never said so. The per-segment screen-loss bound has
2703
+ // no single headline number, so it reads as the residual case.
2704
+ const why = [
2705
+ ...(plan.coverUpscale > MAX_NORMALIZE_UPSCALE
2706
+ ? [`would upscale ×${plan.coverUpscale.toFixed(2)} > ${MAX_NORMALIZE_UPSCALE}`]
2707
+ : []),
2708
+ ...(plan.areaDiscardWeighted > MAX_MEAN_AREA_DISCARD
2709
+ ? [`would discard ${(plan.areaDiscardWeighted * 100).toFixed(0)}% of the picture`]
2710
+ : []),
2711
+ ];
1505
2712
  console.log(
1506
- ` ⚠ strip too small to unify (would upscale ×${plan.coverUpscale.toFixed(2)} > ` +
1507
- `${MAX_NORMALIZE_UPSCALE}) — letterboxed stretches render FITTED at natural size; ` +
2713
+ ` ⚠ strip too small to unify (${
2714
+ why.length > 0 ? why.join("; ") : "a screen segment would lose its content"
2715
+ }) — letterboxed stretches render FITTED at natural size; ` +
1508
2716
  `framing will visibly change at ${contentTimeline.length - 1} boundaries`,
1509
2717
  );
1510
2718
  }
1511
2719
  }
1512
2720
  const contentRect: ContentRect = detection.uniform ?? {
1513
- x: 0, y: 0, w: analysisProbe.width, h: analysisProbe.height, full: true,
2721
+ x: 0, y: 0, w: sourceProbe.width, h: sourceProbe.height, full: true,
1514
2722
  };
1515
2723
  /** The picture's dimensions — what every geometric consumer reasons about. */
1516
2724
  const content = { width: contentRect.w, height: contentRect.h };
@@ -1519,7 +2727,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1519
2727
  // not two independent copies of the same condition that could silently
1520
2728
  // drift apart (Finding 3, final-review fix wave: that drift is exactly
1521
2729
  // what let an accepted image 404 inside Remotion's staticFile()).
1522
- const mezzanineWillBuild = analysisInput === input && (opts.mezzanine || !contentRect.full);
2730
+ // The old `analysisInput === input` term is gone WITH the bake: the bake
2731
+ // was the only thing that ever pointed analysis at a different file, so
2732
+ // with framing as props the analysis input IS the source, always.
2733
+ const mezzanineWillBuild = opts.mezzanine || !contentRect.full;
1523
2734
 
1524
2735
  // Face measurement (FINDINGS §13): one static crop offset per source,
1525
2736
  // measured rather than guessed; cached in the workdir like the transcript.
@@ -1531,10 +2742,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1531
2742
  "render",
1532
2743
  ).start()
1533
2744
  : null;
1534
- const faceBox = await measureFace(tools, analysisInput, analysisProbe.duration, {
2745
+ const faceBox = await measureFace(tools, input, sourceProbe.duration, {
1535
2746
  cacheDir: work,
1536
- cropVf: analysisCropVf,
1537
- cacheTag,
2747
+ cropVf,
1538
2748
  samples: faceSamples,
1539
2749
  });
1540
2750
  if (faceSampleAnim) faceSampleAnim.stop();
@@ -1550,21 +2760,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1550
2760
  "the crop may be wrong, check the output",
1551
2761
  );
1552
2762
 
1553
- // A landscape source loses most of its width to the vertical frame, and how
1554
- // much is arithmetic, not opinion: cover-cropping displays the picture at
1555
- // `height × aspect` and keeps only the frame's width of it. Said out loud
1556
- // because the result LOOKS deliberate — a tight talking head — and nothing
1557
- // else in the run would mention that the desk, the screen and the second
1558
- // person are simply gone.
1559
- if (!landscape && opts.sourceFit !== "contain" && content.height > 0) {
1560
- const displayedW = frame.height * (content.width / content.height);
1561
- if (displayedW > frame.width * 1.05) {
1562
- console.log(
1563
- `▸ source is ${(content.width / content.height).toFixed(2)}:1 — a full-frame crop keeps ` +
1564
- `${((frame.width / displayedW) * 100).toFixed(0)}% of its width. ` +
1565
- "Use --source-fit contain to show the whole frame instead.",
1566
- );
1567
- }
2763
+ // Whatever the source's aspect doesn't share with the frame, a cover crop
2764
+ // trims — and how much is arithmetic, not opinion. Said out loud because
2765
+ // the result LOOKS deliberate — a tight talking head — and nothing else in
2766
+ // the run would mention that the desk, the screen and the second person
2767
+ // are simply gone. Orientation-neutral on purpose: the old `!landscape`
2768
+ // gate assumed a 16:9 output never crops a 16:9-ish source, and the
2769
+ // 2026-08-16 incident (a 1.547:1 screen recording in a 16:9 frame — 13% of
2770
+ // the height silently gone, 28% post-normalization) shipped without a word.
2771
+ const coverKeep = coverKeepFraction(content, frame);
2772
+ if (opts.sourceFit !== "contain" && coverKeep && coverKeep.kept < 0.95) {
2773
+ console.log(
2774
+ `▸ source is ${(content.width / content.height).toFixed(2)}:1 — a full-frame crop keeps ` +
2775
+ `${(coverKeep.kept * 100).toFixed(0)}% of its ${coverKeep.axis}. ` +
2776
+ "Use --source-fit contain to show the whole frame instead.",
2777
+ );
1568
2778
  }
1569
2779
 
1570
2780
  // ---- Route around the source's own burned-in text (FINDINGS §26) --------
@@ -1582,11 +2792,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1582
2792
  // face. Routing around a hazard only pays when there is a hazard, and only
1583
2793
  // the user knows whether their source is already edited.
1584
2794
  const sourceText = opts.sourceIsEdited
1585
- ? await scanSourceText(tools, analysisInput, analysisProbe.duration, {
2795
+ ? await scanSourceText(tools, input, sourceProbe.duration, {
1586
2796
  cacheDir: work,
1587
2797
  assumeEdited: true,
1588
- cropVf: analysisCropVf,
1589
- cacheTag,
2798
+ cropVf,
1590
2799
  })
1591
2800
  : { regions: [], assumed: false, framesSampled: 0 };
1592
2801
  if (sourceText.regions.length > 0) {
@@ -1666,7 +2875,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1666
2875
  if (hiddenIds.length > 0) {
1667
2876
  console.log(`▸ ${hiddenIds.length} scene(s) hidden by the edit layer: ${hiddenIds.join(", ")}`);
1668
2877
  }
1669
- const theme = resolveTheme(defaultTheme, overrideDoc);
2878
+ // Config theme as the BASE (F6): overrides.json > config theme >
2879
+ // defaultTheme. The same `configBaseTheme` feeds props.baseTheme below.
2880
+ const theme = resolveTheme(configBaseTheme, overrideDoc);
1670
2881
 
1671
2882
  // A pin freezes a scene's ABSOLUTE time against whatever its neighbours'
1672
2883
  // timing was when it was set. This same plan may since have re-anchored
@@ -1825,7 +3036,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1825
3036
  const sideDirs = isFolder ? [work, originalInput] : [work, dirname(input)];
1826
3037
  const renderPublicDirPath = planRenderPublicDir({
1827
3038
  input,
1828
- inputIsAnalysisInput: analysisInput === input,
3039
+ // Literal since the framing bake became render-props (2026-08-16): the
3040
+ // bake was the only path that ever analysed a file other than the input.
3041
+ // The parameter (and its platform matrix test) stays, because it encodes
3042
+ // the contract "a non-input analysis file must live in `work`" — the
3043
+ // thing any future re-introduction of such a file has to get right.
3044
+ inputIsAnalysisInput: true,
1829
3045
  mezzanineWillBuild,
1830
3046
  work,
1831
3047
  });
@@ -1952,11 +3168,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1952
3168
  // §128: same reasoning as §122's block above, for the flub the speaker did
1953
3169
  // NOT say a marker over — kept / cut (with similarity) / ignored as a
1954
3170
  // hallucination (with its silence fraction), in the words `report.txt`
1955
- // already trusts. Also the record `--collapse-retakes`'s opt-in default
1956
- // is promoted from: a clean run here is the evidence.
3171
+ // already trusts. Runs automatically with --blooper-marker (2026-08-16
3172
+ // gate decision, inferredRetakesEnabled); a clean run recorded here is
3173
+ // still the promotion evidence the §128 appendix asks for.
1957
3174
  if (retakeGroups.length > 0) {
1958
3175
  report +=
1959
- "\nretakes collapsed (--collapse-retakes — FINDINGS §128):\n" +
3176
+ "\nretakes collapsed (runs with --blooper-marker — FINDINGS §128):\n" +
1960
3177
  retakeGroups.map((g) => formatRetakeGroup(rawTranscript, g)).join("\n") +
1961
3178
  "\n";
1962
3179
  }
@@ -2030,6 +3247,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2030
3247
  // the fill's derived boundaries re-split caption lines would change
2031
3248
  // caption output for zero visual reason (PLAN Task A4.4).
2032
3249
  breakpoints: graphicCues.flatMap((c) => [c.startSec, c.endSec]),
3250
+ // Orientation-dependent packing (captionPackingFor has the budget math):
3251
+ // portrait gets the core defaults verbatim, landscape doubles them.
3252
+ ...captionPackingFor(landscape),
2033
3253
  });
2034
3254
  // §137 (Task 6 review, Critical 1): the caption half of a run — migrate the
2035
3255
  // doc's keys, apply what applies, and account for the rest — is one pure
@@ -2062,21 +3282,111 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2062
3282
  const captionKeysReanchored = captionWork.reanchored;
2063
3283
  for (const line of captionWork.log) console.log(line);
2064
3284
 
3285
+ // Const re-binding of the accepted plan so its narrowing survives into the
3286
+ // `framingTimeline` map closure below — `framingPlan` is a `let`, and TS
3287
+ // drops a `let`'s narrowing inside callbacks.
3288
+ const acceptedFramingPlan = framingPlan?.ok ? framingPlan : null;
3289
+ // Hoisted out of the props spread because TWO consumers need it: the
3290
+ // render-props emission below and the per-span subject mask both motion
3291
+ // drivers gate on. Skipped under `--source-fit contain` — contain shows the
3292
+ // WHOLE frame, and a framing plan's cover windows would fight it — so the
3293
+ // subject gate reads the same timeline the renderer will actually see.
3294
+ const framingTimeline: FramingSegment[] | null =
3295
+ acceptedFramingPlan && opts.sourceFit !== "contain"
3296
+ ? acceptedFramingPlan.segments.map(
3297
+ (s, i): FramingSegment => ({
3298
+ startSec: s.startSec,
3299
+ endSec: s.endSec,
3300
+ window: s.window,
3301
+ subject: acceptedFramingPlan.subject[i] ?? "screen",
3302
+ bias: acceptedFramingPlan.bias[i] ?? { x: 0.5, y: 0.5 },
3303
+ }),
3304
+ )
3305
+ : null;
3306
+ const jumpCutsMode = resolveJumpCuts(opts.jumpCuts);
3307
+ const globalSubject = faceSubject(faceBox);
3308
+ // ONE verdict array feeds BOTH motion drivers (spanFaceMask has the why) —
3309
+ // computed before either plan so neither can be built from a stale or
3310
+ // re-derived copy that disagrees with the other.
3311
+ //
3312
+ // 2026-08-16 v2 review: with NO framing plan (uniform content rects) the
3313
+ // old flat `spanFaceMask(…, null, globalSubject)` let the whole-take PiP
3314
+ // verdict speak for the full-frame face stretches inside a screen
3315
+ // recording, so those spans lost punch concealment and idle zoom — the
3316
+ // mask must be MEASURED per span instead (spanFaceMaskFromFaces has the
3317
+ // full why). Cached: `measureFaceInWindows` itself does not cache, and a
3318
+ // ~55-span take is a few hundred single-frame ffmpeg spawns.
3319
+ let spanIsFaceOnly: boolean[];
3320
+ if (framingTimeline) {
3321
+ spanIsFaceOnly = spanFaceMask(map.spans, framingTimeline, globalSubject);
3322
+ } else {
3323
+ const spanFaceCache = join(work, `face-spans-${spanFaceCacheKey(map.spans, hash)}.json`);
3324
+ let measured: boolean[] | null = null;
3325
+ if (existsSync(spanFaceCache)) {
3326
+ measured = z.array(z.boolean()).length(map.spans.length)
3327
+ .parse(JSON.parse(await readFile(spanFaceCache, "utf8")));
3328
+ } else {
3329
+ const maskAnim = isInteractive()
3330
+ ? new StageAnimator(
3331
+ "SUBJECT TRACKING",
3332
+ `Measuring who the subject is across ${map.spans.length} kept spans...`,
3333
+ "render",
3334
+ ).start()
3335
+ : null;
3336
+ try {
3337
+ const spanFaces = await measureFaceInWindows(
3338
+ tools,
3339
+ input,
3340
+ spanFaceWindows(map.spans),
3341
+ { workDir: work },
3342
+ );
3343
+ measured = spanFaceMaskFromFaces(spanFaces);
3344
+ await writeFile(spanFaceCache, JSON.stringify(measured));
3345
+ } catch (err) {
3346
+ // NEVER cache a FAILURE (§106) — and the punch/zoom are polish, not
3347
+ // the product, so a dead measurement falls back to the whole-take
3348
+ // verdict (the pre-2026-08-16 behavior) rather than killing the run.
3349
+ console.log(
3350
+ ` ⚠ per-span face measurement failed (${err instanceof Error ? err.message : String(err)})` +
3351
+ " — every span shares the whole-take verdict this run",
3352
+ );
3353
+ } finally {
3354
+ if (maskAnim) maskAnim.stop();
3355
+ }
3356
+ }
3357
+ spanIsFaceOnly = measured ?? spanFaceMask(map.spans, null, globalSubject);
3358
+ if (measured) {
3359
+ const faceSpans = measured.filter(Boolean).length;
3360
+ console.log(
3361
+ `▸ subject per span (measured): ${faceSpans} face-only, ` +
3362
+ `${measured.length - faceSpans} screen`,
3363
+ );
3364
+ }
3365
+ }
3366
+ // The jump-cut punch plan (Task 6): mode from the flag pair, gated per
3367
+ // span by who the subject is where that span BEGINS — the frame at the
3368
+ // cut is what the punch scales. Without a framing plan every span shares
3369
+ // the whole-take verdict, the same `face.subject` the stage bias reads.
3370
+ const punch = punchPlanFor(map.spans, jumpCutsMode, spanIsFaceOnly);
3371
+
2065
3372
  // Micro zoom punches (FINDINGS §15) reversing at real phrase breaks (§18).
2066
3373
  // Breaths are source-time; TimeMap has no span mapper, so both ends go
2067
3374
  // through toOutputClamped — a pause that was cut collapses to one instant,
2068
3375
  // which is still a boundary (a jump cut is a phrase break too).
2069
3376
  // One move per cut-free clip: ramp in, then hold. The clip starts ARE the
2070
3377
  // cuts — every point the source jumps — so a take that removed nothing is
2071
- // one clip and gets exactly one slow push.
3378
+ // one clip and gets exactly one slow push. Face-only since 2026-08-16
3379
+ // (same mask as the punch): a screen-subject clip gets NO push at all.
2072
3380
  const zoomOff = opts.zoom === false;
2073
3381
  const zoom = buildZoomPlan(map.outputDuration, {
2074
3382
  clipStarts: map.spans.map((s) => s.outIn),
3383
+ allowedClips: spanIsFaceOnly,
2075
3384
  });
2076
3385
  console.log(
2077
3386
  zoomOff
2078
3387
  ? "▸ zoom: off (--no-zoom) — static camera; jump cuts land unconcealed"
2079
- : `▸ zoom: ${zoom.clips} clip(s), ${zoom.rampSec}s push then hold ` +
3388
+ : `▸ zoom: ${zoom.zoomedClips} clip(s) zoomed, ${zoom.staticClips} static ` +
3389
+ `(screen subject), ${zoom.rampSec}s push then hold ` +
2080
3390
  `(${zoom.segments.length} segments)`,
2081
3391
  );
2082
3392
 
@@ -2101,7 +3411,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2101
3411
  // same head-fits rule would report a defect for working as designed.
2102
3412
  const issues = assessCueFraming(
2103
3413
  graphicCues.flatMap((c) => {
2104
- const v = layoutSlots(c.layout).video;
3414
+ // `frame` must reach layoutSlots, not just the pixel multiply below —
3415
+ // it defaults to PORTRAIT_FRAME, and the R15 split layouts change
3416
+ // geometry with orientation (split-left: {w:1, h:0.5} stacked in
3417
+ // portrait, {w:0.5, h:1} side panel in landscape), so omitting it
3418
+ // judged 16:9 cues against portrait slot shapes. Latent since R15
3419
+ // landscape support; see layoutSlotAspects for the twin brief-side bug.
3420
+ const v = layoutSlots(c.layout, DEFAULT_FACE, [], frame).video;
2105
3421
  if (v.opacity <= 0 || v.rect.w * v.rect.h < PRIMARY_VIDEO_SLOT_AREA) return [];
2106
3422
  return [{
2107
3423
  id: c.id,
@@ -2133,20 +3449,35 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2133
3449
  }
2134
3450
  }
2135
3451
 
2136
- let renderVideo = analysisInput;
3452
+ let renderVideo = input;
2137
3453
  // A letterboxed source MUST go through the re-encode even under
2138
3454
  // --no-mezzanine: the bars are pixels in the file, and cropping them here is
2139
3455
  // what lets every layout and zoom downstream treat the picture as the frame.
2140
3456
  // The cropped file gets its own name so a pre-crop cache is never reused.
2141
- // A NORMALIZED source skips this outright: the bake already carries the
2142
- // mezzanine's encode settings, and re-encoding it would be a second
2143
- // generation of loss for nothing.
2144
3457
  // `mezzanineWillBuild` (computed once, above, with `contentRect`) — not a
2145
3458
  // second copy of this condition — so this can't drift from what
2146
3459
  // `planRenderPublicDir` already decided the accepted-image check against
2147
3460
  // (Finding 3, final-review fix wave).
3461
+ // Display-sized mezzanine (2026-08-17 render-speed pass): computed on the
3462
+ // POST-CROP picture (the crop runs first in the same ffmpeg pass, so
3463
+ // `contentRect` IS what the scale filter sees) against the OUTPUT
3464
+ // frame+fps. Deliberately null when no mezzanine will build (--no-mezzanine
3465
+ // on a bar-free source): there is no re-encode to scale, the render plays
3466
+ // the source itself, and the window emissions below must then stay in true
3467
+ // source pixels — which the identity `mezzFactor` below guarantees.
3468
+ const mezzScale = mezzanineWillBuild
3469
+ ? mezzanineScale(
3470
+ { width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
3471
+ production.render,
3472
+ opts.sourceFit ?? "cover",
3473
+ )
3474
+ : null;
2148
3475
  if (mezzanineWillBuild) {
2149
- const mezz = join(work, contentRect.full ? "mezzanine.mp4" : "mezzanine-content.mp4");
3476
+ // The scale decision rides the FILENAME (`mezzanineFileName` has the
3477
+ // why): mezzanine caching is existence-keyed, so a pre-pass full-res
3478
+ // mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
3479
+ // windows — the scaled file rebuilds once under its own name.
3480
+ const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale));
2150
3481
  if (!existsSync(mezz)) {
2151
3482
  const mezzAnim = isInteractive()
2152
3483
  ? new StageAnimator(
@@ -2164,11 +3495,41 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2164
3495
  : "▸ building mezzanine (dense keyframes, letterbox bars trimmed)…",
2165
3496
  );
2166
3497
  }
2167
- await makeMezzanine(tools, input, mezz, { cropVf: cropVf || undefined });
3498
+ await makeMezzanine(tools, input, mezz, {
3499
+ cropVf: cropVf || undefined,
3500
+ scale: mezzScale ?? undefined,
3501
+ });
2168
3502
  if (mezzAnim) mezzAnim.stop();
2169
3503
  }
3504
+ if (mezzScale) {
3505
+ console.log(
3506
+ `▸ mezzanine: ${contentRect.w}x${contentRect.h}@${Math.round(sourceProbe.fps)} → ` +
3507
+ `${mezzScale.width}x${mezzScale.height}@${Math.round(mezzScale.fps)} ` +
3508
+ `(render-sized — decode is the render bottleneck)`,
3509
+ );
3510
+ }
2170
3511
  renderVideo = mezz;
2171
3512
  }
3513
+ // Window space must equal PLAYED-FILE space: the renderer's crop math
3514
+ // (`contentCoverBox` et al.) positions windows against the file it plays,
3515
+ // so a scaled mezzanine needs every pixel-space emission below scaled by
3516
+ // the same factor. Derived from the actual scaled dims — per axis, because
3517
+ // yuv420 even-rounding makes the two ratios differ by a hair — and
3518
+ // identity whenever the render plays an unscaled file (no mezzanine, or a
3519
+ // source already at display size). `playedFullFrame` is the matching
3520
+ // `sourceSize`: the framing/fit paths only ever fire with a FULL-frame
3521
+ // mezzanine (mixed framing ⇒ no uniform crop), so its base is the source's
3522
+ // own dims. Face fractions, `sourceAspect` and `sourceTextRegions` are
3523
+ // ratios/fractions — scale-invariant, untouched. The Premiere project
3524
+ // export stays in TRUE source space by construction: it cuts the ORIGINAL
3525
+ // file (`production.source`, path + probe) and consumes only seconds and
3526
+ // scales from render-props (spans, zoomPlan, punch), never these windows.
3527
+ const mezzFactor = mezzScale
3528
+ ? { x: mezzScale.width / contentRect.w, y: mezzScale.height / contentRect.h }
3529
+ : { x: 1, y: 1 };
3530
+ const playedFullFrame = mezzScale
3531
+ ? { width: mezzScale.width, height: mezzScale.height }
3532
+ : null;
2172
3533
 
2173
3534
  // Comment-CTA keyword (FINDINGS §16), scoped to the ask (FINDINGS §22).
2174
3535
  // Read off the timed CUE, not the untimed scene: the cue carries the same
@@ -2191,6 +3552,33 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2191
3552
  );
2192
3553
  }
2193
3554
 
3555
+ // Bundled Nastaliq for RTL captions (2026-08-17): the render must not
3556
+ // depend on the machine having an Arabic-script font — a Linux box has
3557
+ // none, and macOS/Windows each substitute a different one, so identical
3558
+ // render-props drew three different Urdu caption sets. Gated on the SAME
3559
+ // predicate CaptionTrack keys its @font-face on (`captionsNeedNastaliq`),
3560
+ // so pure-Latin runs copy nothing and render byte-identically. Staged into
3561
+ // the render's public dir AND the workdir when they differ (a
3562
+ // --no-mezzanine file run serves the render from the source's own folder,
3563
+ // but `ossclip edit` serves from the workdir — program.ts's
3564
+ // `dirname(propsPath)` — and both mounts fetch the same served URL).
3565
+ // `join` is correct here where NASTALIQ_FONT_REL itself must stay
3566
+ // POSIX-literal: these are filesystem paths, the REL is the served URL
3567
+ // (sideImageDestRel's Windows lesson).
3568
+ if (!captionsHidden && captionsNeedNastaliq(captionLines)) {
3569
+ const fontSrc = nastaliqFontFile();
3570
+ for (const dir of new Set([renderPublicDirPath, work])) {
3571
+ const dest = join(dir, NASTALIQ_FONT_REL);
3572
+ if (!existsSync(dest)) {
3573
+ mkdirSync(dirname(dest), { recursive: true });
3574
+ copyFileSync(fontSrc, dest);
3575
+ }
3576
+ }
3577
+ console.log(
3578
+ `▸ captions: RTL lines detected — bundled ${NASTALIQ_FONT_NAME} staged as ${NASTALIQ_FONT_REL}`,
3579
+ );
3580
+ }
3581
+
2194
3582
  const ctaCue = [...graphicCues]
2195
3583
  .reverse()
2196
3584
  .find((c) => typeof c.props?.keyword === "string" && (c.props.keyword as string).length > 0);
@@ -2236,7 +3624,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2236
3624
  // editing session would have nothing to fall back to and render as if
2237
3625
  // it never happened, even though `overrides.json` on disk is correct.
2238
3626
  baseSceneCues: routed.cues,
2239
- baseTheme: defaultTheme,
3627
+ // The CONFIG base, not defaultTheme (F6): the editor re-applies its
3628
+ // overrides onto this, so a theme reset there must land on the user's
3629
+ // global colors — falling to factory defaults would silently discard
3630
+ // ~/.ossclip/config.json's theme the first time anyone touched a color.
3631
+ baseTheme: configBaseTheme,
2240
3632
  baseCaptionLines,
2241
3633
  settings: production.render,
2242
3634
  outputDurationSec: map.outputDuration,
@@ -2250,8 +3642,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2250
3642
  centerXFrac: faceBox.centerXFrac,
2251
3643
  sizeFrac: faceBox.sizeFrac,
2252
3644
  // The CONTENT's shape, not the container's — with bars trimmed the
2253
- // rendered video IS the content rect (PLAN Task 7).
3645
+ // rendered video IS the content rect (PLAN Task 7). Since the
3646
+ // framing bake became props (2026-08-16), this is always the RAW
3647
+ // source's picture: `content` derives from `sourceProbe`, never
3648
+ // from a re-encoded canvas, so a framing plan no longer distorts
3649
+ // the aspect the stage crops against.
2254
3650
  sourceAspect: content.height > 0 ? content.width / content.height : undefined,
3651
+ // Whether the face IS the subject, by the same rule the framing
3652
+ // plan applies per segment. 2026-08-16 incident: the global
3653
+ // 9-sample median landed on the camera PiP and pinned objectPosY
3654
+ // to 1.0, decapitating the speaker at the top of the frame — a
3655
+ // PiP-sized face must not steer the cover. Absent (old props)
3656
+ // means "face", so pre-existing render-props render unchanged.
3657
+ subject: faceSubject(faceBox),
2255
3658
  }
2256
3659
  : null,
2257
3660
  // Emptied, not flattened-to-1: a plan of flat segments still reads as "a
@@ -2265,25 +3668,57 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2265
3668
  ctaKeyword,
2266
3669
  ctaWindow,
2267
3670
  sourceTextRegions: textRegions,
2268
- // Sent ONLY on the fit fallback (option (b)): a normalized mixed source is
2269
- // already one uniform file, and a uniform source had its bars cropped into
2270
- // the mezzanine — cropping either again at render time would eat the
2271
- // picture twice.
3671
+ // Sent ONLY on the fit fallback (option (b)): a plan-framed mixed source
3672
+ // carries its windows in `framingTimeline` below, and a uniform source
3673
+ // had its bars cropped into the mezzanine — cropping either again at
3674
+ // render time would eat the picture twice. Rects and sourceSize are in
3675
+ // PLAYED-FILE pixels (`mezzFactor`/`playedFullFrame` above): the renderer
3676
+ // windows the file it plays, which a display-sized mezzanine has resampled.
2272
3677
  ...(fitFallback
2273
3678
  ? {
2274
- contentTimeline,
2275
- sourceSize: { width: sourceProbe.width, height: sourceProbe.height },
3679
+ contentTimeline: scaleContentTimeline(contentTimeline, mezzFactor),
3680
+ sourceSize:
3681
+ playedFullFrame ?? { width: sourceProbe.width, height: sourceProbe.height },
2276
3682
  contentCropMode: "fit" as const,
2277
3683
  }
2278
3684
  : {}),
3685
+ // The accepted framing plan, as DATA (2026-08-16 incident: the bake this
3686
+ // replaces was irreversible — deleting the re-encoded content-<hash>.mp4
3687
+ // was the only remedy — and it suppressed these very keys, so the editor
3688
+ // could not even see the crop). Mutually exclusive with the fit-fallback
3689
+ // spread by construction (`fitFallback` ⇔ `!plan.ok`), so `sourceSize`
3690
+ // is emitted by exactly one of them. Skipped under `--source-fit
3691
+ // contain`: contain shows the WHOLE frame, and a framing plan's cover
3692
+ // windows would fight it — the explicit flag wins over the inferred plan.
3693
+ ...(framingTimeline
3694
+ ? {
3695
+ // The plan's windows are TRUE source pixels (planNormalization
3696
+ // analyses the source); scaled here, at emission, into the pixel
3697
+ // space of the file the render plays — a display-sized mezzanine
3698
+ // resamples that space by `mezzFactor` (identity when unscaled).
3699
+ framingTimeline: scaleFramingWindows(framingTimeline, mezzFactor),
3700
+ // The PLAYED file's size — the scaled windows are in its pixels.
3701
+ sourceSize:
3702
+ playedFullFrame ?? { width: sourceProbe.width, height: sourceProbe.height },
3703
+ }
3704
+ : {}),
3705
+ // ALWAYS written, never absent-when-default like the flags around it:
3706
+ // an ABSENT `punch` is the LEGACY contract — EdlVideo's 1.07 punch on
3707
+ // every alternating span — kept so every pre-feature render-props.json
3708
+ // renders byte-identical to what it always did. Presence, even an
3709
+ // all-false "off" mask, is what opts a render into the face-only 1.015
3710
+ // behavior (punchPlanFor has the guard's why).
3711
+ punch,
2279
3712
  // `--source-fit contain`: show the whole frame instead of cropping it.
2280
3713
  // The size sent is the PICTURE's, not the container's — with bars trimmed
2281
3714
  // into the mezzanine the rendered video IS the content rect, and fitting
2282
3715
  // against the container's shape would inset a frame that no longer exists.
2283
3716
  // Listed after the fit fallback so it wins on a source that is both mixed
2284
- // and asked to be shown whole.
3717
+ // and asked to be shown whole. `playedFullFrame` when the mezzanine is
3718
+ // display-sized: it is that same picture, post-resample — the file the
3719
+ // renderer fits.
2285
3720
  ...(opts.sourceFit === "contain"
2286
- ? { sourceFit: "contain" as const, sourceSize: content }
3721
+ ? { sourceFit: "contain" as const, sourceSize: playedFullFrame ?? content }
2287
3722
  : {}),
2288
3723
  // Written only when ON, matching the field's absent-means-off contract:
2289
3724
  // an off run's render-props.json stays byte-identical to a pre-watermark
@@ -2344,7 +3779,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2344
3779
  // recovered from (`legacySplitId`). Repairing the captions would destroy the
2345
3780
  // evidence for the split. `writeOverrideDoc` carries the full argument for
2346
3781
  // why a caption-only write has nothing worth backing up.
2347
- 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) {
2348
3789
  await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
2349
3790
  console.log(overridesWriteLine(cutResult.changed));
2350
3791
  }
@@ -2365,13 +3806,124 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2365
3806
  };
2366
3807
  }
2367
3808
 
2368
- const outPath = opts.out
2369
- ? isAbsolute(opts.out)
2370
- ? opts.out
2371
- : resolve(baseCwd, opts.out)
2372
- : resolve(defaultOutPath(originalInput));
3809
+ // outPath was resolved (and its parent healed) at produce start — see the
3810
+ // 2026-08-16 fail-fast block up top.
2373
3811
  const rawPath = join(work, "render-raw.mp4");
2374
3812
  const interactive = isInteractive();
3813
+
3814
+ // ---- Thumbnail concept approval (thumbnail UX, 2026-08-16) --------------
3815
+ // BEFORE the render kickoff, after scene planning: the concept is the one
3816
+ // creative judgement the user previously only discovered after a
3817
+ // multi-minute render. Interactive runs approve (or edit, or skip) it
3818
+ // here; the file it writes is what thumbnailStep honors after render — and
3819
+ // what a non-TTY replay (the editor's Render) reuses, which is the whole
3820
+ // persistence story.
3821
+ //
3822
+ // Resolved HERE rather than at the pack section below so the gate and the
3823
+ // post-render consumers read one answer. Typed-beats-config, `typeof` not
3824
+ // truthiness (the `portrait` posture): config.json is hand-edited and
3825
+ // unparsed, and a `"audience": true` typo must resolve to "no audience".
3826
+ const youtube = resolveYoutube(opts.youtube, cfg.youtube);
3827
+ // resolvePortrait (portrait-override.ts) carries the expandHome treatment
3828
+ // of the flag and config paths, and puts the workdir's portrait-override
3829
+ // ABOVE both (editor face swap, 2026-08-17): a per-project expression
3830
+ // chosen in the editor must survive CLI re-renders — the flag/config
3831
+ // portrait is the fallback headshot, and a replay silently reverting the
3832
+ // swapped face would undo the one thing the swap exists for.
3833
+ const portrait = resolvePortrait({
3834
+ overridePath: portraitOverridePath(work),
3835
+ flagPortrait: opts.portrait,
3836
+ cfgPortrait: cfg.portrait,
3837
+ })?.path;
3838
+ const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
3839
+ const thumbnailBrief =
3840
+ opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
3841
+ const geminiKey = process.env.GEMINI_API_KEY;
3842
+ const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
3843
+ // The gate reuses thumbnailDecision (plus the mime check) so the prompt
3844
+ // never asks about a thumbnail the post-render step would skip anyway.
3845
+ const thumbnailWouldGenerate =
3846
+ provider != null &&
3847
+ portrait !== undefined &&
3848
+ thumbnailDecision(
3849
+ youtube,
3850
+ portrait,
3851
+ geminiKey !== undefined && geminiKey !== "",
3852
+ existsSync(portrait),
3853
+ ) === "generate" &&
3854
+ portraitMimeType(portrait) !== undefined;
3855
+ if (interactive && thumbnailWouldGenerate) {
3856
+ if (existsSync(approvedConceptPath)) {
3857
+ // A decision already on file IS the answer — re-asking a question the
3858
+ // user settled would make every warm re-run nag. The line names the
3859
+ // escape hatch instead.
3860
+ console.log(
3861
+ `▸ thumbnail: concept already decided (${THUMBNAIL_APPROVED_BASENAME} — delete it to revisit)`,
3862
+ );
3863
+ } else {
3864
+ // Seed from the concept cache when this exact steer was asked before
3865
+ // (a prior non-TTY run) — no titleAngle: the pack generates after
3866
+ // render, so the pre-render call cannot carry it and passes the hook
3867
+ // instead (see generateConcept below).
3868
+ const conceptCache = join(
3869
+ work,
3870
+ thumbnailConceptCacheName({
3871
+ providerName,
3872
+ llmModel: opts.llmModel,
3873
+ intent: opts.intent,
3874
+ hook: beatSheet?.hook,
3875
+ audience,
3876
+ brief: thumbnailBrief,
3877
+ transcriptWords: transcript.words.map((w) => w.text),
3878
+ }),
3879
+ );
3880
+ const initial = existsSync(conceptCache)
3881
+ ? ThumbnailConceptSchema.parse(JSON.parse(await readFile(conceptCache, "utf8")))
3882
+ : undefined;
3883
+ try {
3884
+ const approved = await approveThumbnailConcept({
3885
+ initial,
3886
+ generateConcept: async (note) => {
3887
+ const fresh = await phases.time("llm", () =>
3888
+ generateThumbnailConcept(provider!, {
3889
+ hook: beatSheet?.hook,
3890
+ intent: opts.intent,
3891
+ audience,
3892
+ brief: thumbnailBrief,
3893
+ note,
3894
+ transcriptText: transcript.words.map((w) => w.text).join(" "),
3895
+ }),
3896
+ );
3897
+ // The §35 word cap, thumbnailStep's exact treatment — approved
3898
+ // text must be the text the image is prompted with.
3899
+ return { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
3900
+ },
3901
+ });
3902
+ await writeFile(approvedConceptPath, JSON.stringify(approved, null, 2));
3903
+ } catch (err) {
3904
+ // A concept-call failure must not block the render the user is
3905
+ // waiting on (§112 posture) — no approved file is written, and the
3906
+ // post-render step retries the concept on its own.
3907
+ console.log(
3908
+ `▸ thumbnail: concept approval unavailable (${err instanceof Error ? err.message : String(err)}) ` +
3909
+ "— the post-render step will try again",
3910
+ );
3911
+ }
3912
+ }
3913
+ }
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);
2375
3927
  let renderHud: RenderTimelineHUD | null = null;
2376
3928
  if (interactive) {
2377
3929
  renderHud = new RenderTimelineHUD({
@@ -2381,27 +3933,109 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2381
3933
  aspect: landscape ? "16:9" : "9:16",
2382
3934
  }).start();
2383
3935
  } else {
2384
- console.log("▸ rendering…");
3936
+ console.log(`▸ rendering… (${renderConcurrency.concurrency} parallel tabs)`);
2385
3937
  }
2386
3938
  let lastPct = -10;
2387
- await phases.time("render", () =>
2388
- renderProduction(props, {
2389
- publicDir: dirname(renderVideo),
2390
- outPath: rawPath,
2391
- browserExecutable: cfg.browserExecutable,
2392
- onProgress: (p) => {
2393
- if (renderHud) {
2394
- renderHud.setProgress(p);
2395
- } else {
2396
- const pct = Math.floor(p * 100);
2397
- if (pct >= lastPct + 10) {
2398
- lastPct = pct;
2399
- 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
+ }
2400
4011
  }
2401
- }
2402
- },
2403
- }),
2404
- );
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);
2405
4039
  if (renderHud) renderHud.stop();
2406
4040
 
2407
4041
  const masterAnim = interactive
@@ -2415,7 +4049,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2415
4049
  const normPath = join(work, "render-norm.mp4");
2416
4050
  await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
2417
4051
  if (masterAnim) masterAnim.stop();
2418
- await rename(normPath, outPath);
4052
+ // moveFile, not fs rename: an --out on another volume (external drive)
4053
+ // throws EXDEV at the very end of the run — the sibling trap to the
4054
+ // ENOENT ensureParentDir prevents upfront (paths.ts).
4055
+ await moveFile(normPath, outPath);
2419
4056
 
2420
4057
  // ---- Cover image (FINDINGS §31) -----------------------------------------
2421
4058
  // A separate file, not a burned-in intro: both platforms accept a custom
@@ -2432,9 +4069,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2432
4069
  const cover = coverDecision(opts.cover !== false, coverText);
2433
4070
  if (cover !== "none") {
2434
4071
  const detector = await createFaceDetector();
2435
- const pick = await pickCoverFrame(tools, analysisInput, analysisProbe.duration, {
4072
+ const pick = await pickCoverFrame(tools, input, sourceProbe.duration, {
2436
4073
  cacheDir: work,
2437
- cropVf: analysisCropVf,
4074
+ cropVf,
4075
+ // On a screen-subject take the face weight is zeroed (2026-08-16: a
4076
+ // Facebook reel face visible IN the screen recording won the cover
4077
+ // — scoreCandidate has the incident). Same whole-take verdict the
4078
+ // stage bias and the span mask fallback read.
4079
+ subject: faceSubject(faceBox),
2438
4080
  detectFace: (pixels, w, h) => {
2439
4081
  const d = detector(pixels, w, h);
2440
4082
  // pico returns [row, col, size, score] in detection-frame pixels,
@@ -2450,13 +4092,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2450
4092
  await run(cfg.ffmpegPath, [
2451
4093
  "-v", "error",
2452
4094
  "-ss", pick.timeSec.toFixed(3),
2453
- "-i", analysisInput,
4095
+ "-i", input,
2454
4096
  "-frames:v", "1",
2455
- "-vf", `${analysisCropVf ? `${analysisCropVf},` : ""}scale=${frame.width}:${frame.height}:force_original_aspect_ratio=increase,crop=${frame.width}:${frame.height}`,
4097
+ "-vf", `${cropVf ? `${cropVf},` : ""}scale=${frame.width}:${frame.height}:force_original_aspect_ratio=increase,crop=${frame.width}:${frame.height}`,
2456
4098
  "-y", join(work, frameName),
2457
4099
  ]);
4100
+ // expandHome on the user half only — the artifactPath default derives
4101
+ // from the already-expanded outPath (2026-08-16, paths.ts).
2458
4102
  const coverPath = resolve(
2459
- opts.coverPath ?? outPath.replace(/(\.[^.]+)?$/, ".cover.jpg"),
4103
+ opts.coverPath !== undefined
4104
+ ? expandHome(opts.coverPath)
4105
+ : artifactPath(outPath, ".cover.jpg"),
2460
4106
  );
2461
4107
  // The §34 dedupe check and the band-placement log exist only to
2462
4108
  // route a banner around the frame's contents — a textless cover
@@ -2497,6 +4143,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2497
4143
  {
2498
4144
  frameFileName: frameName,
2499
4145
  text: bannerText,
4146
+ // The RESOLVED theme — so the cover's banner already carries the
4147
+ // config theme (F6) via resolveTheme's base, no separate wiring.
2500
4148
  theme,
2501
4149
  face: pick.face,
2502
4150
  // The cover is the OUTPUT's thumbnail — a landscape render gets a
@@ -2510,6 +4158,148 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2510
4158
  }
2511
4159
  }
2512
4160
  }
4161
+
4162
+ // ---- YouTube pack (Y2, 2026-08-16) --------------------------------------
4163
+ // AFTER the cover block and additive to it: the pack never changes the
4164
+ // video or the cover, it only writes siblings — so a failure here degrades
4165
+ // to "no metadata file" on a render that already succeeded, never a dead
4166
+ // run (§112 posture). `youtube`/`portrait`/`audience`/`thumbnailBrief`
4167
+ // were resolved before the render kickoff — the concept-approval gate and
4168
+ // this section must read one answer.
4169
+ let youtubeMdPath: string | undefined;
4170
+ let thumbnailPath: string | undefined;
4171
+ // The pack's FIRST title, when it generated — the thumbnail concept's
4172
+ // titleAngle, so thumbnail and title tell one story. (The pre-render
4173
+ // approval could not carry it: the pack writes here, after render.)
4174
+ let packTitle: string | undefined;
4175
+ if (youtube) {
4176
+ // The approved file wins outright (readApprovedYoutubePack): no cache
4177
+ // lookup, no LLM call — and no provider needed, so an edited pack still
4178
+ // writes its markdown on a --youtube run that carries no --produce.
4179
+ let pack: YoutubePack | undefined = await readApprovedYoutubePack(work);
4180
+ if (pack) {
4181
+ console.log(
4182
+ "▸ youtube: metadata from your edited pack " +
4183
+ `(delete ${YOUTUBE_APPROVED_BASENAME} to regenerate)`,
4184
+ );
4185
+ } else if (!provider) {
4186
+ // The metadata call rides the run's LLM provider; a run without one
4187
+ // (no --produce) has nothing to call. Said out loud rather than
4188
+ // silently — the user typed/configured --youtube. The thumbnail (Y3)
4189
+ // is a separate API keyed by GEMINI_API_KEY and still attempts.
4190
+ console.log("▸ youtube: metadata needs an LLM provider — skipped (thumbnail unaffected)");
4191
+ } else {
4192
+ // Beat-sheet cache shape: keyed on everything that changes the answer —
4193
+ // who is asked, with what editorial steer, about which words.
4194
+ const packKey = createHash("sha1")
4195
+ .update(
4196
+ JSON.stringify([
4197
+ // Prompt changes change the answer (the §78 posture): the v2
4198
+ // rewrite must not serve a pack cached under v1's questions.
4199
+ YOUTUBE_PROMPT_VERSION,
4200
+ providerName,
4201
+ opts.llmModel ?? "",
4202
+ opts.intent ?? "",
4203
+ // Steer, so part of the key — a changed audience is a different
4204
+ // pack, not a cache hit.
4205
+ audience ?? "",
4206
+ transcript.words.map((w) => w.text),
4207
+ // The stamped transcript's [m:ss] marks come from the CUT MAP,
4208
+ // not the words — a re-cut with identical words moves every
4209
+ // chapter stamp, and a word-only key would serve the stale
4210
+ // chapters (v2 review gap, 2026-08-17). Spans, rounded to ms,
4211
+ // pin the timeline the stamps were computed on.
4212
+ map.spans.map((s) => [Math.round(s.srcIn * 1000), Math.round(s.outIn * 1000)]),
4213
+ ]),
4214
+ )
4215
+ .digest("hex")
4216
+ .slice(0, 8);
4217
+ const packCache = join(work, `youtube-${packKey}.json`);
4218
+ if (existsSync(packCache)) {
4219
+ pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
4220
+ console.log("▸ youtube: metadata cached");
4221
+ } else {
4222
+ try {
4223
+ pack = await phases.time("llm", () =>
4224
+ generateYoutubePack(provider!, {
4225
+ // Sentence lines stamped with OUTPUT-clock times (prompt v2):
4226
+ // the words carry SOURCE seconds and `map` translates them, so
4227
+ // the chapters the model returns are measured, not guessed —
4228
+ // the one thing a paste-a-transcript prompt tool cannot do.
4229
+ transcriptText: stampedTranscript(transcript.words, map),
4230
+ intent: opts.intent,
4231
+ hook: beatSheet?.hook,
4232
+ coverText: beatSheet?.coverText,
4233
+ audience,
4234
+ durationSec: map.outputDuration,
4235
+ }),
4236
+ );
4237
+ await writeFile(packCache, JSON.stringify(pack, null, 2));
4238
+ } catch (err) {
4239
+ // NEVER cache a failure (§106), and never fail the produce that
4240
+ // just rendered over a metadata sidecar: one loud line, the video
4241
+ // and cover stand, the next run retries the call.
4242
+ console.log(
4243
+ ` ⚠ youtube metadata unavailable: ${err instanceof Error ? err.message : String(err)}\n` +
4244
+ " (not cached — the next run retries the pass)",
4245
+ );
4246
+ }
4247
+ }
4248
+ }
4249
+ if (pack) {
4250
+ youtubeMdPath = artifactPath(outPath, ".youtube.md");
4251
+ await writeFile(youtubeMdPath, formatYoutubeMarkdown(pack));
4252
+ console.log(`✓ youtube pack → ${youtubeMdPath}`);
4253
+ packTitle = pack.titles[0];
4254
+ }
4255
+ // ---- AI thumbnail (Y3, 2026-08-16) ------------------------------------
4256
+ // Shares the gate and `portrait` above but not the provider's KEY — its
4257
+ // credential is GEMINI_API_KEY, env-only (secrets never in config.json,
4258
+ // env.ts:7-9 rule; env-file loading already ran at CLI entry), so a
4259
+ // metadata skip must not skip it. The concept call does still need the
4260
+ // run's text provider; thumbnailStep says so out loud when it's absent.
4261
+ // Consumer-side validation, the `portrait` posture above: config.json
4262
+ // is hand-edited and unparsed, so a non-string `thumbnailModel` falls
4263
+ // back to the default rather than reaching the API as garbage.
4264
+ const thumbnailModel =
4265
+ typeof cfg.thumbnailModel === "string" ? cfg.thumbnailModel : THUMBNAIL_MODEL_DEFAULT;
4266
+ const thumbnail = await thumbnailStep({
4267
+ youtube,
4268
+ portraitPath: portrait,
4269
+ apiKey: geminiKey,
4270
+ model: thumbnailModel,
4271
+ work,
4272
+ outPath,
4273
+ // The run's provider is `LlmProvider | null`; the step's "absent" is
4274
+ // undefined, matching thumbnailDecision's optional-argument shape.
4275
+ provider: provider ?? undefined,
4276
+ providerName,
4277
+ llmModel: opts.llmModel,
4278
+ intent: opts.intent,
4279
+ hook: beatSheet?.hook,
4280
+ audience,
4281
+ brief: thumbnailBrief,
4282
+ titleAngle: packTitle,
4283
+ transcriptWords: transcript.words.map((w) => w.text),
4284
+ time: (fn) => phases.time("llm", fn),
4285
+ });
4286
+ thumbnailPath = thumbnail?.path;
4287
+ if (thumbnail && interactive && geminiKey) {
4288
+ // Post-generation retry (thumbnail UX, 2026-08-16): the image call is
4289
+ // seconds where the render was minutes, so an unwanted result is cheap
4290
+ // to redo NOW — the concept stays fixed, only the image re-rolls with
4291
+ // the user's note.
4292
+ await thumbnailRetryLoop({
4293
+ imagePath: thumbnail.path,
4294
+ imageCachePath: thumbnail.imageCachePath,
4295
+ concept: thumbnail.concept,
4296
+ apiKey: geminiKey,
4297
+ model: thumbnailModel,
4298
+ portrait: thumbnail.portrait,
4299
+ generate: generateThumbnailImage,
4300
+ });
4301
+ }
4302
+ }
2513
4303
  // Record THIS invocation so the editor's Render button can replay it (R11
2514
4304
  // Task 4). Nothing else can reconstruct it — production.json has the
2515
4305
  // source path, cleanup and intent, but not --produce, --out or the LLM
@@ -2550,11 +4340,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2550
4340
  // because the EDITOR hid them would freeze an edit the user may later
2551
4341
  // undo in that same editor. See recordedProduceArgs for why the pin is
2552
4342
  // unconditional even though captions' default is config-independent today.
4343
+ // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4344
+ // argv — "auto" has no flag spelling and stays unpinned (see
4345
+ // recordedProduceArgs for why that is safe today).
4346
+ // Youtube pin: the watermark's config-dependent-default rationale exactly —
4347
+ // resolved both ways, so a later config edit can't flip what Render
4348
+ // replays. Portrait and dictionary pin the RESOLVED values (a path and
4349
+ // terms, never a secret) for the same reason; recordedProduceArgs owns
4350
+ // the non-empty/includes guards.
2553
4351
  const recordedArgs = recordedProduceArgs({
2554
4352
  llm: provider ? providerName : undefined,
2555
4353
  clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
2556
4354
  watermark,
2557
4355
  captions: opts.captions ?? true,
4356
+ jumpCuts: jumpCutsMode,
4357
+ dictionary,
4358
+ youtube,
4359
+ portrait,
4360
+ audience,
4361
+ thumbnailBrief,
2558
4362
  });
2559
4363
  await writeFile(
2560
4364
  join(work, "command.json"),
@@ -2579,7 +4383,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2579
4383
  if (isInteractive()) {
2580
4384
  printProductionCompleteBanner({
2581
4385
  outPath,
2582
- coverPath: opts.cover !== false ? (typeof opts.cover === "string" ? opts.cover : outPath.replace(/(\.[^.]+)?$/, ".cover.jpg")) : undefined,
4386
+ // `opts.coverPath ?? artifactPath(...)`, matching the cover write above.
4387
+ // The old check here was `typeof opts.cover === "string"` — stale since
4388
+ // the cover/coverPath split, so an explicit --cover <path> banner'd the
4389
+ // default path instead of the file actually written.
4390
+ coverPath: opts.cover !== false ? opts.coverPath ?? artifactPath(outPath, ".cover.jpg") : undefined,
4391
+ youtubePath: youtubeMdPath,
4392
+ thumbnailPath,
2583
4393
  sourceDurationSec: sourceProbe.duration,
2584
4394
  outputDurationSec: map.outputDuration,
2585
4395
  sceneCount: scenes.length,