ossclip 0.1.23 → 0.1.25

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,8 @@
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";
3
+ import { copyFile, mkdir, readFile, writeFile, rm } from "node:fs/promises";
4
4
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "node:fs";
5
+ import { cpus } from "node:os";
5
6
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
6
7
  import { z } from "zod/v4";
7
8
  import {
@@ -17,6 +18,11 @@ import {
17
18
  assembleScenes,
18
19
  buildCaptionLines,
19
20
  buildCutlist,
21
+ canonicalizeDictionaryCasing,
22
+ captionsNeedNastaliq,
23
+ NASTALIQ_FONT_NAME,
24
+ NASTALIQ_FONT_REL,
25
+ nastaliqFontFile,
20
26
  buildZoomPlan,
21
27
  checkGrounding,
22
28
  rejectCtaKeyword,
@@ -48,18 +54,50 @@ import {
48
54
  formatBloopSpan,
49
55
  findRetakeGroups,
50
56
  formatRetakeGroup,
57
+ RESTART_PREFIX_CONFIDENCE,
58
+ type RetakeGroup,
51
59
  formatUsageLine,
52
60
  formatUsageReport,
61
+ formatYoutubeMarkdown,
62
+ generateYoutubePack,
63
+ stampedTranscript,
64
+ YOUTUBE_APPROVED_BASENAME,
65
+ YOUTUBE_PROMPT_VERSION,
66
+ YoutubePackSchema,
67
+ type YoutubePack,
68
+ THUMBNAIL_APPROVED_BASENAME,
69
+ THUMBNAIL_MODEL_DEFAULT,
70
+ ThumbnailConceptApprovedSchema,
71
+ ThumbnailConceptSchema,
72
+ type ThumbnailConcept,
73
+ type GenerateThumbnailImageOptions,
74
+ approvedOverlayText,
75
+ buildThumbnailPrompt,
76
+ generateThumbnailConcept,
77
+ generateThumbnailImage,
78
+ portraitMimeType,
79
+ thumbnailDecision,
80
+ thumbnailImageCacheName,
53
81
  applyUserCuts,
54
82
  loadConfig,
55
83
  loudnorm,
56
84
  MAX_NORMALIZE_UPSCALE,
85
+ MAX_MEAN_AREA_DISCARD,
86
+ FACE_ONLY_MIN_FRAC,
87
+ FACE_MIN_DETECTION_RATIO,
57
88
  ZOOM_MAX_SCALE,
58
89
  assessCueFraming,
59
- bakeNormalizedSource,
60
90
  planNormalization,
91
+ segmentIsFaceOnly,
92
+ type WindowFace,
93
+ type FaceBox,
94
+ type FramingSegment,
61
95
  type NormalizePlan,
62
96
  makeMezzanine,
97
+ mezzanineFileName,
98
+ mezzanineScale,
99
+ scaleContentTimeline,
100
+ scaleFramingWindows,
63
101
  measureFace,
64
102
  measureFaceInWindows,
65
103
  pickCoverFrame,
@@ -72,7 +110,10 @@ import {
72
110
  resolveTheme,
73
111
  run,
74
112
  runWhisper,
113
+ whisperPromptFor,
75
114
  scanSourceText,
115
+ ThemeSchema,
116
+ type Theme,
76
117
  appendUsageRun,
77
118
  OverrideDocSchema,
78
119
  CLIP_SNAP_TOLERANCE,
@@ -88,6 +129,7 @@ import {
88
129
  type BeatsValidationIssue,
89
130
  type CleanupLevel,
90
131
  type ClipWindow,
132
+ type Layout,
91
133
  type LlmProvider,
92
134
  type Production,
93
135
  type ProviderName,
@@ -98,6 +140,12 @@ import {
98
140
  } from "@ossclip/core";
99
141
  import { recordRecentProject } from "./edit";
100
142
  import { binOnPath, detectionLine } from "./llm-detect";
143
+ import {
144
+ modelImpliedLanguage,
145
+ modelUrl,
146
+ validModelSources,
147
+ whisperModelPath,
148
+ } from "./setup/manifest";
101
149
  import { PhaseTimer, formatPhaseLine, type PhaseTimings } from "./phase-timing";
102
150
  import {
103
151
  strandedOverrideSiblings,
@@ -105,6 +153,9 @@ import {
105
153
  workdirBaseName,
106
154
  } from "./stranded-overrides";
107
155
  import { editHint } from "./interactive/edit-hint";
156
+ import { artifactPath, ensureParentDir, expandHome, moveFile } from "./paths";
157
+ import { portraitOverridePath, resolvePortrait } from "./portrait-override";
158
+ import { approveThumbnailConcept, thumbnailRetryLoop } from "./interactive/thumbnail-approve";
108
159
  import { isInteractive } from "./interactive/tty";
109
160
  import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from "./ui/animation";
110
161
  import { reconcileCaptionEdits } from "./caption-report";
@@ -112,6 +163,7 @@ import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
112
163
  import { recordedProduceArgs } from "./replay-argv";
113
164
  import { renderCover, renderProduction } from "@ossclip/renderer";
114
165
  import {
166
+ DEFAULT_FACE,
115
167
  coverTextRect,
116
168
  layoutSlots,
117
169
  regionsDuring,
@@ -155,6 +207,13 @@ export interface ProduceResult {
155
207
  export const TranscriptKeySchema = z.object({
156
208
  model: z.string(),
157
209
  language: z.string().optional(),
210
+ /**
211
+ * The dictionary the whisper `--prompt` was biased with (F4, 2026-08-16) —
212
+ * a changed vocabulary changes what whisper decodes, so it re-keys the
213
+ * cache exactly like the model does. Absent (old key files, no-dictionary
214
+ * runs) means "no biasing".
215
+ */
216
+ dictionary: z.array(z.string()).optional(),
158
217
  });
159
218
  export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
160
219
 
@@ -178,7 +237,14 @@ export function transcriptCacheReusable(
178
237
  effective.model === requested.model &&
179
238
  // "" and absent both mean whisper's en default — program.ts rejects an
180
239
  // empty code, but a key file predating that guard must not wedge.
181
- (effective.language ?? "") === (requested.language ?? ""),
240
+ (effective.language ?? "") === (requested.language ?? "") &&
241
+ // ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
242
+ // text verbatim, so a reordered list genuinely is a different decoder
243
+ // input — treating it as equal would serve a transcript biased by a
244
+ // prompt this run never sent. Absent and [] compare equal (both mean
245
+ // "no biasing"), so pre-dictionary key files reuse under a
246
+ // no-dictionary request.
247
+ JSON.stringify(effective.dictionary ?? []) === JSON.stringify(requested.dictionary ?? []),
182
248
  recorded: effective,
183
249
  };
184
250
  }
@@ -214,6 +280,13 @@ export interface ProduceOptions {
214
280
  * decodes garbage (Urdu field test 2026-08-05).
215
281
  */
216
282
  whisperLanguage?: string;
283
+ /**
284
+ * Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
285
+ * split/trimmed by the action. Wholesale beats the config's `dictionary`
286
+ * — typed-beats-config like the watermark, and never merged: a per-run
287
+ * list is a deliberate substitution, not an addition.
288
+ */
289
+ dictionary?: string[];
217
290
  /** Debug: force every graphic moment to this component. */
218
291
  forceComponent?: SceneComponentId;
219
292
  /** Write a cover image beside the video (default on). */
@@ -228,11 +301,11 @@ export interface ProduceOptions {
228
301
  */
229
302
  blooperMarker?: string;
230
303
  /**
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.
304
+ * `--collapse-retakes` — legacy no-op (2026-08-16). Retake collapse (R27
305
+ * §128) now runs automatically whenever `--blooper-marker` is given and
306
+ * never otherwise (`inferredRetakesEnabled` quotes the user's rule). The
307
+ * flag stays parseable so old command.json replays don't error; typing it
308
+ * without a marker earns a notice instead of a silent ignore.
236
309
  */
237
310
  collapseRetakes?: boolean;
238
311
  /**
@@ -278,6 +351,33 @@ export interface ProduceOptions {
278
351
  * a free-tier limitation; this is voluntary attribution.
279
352
  */
280
353
  watermark?: boolean;
354
+ /**
355
+ * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
356
+ * true/false when TYPED, undefined when not — undefined lets the config's
357
+ * `youtube` key supply the default (`resolveYoutube`). One flag covers the
358
+ * whole pack (SEO metadata + AI thumbnail) by user decision 2026-08-16.
359
+ */
360
+ youtube?: boolean;
361
+ /**
362
+ * `--portrait <path>`: the creator's portrait photo, the likeness
363
+ * reference for the `--youtube` AI thumbnail. Typed-beats-config like the
364
+ * dictionary; validated at USE (the thumbnail step), where an absent file
365
+ * is a loud skip and the frame-grab cover stands.
366
+ */
367
+ portrait?: string;
368
+ /**
369
+ * `--audience <text>`: who watches the channel, steering BOTH the youtube
370
+ * pack's titles/tags and the thumbnail concept. Typed-beats-config like
371
+ * `--portrait`; the config's `audience` supplies the default, validated
372
+ * with `typeof === "string"` at use.
373
+ */
374
+ audience?: string;
375
+ /**
376
+ * `--thumbnail-brief <text>`: the durable thumbnail steer, fed to the
377
+ * concept call as a must-honor creator brief. Same typed-beats-config
378
+ * contract as `audience` (config key `thumbnailBrief`).
379
+ */
380
+ thumbnailBrief?: string;
281
381
  /**
282
382
  * `--captions` / `--no-captions` tri-state: true/false when TYPED,
283
383
  * undefined when not. Unlike `watermark` above there is no config key —
@@ -287,6 +387,15 @@ export interface ProduceOptions {
287
387
  * record differently.
288
388
  */
289
389
  captions?: boolean;
390
+ /**
391
+ * `--add-jump-cuts` / `--no-jump-cuts` tri-state: true/false when TYPED,
392
+ * undefined when not ("auto", the default — punch, face-only). Resolved by
393
+ * `resolveJumpCuts`; scope is the cut punch-in ONLY, narrower than `zoom`,
394
+ * which kills every motion driver at once. Note `true` does NOT override
395
+ * the face-only guard (`punchPlanFor` has the why) — it exists to beat a
396
+ * future config-off, nothing else.
397
+ */
398
+ jumpCuts?: boolean;
290
399
  /**
291
400
  * `<input>` a DIRECTORY: order its clips before concatenating them into the
292
401
  * source produce runs on (folder-input-brief.md). `name` (default) is a
@@ -321,6 +430,688 @@ export function resolveWatermark(
321
430
  return flag ?? configValue === true;
322
431
  }
323
432
 
433
+ /**
434
+ * The effective `--youtube` switch — resolveWatermark's semantics verbatim:
435
+ * a TYPED flag always wins (so `--no-youtube` beats a config-on), and only
436
+ * then does the config supply the default. The config side is `=== true`,
437
+ * never truthiness, for the same parse-don't-coerce reason: the value comes
438
+ * from a hand-editable JSON file loadConfig doesn't zod-parse, and a typo'd
439
+ * `"youtube": "no"` must not switch a metadata+thumbnail pipeline ON. Off is
440
+ * the only safe reading of anything malformed for an opt-in extra. Pure so
441
+ * the flag × config matrix is testable without a config file on disk.
442
+ */
443
+ export function resolveYoutube(
444
+ flag: boolean | undefined,
445
+ configValue: boolean | undefined,
446
+ ): boolean {
447
+ return flag ?? configValue === true;
448
+ }
449
+
450
+ /**
451
+ * How many browser tabs the render runs in parallel (2026-08-17 render-speed
452
+ * pass). Default is cpus-2 with a floor of 2: the render is decode-bound —
453
+ * every tab waits on OffthreadVideo's ffmpeg extract workers — so saturating
454
+ * all cores with tabs starves the very processes the tabs block on. The
455
+ * config's `renderConcurrency` overrides for machines where that guess is
456
+ * wrong; validated here at the consumer (the `dictionary` posture: the value
457
+ * comes from hand-editable JSON loadConfig doesn't zod-parse), a positive
458
+ * integer or one warning and the default — never a coerced tab count. Pure
459
+ * so the config × cpu matrix is testable without a config file or real
460
+ * cpus().
461
+ */
462
+ export function resolveRenderConcurrency(
463
+ configValue: unknown,
464
+ cpuCount: number,
465
+ ): { concurrency: number; warning?: string } {
466
+ const fallback = Math.max(2, cpuCount - 2);
467
+ if (configValue === undefined) return { concurrency: fallback };
468
+ if (typeof configValue === "number" && Number.isInteger(configValue) && configValue > 0) {
469
+ return { concurrency: configValue };
470
+ }
471
+ return {
472
+ concurrency: fallback,
473
+ warning: "⚠ config renderConcurrency ignored — expected a positive integer",
474
+ };
475
+ }
476
+
477
+ // Moved to paths.ts (2026-08-17, editor thumbnail panel): the edit server
478
+ // derives `<out>.thumbnail.png` from command.json's recorded out and must not
479
+ // import this module — produce.ts imports edit.ts (recordRecentProject), so
480
+ // the reverse edge would be a cycle, and this module's import graph drags the
481
+ // whole renderer into a server that is deliberately dependency-free.
482
+ // Re-exported so existing importers (tests) keep their path.
483
+ export { artifactPath } from "./paths";
484
+
485
+ /**
486
+ * `--dictionary "JSON, ossclip"` → `["JSON", "ossclip"]`. Comma-separated in
487
+ * ONE value because a variadic option fights the optional positional
488
+ * `[input]` (see program.ts); split/trim/drop-empties here so a trailing
489
+ * comma or doubled space never becomes an empty term in the whisper prompt.
490
+ * `undefined` in, `undefined` out — "not typed" must survive to let the
491
+ * config supply the dictionary. Pure so the split matrix is testable without
492
+ * commander.
493
+ */
494
+ export function dictionaryFlag(value: string | undefined): string[] | undefined {
495
+ if (value === undefined) return undefined;
496
+ return value
497
+ .split(",")
498
+ .map((t) => t.trim())
499
+ .filter(Boolean);
500
+ }
501
+
502
+ /**
503
+ * Consumer-side validation for the config's `dictionary` key — the
504
+ * `watermark` posture applied to an array: the value comes from a
505
+ * hand-editable JSON file loadConfig doesn't zod-parse, so a non-array, a
506
+ * number in the list, or a term that trims to nothing means the whole key is
507
+ * ignored (`undefined`) and the call site prints one warning naming the
508
+ * problem. All-or-nothing on purpose: silently keeping the salvageable half
509
+ * of a typo'd list would bias whisper with a vocabulary the user never
510
+ * reviewed. Pure so the matrix is testable without a config file on disk.
511
+ */
512
+ export function validDictionary(value: unknown): string[] | undefined {
513
+ if (!Array.isArray(value) || value.length === 0) return undefined;
514
+ if (!value.every((t) => typeof t === "string" && t.trim().length > 0)) return undefined;
515
+ return value.map((t: string) => t.trim());
516
+ }
517
+
518
+ /**
519
+ * The effective whisper `-l` for a run — typed-beats-config precedence like
520
+ * `--dictionary`, with a third rung under both: the curated model table's
521
+ * implied language (`modelImpliedLanguage`), so `--whisper-model medium-urdu`
522
+ * alone decodes Urdu instead of silently decoding English garbage (the Urdu
523
+ * field test's exact first-run failure, 2026-08-05). The config side is
524
+ * typeof+trim, never truthiness — `language` comes from a hand-editable JSON
525
+ * file loadConfig doesn't zod-parse, and a malformed value earns one warning
526
+ * and falls through, never a coerced `-l`. `source` rides along so the call
527
+ * site can say where a non-flag language came from. Pure so the whole
528
+ * flag × config × model matrix is testable without a config file on disk.
529
+ */
530
+ export function resolveWhisperLanguage(
531
+ flag: string | undefined,
532
+ configValue: unknown,
533
+ modelImplied: string | undefined,
534
+ ): { language: string | undefined; source: "flag" | "config" | "model" | null; warning?: string } {
535
+ if (flag !== undefined) return { language: flag, source: "flag" };
536
+ const configOk = typeof configValue === "string" && configValue.trim().length > 0;
537
+ const warning =
538
+ configValue !== undefined && !configOk
539
+ ? "⚠ config language ignored — expected a non-empty language code string"
540
+ : undefined;
541
+ if (configOk) return { language: (configValue as string).trim(), source: "config" };
542
+ if (modelImplied !== undefined) return { language: modelImplied, source: "model", ...(warning ? { warning } : {}) };
543
+ return { language: undefined, source: null, ...(warning ? { warning } : {}) };
544
+ }
545
+
546
+ /**
547
+ * The BASE theme a run starts from: the config's `theme` merged over
548
+ * `defaultTheme` (F6, 2026-08-16). Precedence overall is overrides.json >
549
+ * config theme > defaultTheme — this helper builds the bottom two layers,
550
+ * and it must feed BOTH `resolveTheme`'s base and props.baseTheme: the
551
+ * editor re-applies overrides onto `baseTheme`, so a reset there must fall
552
+ * back to the user's global colors, not to factory defaults.
553
+ *
554
+ * All-or-nothing: `ThemeSchema.partial().safeParse` — one malformed key (a
555
+ * numeric `accent`, an unknown-shaped value) voids the WHOLE config theme
556
+ * with a warning naming the issue, because half-applying a palette the
557
+ * schema rejected would render colors the user never chose. The warning is
558
+ * RETURNED, not printed — pure, so the precedence matrix is testable without
559
+ * a config file or a captured console.
560
+ */
561
+ export function configuredBaseTheme(cfgTheme: unknown): { theme: Theme; warning?: string } {
562
+ if (cfgTheme === undefined) return { theme: defaultTheme };
563
+ const parsed = ThemeSchema.partial().strict().safeParse(cfgTheme);
564
+ if (!parsed.success) {
565
+ return {
566
+ theme: defaultTheme,
567
+ warning:
568
+ `⚠ config theme ignored — ${parsed.error.issues
569
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
570
+ .join("; ")}`,
571
+ };
572
+ }
573
+ // Re-parse the merge so zod's defaults fill anything the partial left out —
574
+ // the same construction defaultTheme itself uses.
575
+ return { theme: ThemeSchema.parse({ ...defaultTheme, ...parsed.data }) };
576
+ }
577
+
578
+ /**
579
+ * The concept cache's filename: keyed on who is asked, with what steer,
580
+ * about which words (the Y2 pack-key shape) — audience/brief/titleAngle are
581
+ * steer, so a changed one regenerates. ONE function shared by thumbnailStep
582
+ * and the pre-render approval step, so the approval's cache seed can never
583
+ * drift from the file the step would write. Pure so the key's inputs are
584
+ * pinned by a test.
585
+ */
586
+ export function thumbnailConceptCacheName(parts: {
587
+ providerName: string;
588
+ llmModel?: string;
589
+ intent?: string;
590
+ hook?: string;
591
+ audience?: string;
592
+ brief?: string;
593
+ titleAngle?: string;
594
+ transcriptWords: readonly string[];
595
+ }): string {
596
+ const key = createHash("sha1")
597
+ .update(
598
+ JSON.stringify([
599
+ parts.providerName,
600
+ parts.llmModel ?? "",
601
+ parts.intent ?? "",
602
+ parts.hook ?? "",
603
+ parts.audience ?? "",
604
+ parts.brief ?? "",
605
+ parts.titleAngle ?? "",
606
+ parts.transcriptWords,
607
+ ]),
608
+ )
609
+ .digest("hex")
610
+ .slice(0, 8);
611
+ return `thumbnail-concept-${key}.json`;
612
+ }
613
+
614
+ /**
615
+ * The workdir's approved YouTube pack, or undefined. The Y2 block checks
616
+ * this FIRST (editor SEO panel, 2026-08-17 — thumbnailStep's approval-file
617
+ * contract applied to the pack): once the editor persisted an edited pack,
618
+ * a cache lookup or a fresh LLM call would silently discard the user's
619
+ * words. Exported so the honor/leniency matrix is testable with a temp dir.
620
+ *
621
+ * Read-side leniency, unlike thumbnailStep's hard `.parse`: a corrupt
622
+ * decision file here warns and falls through to the generate path — the pack
623
+ * is a sidecar on a render that must not die over it (§112), and the next
624
+ * editor save atomically replaces the file anyway.
625
+ */
626
+ export async function readApprovedYoutubePack(
627
+ work: string,
628
+ log: (line: string) => void = console.log,
629
+ ): Promise<YoutubePack | undefined> {
630
+ const path = join(work, YOUTUBE_APPROVED_BASENAME);
631
+ if (!existsSync(path)) return undefined;
632
+ try {
633
+ const parsed = YoutubePackSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
634
+ if (parsed.success) return parsed.data;
635
+ log(` ⚠ ${YOUTUBE_APPROVED_BASENAME} is not a valid pack — regenerating instead`);
636
+ } catch {
637
+ log(` ⚠ ${YOUTUBE_APPROVED_BASENAME} is not valid JSON — regenerating instead`);
638
+ }
639
+ return undefined;
640
+ }
641
+
642
+ /** Everything the AI thumbnail step (Y3) needs, gathered for testability. */
643
+ export interface ThumbnailStepArgs {
644
+ /** The resolved `--youtube` switch — off means the whole step is silent. */
645
+ youtube: boolean;
646
+ /** Resolved `--portrait` / config path; existence is checked HERE. */
647
+ portraitPath: string | undefined;
648
+ /** GEMINI_API_KEY — env-only, never config (env.ts secrets rule). */
649
+ apiKey: string | undefined;
650
+ /** The image model slug (config `thumbnailModel` or the default). */
651
+ model: string;
652
+ work: string;
653
+ outPath: string;
654
+ /** The run's text provider — the concept call rides it, tier editorial. */
655
+ provider: LlmProvider | undefined;
656
+ providerName: string;
657
+ llmModel: string | undefined;
658
+ intent: string | undefined;
659
+ hook: string | undefined;
660
+ /** Resolved `--audience` / config — who the channel is for. */
661
+ audience?: string;
662
+ /** Resolved `--thumbnail-brief` / config — the durable must-honor steer. */
663
+ brief?: string;
664
+ /**
665
+ * The youtube pack's first title, when the pack generated before this step
666
+ * — the thumbnail must tell the same story as the title it ships under.
667
+ */
668
+ titleAngle?: string;
669
+ transcriptWords: readonly string[];
670
+ /**
671
+ * The image-generation seam, pickCoverFrame's `detectFace` shape: tests
672
+ * inject a stub here and therefore never import @google/genai.
673
+ */
674
+ generate?: (opts: GenerateThumbnailImageOptions) => Promise<Uint8Array>;
675
+ /** Phase-timing wrapper for the concept LLM call; identity by default. */
676
+ time?: <T>(fn: () => Promise<T>) => Promise<T>;
677
+ log?: (line: string) => void;
678
+ }
679
+
680
+ /**
681
+ * What a successful thumbnail step hands back — more than the path, because
682
+ * the post-generation retry loop ("regenerate with a note", 2026-08-16
683
+ * thumbnail UX) reuses the exact concept, cache file and portrait bytes this
684
+ * step generated with. Re-deriving any of them in the loop would let the two
685
+ * drift (a different cache key regenerating a file the loop then never
686
+ * overwrites).
687
+ */
688
+ export interface ThumbnailStepResult {
689
+ /** The written `<out>.thumbnail.png`. */
690
+ path: string;
691
+ /** The concept the image was prompted with — unchanged across retries. */
692
+ concept: ThumbnailConcept;
693
+ /** The workdir image cache the retry loop overwrites in place. */
694
+ imageCachePath: string;
695
+ /** The portrait as the inlineData shape the generate seam takes. */
696
+ portrait: { data: string; mimeType: string };
697
+ }
698
+
699
+ /**
700
+ * The `--youtube` AI thumbnail orchestration (Y3, 2026-08-16): decide,
701
+ * concept, image, copy beside the output. Extracted from `produce()` so the
702
+ * cache/degrade matrix is testable with an injected `generate` and a temp
703
+ * dir — no SDK, no network.
704
+ *
705
+ * Additive to the cover pipeline by contract: EVERY exit short of success is
706
+ * one loud line and `undefined`, and the frame-grab cover stands. Returns
707
+ * the written `<out>.thumbnail.png` path (plus the retry loop's inputs) on
708
+ * success.
709
+ */
710
+ export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailStepResult | undefined> {
711
+ const {
712
+ generate = generateThumbnailImage,
713
+ time = <T>(fn: () => Promise<T>) => fn(),
714
+ log = console.log,
715
+ } = args;
716
+ const portraitExists = args.portraitPath ? existsSync(args.portraitPath) : false;
717
+ const decision = thumbnailDecision(
718
+ args.youtube,
719
+ args.portraitPath,
720
+ args.apiKey !== undefined && args.apiKey !== "",
721
+ portraitExists,
722
+ );
723
+ if (decision !== "generate") {
724
+ // youtube-off is the one silent exit: the user never opted in, so there
725
+ // is nothing to explain. Every other skip is a run the user configured
726
+ // for a thumbnail and didn't get one — say why, once.
727
+ if (decision !== "skip-no-youtube") {
728
+ const reason =
729
+ decision === "skip-no-portrait"
730
+ ? "no portrait — set `portrait` in ~/.ossclip/config.json or pass --portrait"
731
+ : decision === "skip-no-key"
732
+ ? "GEMINI_API_KEY not set"
733
+ : `portrait not found: ${args.portraitPath}`;
734
+ log(`▸ thumbnail: skipped (${reason}) — frame-grab cover stands`);
735
+ }
736
+ return undefined;
737
+ }
738
+ // The pre-render approval file, checked FIRST (2026-08-16 thumbnail UX):
739
+ // the user approved — or explicitly skipped — this exact concept before
740
+ // the render, so asking a model again here would discard their edit. The
741
+ // skip variant is a LOUD skip: unlike youtube-off, the user opted in and
742
+ // then declined this one thumbnail, and the line says how to revisit.
743
+ const approvedPath = join(args.work, THUMBNAIL_APPROVED_BASENAME);
744
+ let approved: ThumbnailConcept | undefined;
745
+ if (existsSync(approvedPath)) {
746
+ const parsed = ThumbnailConceptApprovedSchema.parse(
747
+ JSON.parse(await readFile(approvedPath, "utf8")),
748
+ );
749
+ if ("skip" in parsed) {
750
+ log(
751
+ `▸ thumbnail: skipped (declined at concept approval — delete ` +
752
+ `${THUMBNAIL_APPROVED_BASENAME} in the workdir to revisit) — frame-grab cover stands`,
753
+ );
754
+ return undefined;
755
+ }
756
+ approved = parsed;
757
+ }
758
+ const mimeType = portraitMimeType(args.portraitPath!);
759
+ if (!mimeType) {
760
+ log(
761
+ `▸ thumbnail: skipped (unsupported portrait format "${args.portraitPath}" — ` +
762
+ "use png, jpg, jpeg or webp) — frame-grab cover stands",
763
+ );
764
+ return undefined;
765
+ }
766
+ let concept: ThumbnailConcept;
767
+ if (approved) {
768
+ // No concept call, no concept cache — the approved file IS the concept.
769
+ concept = approved;
770
+ log("▸ thumbnail: using the approved concept");
771
+ } else {
772
+ if (!args.provider) {
773
+ // The concept call rides the run's text provider (Y2's exactly); the
774
+ // IMAGE key alone cannot write the concept, so no provider means no
775
+ // thumbnail — loud, because the youtube gate was on.
776
+ log("▸ thumbnail: skipped (no LLM provider for the concept) — frame-grab cover stands");
777
+ return undefined;
778
+ }
779
+
780
+ // Concept cache (thumbnailConceptCacheName has the key's rationale).
781
+ // Failures are never cached (§106).
782
+ const conceptCache = join(args.work, thumbnailConceptCacheName(args));
783
+ if (existsSync(conceptCache)) {
784
+ concept = ThumbnailConceptSchema.parse(JSON.parse(await readFile(conceptCache, "utf8")));
785
+ log("▸ thumbnail: concept cached");
786
+ } else {
787
+ try {
788
+ const fresh = await time(() =>
789
+ generateThumbnailConcept(args.provider!, {
790
+ hook: args.hook,
791
+ intent: args.intent,
792
+ audience: args.audience,
793
+ brief: args.brief,
794
+ titleAngle: args.titleAngle,
795
+ transcriptText: args.transcriptWords.join(" "),
796
+ }),
797
+ );
798
+ // The schema caps CHARACTERS; approvedOverlayText caps WORDS (§35 —
799
+ // overlay text at thumbnail size has a cover banner's 4-9 word
800
+ // ceiling). Capped BEFORE caching so the cache and the image key hold
801
+ // what is used.
802
+ concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
803
+ await writeFile(conceptCache, JSON.stringify(concept, null, 2));
804
+ } catch (err) {
805
+ log(
806
+ `▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
807
+ "— frame-grab cover stands",
808
+ );
809
+ return undefined;
810
+ }
811
+ }
812
+ }
813
+
814
+ // Image cache — thumbnailImageCacheName has the key's rationale, and it is
815
+ // shared with the editor's regenerate endpoint so the two callers can never
816
+ // cache past each other.
817
+ const portraitBytes = await readFile(args.portraitPath!);
818
+ const imageCache = join(
819
+ args.work,
820
+ thumbnailImageCacheName(
821
+ args.model,
822
+ concept,
823
+ createHash("sha1").update(portraitBytes).digest("hex"),
824
+ ),
825
+ );
826
+ if (existsSync(imageCache)) {
827
+ log("▸ thumbnail: image cached");
828
+ } else {
829
+ try {
830
+ const bytes = await generate({
831
+ apiKey: args.apiKey!,
832
+ model: args.model,
833
+ prompt: buildThumbnailPrompt(concept, true),
834
+ portrait: { data: portraitBytes.toString("base64"), mimeType },
835
+ });
836
+ await writeFile(imageCache, bytes);
837
+ } catch (err) {
838
+ // NEVER cache a failure (§106), never fail the produce that just
839
+ // rendered. The message rides VERBATIM — the model slug is
840
+ // user-specified, and an unknown-model rejection is deterministic, so
841
+ // no retry and no paraphrase (§132 posture).
842
+ log(
843
+ `▸ thumbnail: generation failed (${err instanceof Error ? err.message : String(err)}) ` +
844
+ "— frame-grab cover stands",
845
+ );
846
+ return undefined;
847
+ }
848
+ }
849
+ const dest = artifactPath(args.outPath, ".thumbnail.png");
850
+ await copyFile(imageCache, dest);
851
+ log(`✓ thumbnail → ${dest}`);
852
+ return {
853
+ path: dest,
854
+ concept,
855
+ imageCachePath: imageCache,
856
+ portrait: { data: portraitBytes.toString("base64"), mimeType },
857
+ };
858
+ }
859
+
860
+ /**
861
+ * Whether inferred retake collapse (findRetakeGroups, R27 §128) runs at all.
862
+ * Gated on the blooper marker, NOT on `--collapse-retakes` — user decision,
863
+ * verbatim (2026-08-16): "Bloopers and retakes go hand-in-hand. Do not do
864
+ * retakes without bloopers... If blooper is there, we do it, else we don't."
865
+ * A marker the speaker says out loud is the signal that this recording style
866
+ * leaves flubs in the take; without it, inferred cutting has no such
867
+ * license. `--collapse-retakes` stays parseable (old command.json replays)
868
+ * but inert. Trim-empty counts as absent: findBloopSpans refuses a blank
869
+ * marker for the same reason. Pure so the gate matrix is testable without a
870
+ * run.
871
+ */
872
+ export function inferredRetakesEnabled(blooperMarker: string | undefined): boolean {
873
+ return typeof blooperMarker === "string" && blooperMarker.trim().length > 0;
874
+ }
875
+
876
+ /**
877
+ * RetakeGroup cuts → `buildCutlist`'s `retakes` entries. The exact-prefix
878
+ * restart rule carries RESTART_PREFIX_CONFIDENCE (0.85) instead of the 0.9
879
+ * default a similarity-matched retake earns — the group's `rule` is the only
880
+ * place that distinction lives, and it's dropped by the flatMap, so the
881
+ * confidence has to be attached here. Pure so the mapping is testable
882
+ * without a run.
883
+ */
884
+ export function retakeCutsFor(
885
+ groups: readonly RetakeGroup[],
886
+ ): { startWord: number; endWord: number; startSec: number; endSec: number; confidence?: number }[] {
887
+ return groups.flatMap((g) =>
888
+ g.cuts.map((c) =>
889
+ g.rule === "exact-prefix" ? { ...c, confidence: RESTART_PREFIX_CONFIDENCE } : c,
890
+ ),
891
+ );
892
+ }
893
+
894
+ /**
895
+ * How much of the source a cover crop into `frame` keeps, and on which axis.
896
+ * Cover scales the picture until BOTH frame axes are filled, then trims
897
+ * whichever source axis overflows: a source wider than the frame loses width
898
+ * (kept = frameAspect / contentAspect), a narrower one loses height (the
899
+ * inverse). `null` means either nothing is trimmed (matching aspects) or a
900
+ * dimension is degenerate and no claim can be made. Orientation-neutral on
901
+ * purpose: the old call-site warning was gated on `!landscape`, assuming a
902
+ * 16:9 output never meaningfully crops a 16:9-ish source — and the
903
+ * 2026-08-16 incident was exactly that, a 1.547:1 screen recording in a 16:9
904
+ * frame with 13% of the height silently gone (28% post-normalization) and no
905
+ * line in the log ever mentioning it. Pure so the whole orientation matrix
906
+ * is testable without probing a real video.
907
+ */
908
+ export function coverKeepFraction(
909
+ content: { width: number; height: number },
910
+ frame: { width: number; height: number },
911
+ ): { axis: "width" | "height"; kept: number } | null {
912
+ if (content.width <= 0 || content.height <= 0 || frame.width <= 0 || frame.height <= 0) {
913
+ return null;
914
+ }
915
+ const contentAspect = content.width / content.height;
916
+ const frameAspect = frame.width / frame.height;
917
+ if (contentAspect > frameAspect) return { axis: "width", kept: frameAspect / contentAspect };
918
+ if (contentAspect < frameAspect) return { axis: "height", kept: contentAspect / frameAspect };
919
+ return null;
920
+ }
921
+
922
+ /**
923
+ * What the whole-take face measurement says the frame's SUBJECT is — the
924
+ * same rule `segmentIsFaceOnly` (core) applies per segment, here on the
925
+ * global median box that feeds `face` in render-props. "screen" tells the
926
+ * stage's cover bias to stay centered instead of chasing the face: in the
927
+ * 2026-08-16 incident the global 9-sample median landed on the camera PiP
928
+ * (sizeFrac 0.119, bottom-right) and pinned objectPosY to 1.0, cutting the
929
+ * speaker's head off at the top of every full-frame stretch — a PiP-sized
930
+ * face must not steer the cover. Accepts `measureFace`'s own return shape
931
+ * (null = no face found at all), and reads null the way segmentIsFaceOnly
932
+ * does: no face, one below FACE_ONLY_MIN_FRAC, or one seen in under
933
+ * FACE_MIN_DETECTION_RATIO of the samples means the picture is the subject.
934
+ * Pure so the classification matrix is testable without a video or the
935
+ * detector.
936
+ */
937
+ export function faceSubject(faceBox: FaceBox | null): "face" | "screen" {
938
+ if (!faceBox) return "screen";
939
+ if (faceBox.sizeFrac < FACE_ONLY_MIN_FRAC) return "screen";
940
+ return faceBox.framesSampled > 0 &&
941
+ faceBox.framesDetected / faceBox.framesSampled >= FACE_MIN_DETECTION_RATIO
942
+ ? "face"
943
+ : "screen";
944
+ }
945
+
946
+ /**
947
+ * Reunites commander's two jump-cut keys into the one tri-state
948
+ * `ProduceOptions.jumpCuts`. Unlike the watermark pair — one key, positive
949
+ * declared first so the untyped default stays undefined — this pair's
950
+ * positive is spelled `--add-jump-cuts` (bare "--jump-cuts" reads as adding
951
+ * CUTS, not the zooms that conceal them), and commander only folds a
952
+ * negative onto the key its exact positive spelling owns: `--no-jump-cuts`
953
+ * alone creates `jumpCuts` defaulting TRUE, while `--add-jump-cuts` lands on
954
+ * `addJumpCuts`. So "typed --no-jump-cuts" is indistinguishable from "not
955
+ * typed" by value — the caller passes commander's getOptionValueSource
956
+ * verdict instead. Both typed is a contradiction and must be a loud error,
957
+ * never a precedence rule the user has to memorize. Pure so the whole
958
+ * flag matrix is testable without commander in the loop.
959
+ */
960
+ export function jumpCutsFlag(
961
+ addJumpCuts: boolean | undefined,
962
+ noJumpCutsTyped: boolean,
963
+ ): boolean | undefined {
964
+ if (addJumpCuts === true && noJumpCutsTyped) {
965
+ throw new Error("--add-jump-cuts contradicts --no-jump-cuts — pass at most one");
966
+ }
967
+ if (addJumpCuts === true) return true;
968
+ return noJumpCutsTyped ? false : undefined;
969
+ }
970
+
971
+ /**
972
+ * The effective jump-cut punch mode from the tri-state flag. "auto" (not
973
+ * typed) and "force" (--add-jump-cuts) punch identically TODAY — the split
974
+ * exists so a future config key can turn the default off while a typed
975
+ * --add-jump-cuts still beats it, resolveWatermark's flag-beats-config
976
+ * precedence declared before the config side even exists. Pure so the
977
+ * matrix is testable without a flag parse.
978
+ */
979
+ export type JumpCutsMode = "off" | "auto" | "force";
980
+
981
+ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
982
+ if (flag === true) return "force";
983
+ if (flag === false) return "off";
984
+ return "auto";
985
+ }
986
+
987
+ /**
988
+ * The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
989
+ * (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
990
+ * screen content sideways at every cut on the incident's screen recording,
991
+ * and even on a talking head a 7% lurch reads as the camera stumbling. Big
992
+ * enough to break up the jump, small enough to pass as sensor noise.
993
+ */
994
+ export const FACE_PUNCH_SCALE = 1.015;
995
+
996
+ /**
997
+ * The framing subject at a SOURCE time — which of the plan's segments owns
998
+ * `srcSec`, with the same edge clamping as scenes' `framingWindowAtOutput`:
999
+ * a time before the first segment reads as the first, after the last as the
1000
+ * last, so a span whose in-point rounds a hair past a boundary still gets a
1001
+ * segment's verdict rather than a hole. An empty timeline reads as "screen"
1002
+ * — no punch — because with no plan there is no evidence the frame is just
1003
+ * a face, and the guard's failure mode (sliding a screen share) is the
1004
+ * worse of the two. Pure so the lookup is testable against a fixture.
1005
+ */
1006
+ export function framingSubjectAt(
1007
+ timeline: readonly FramingSegment[],
1008
+ srcSec: number,
1009
+ ): "face" | "screen" {
1010
+ if (timeline.length === 0) return "screen";
1011
+ if (srcSec < timeline[0]!.startSec) return timeline[0]!.subject;
1012
+ for (const seg of timeline) {
1013
+ if (srcSec >= seg.startSec && srcSec < seg.endSec) return seg.subject;
1014
+ }
1015
+ return timeline[timeline.length - 1]!.subject;
1016
+ }
1017
+
1018
+ /**
1019
+ * The per-span jump-cut punch plan `render-props.punch` carries. THE
1020
+ * FACE-ONLY GUARD HOLDS IN EVERY MODE, "force" included: punching a screen
1021
+ * share slides its content — text visibly drifting is WORSE than the jump
1022
+ * the punch would conceal — so `--add-jump-cuts` overrides a (future)
1023
+ * config-off, never the guard. `spanIsFaceOnly` comes per span from the
1024
+ * framing timeline's subject at the span's source in-point, or from the
1025
+ * global `faceSubject` verdict when no plan exists. Mode "off" still emits
1026
+ * a full all-false mask rather than nothing: an ABSENT `punch` key is the
1027
+ * legacy 1.07-everywhere contract, the opposite of off. Pure so the
1028
+ * mode × subject matrix is testable without a produce run.
1029
+ */
1030
+ export function punchPlanFor(
1031
+ spans: readonly KeptSpan[],
1032
+ mode: JumpCutsMode,
1033
+ spanIsFaceOnly: readonly boolean[],
1034
+ ): { scale: number; allowed: boolean[] } {
1035
+ if (mode === "off") return { scale: 1, allowed: spans.map(() => false) };
1036
+ return {
1037
+ scale: FACE_PUNCH_SCALE,
1038
+ allowed: spans.map((_, i) => spanIsFaceOnly[i] === true),
1039
+ };
1040
+ }
1041
+
1042
+ /**
1043
+ * One face-only verdict per kept span, read where that span BEGINS — the
1044
+ * frame at the cut is what any motion driver scales. With a framing plan the
1045
+ * verdict is the plan's per-segment subject at the span's source in-point;
1046
+ * without one every span shares the global `faceSubject` verdict. Hoisted to
1047
+ * ONE mask because TWO motion drivers consume it — the jump-cut punch
1048
+ * (`punchPlanFor.allowed`) and the idle zoom (`buildZoomPlan.allowedClips`,
1049
+ * user decision 2026-08-16: "Face-only. If there's anything else, then no
1050
+ * zoom" — the idle push visibly SLID screen-recording content) — and they
1051
+ * must never disagree about who the subject is: a span the punch holds still
1052
+ * but the idle zoom pushes would slide the very content the guard exists to
1053
+ * protect. Pure so the timeline × subject matrix is testable without a
1054
+ * produce run.
1055
+ */
1056
+ export function spanFaceMask(
1057
+ spans: readonly KeptSpan[],
1058
+ framingTimeline: readonly FramingSegment[] | null,
1059
+ globalSubject: "face" | "screen",
1060
+ ): boolean[] {
1061
+ return spans.map(
1062
+ (sp) =>
1063
+ (framingTimeline ? framingSubjectAt(framingTimeline, sp.srcIn) : globalSubject) === "face",
1064
+ );
1065
+ }
1066
+
1067
+ /**
1068
+ * The measurement windows for a MEASURED per-span mask — each kept span's
1069
+ * SOURCE range over the full frame (`cropVf: ""`, the shape
1070
+ * `measureFaceInWindows` takes). This path only runs when no framing plan
1071
+ * exists, i.e. the content rects are uniform, so there is no per-segment
1072
+ * rect to crop to first. Pure so the span→window mapping is testable
1073
+ * without ffmpeg.
1074
+ */
1075
+ export function spanFaceWindows(
1076
+ spans: readonly KeptSpan[],
1077
+ ): Array<{ startSec: number; endSec: number; cropVf: string }> {
1078
+ return spans.map((sp) => ({ startSec: sp.srcIn, endSec: sp.srcOut, cropVf: "" }));
1079
+ }
1080
+
1081
+ /**
1082
+ * `spanFaceMask`'s sibling for the no-plan path, from MEASURED faces
1083
+ * (2026-08-16 v2 review): a screen recording with full-frame webcam
1084
+ * stretches has uniform content rects, so no framing plan exists and the
1085
+ * old fallback let the GLOBAL `faceSubject` verdict — "screen", because the
1086
+ * whole-take median landed on the 0.119 PiP — speak for every span. The
1087
+ * face-only stretches therefore got no punch concealment (raw jump cuts
1088
+ * visible on the face) and no idle zoom. With no plan to supply subjects,
1089
+ * the mask must be measured per span; the verdict rule is core's own
1090
+ * `segmentIsFaceOnly`, the same one the framing plan applies per segment.
1091
+ * `faces` is parallel to the spans that produced the windows. Pure so the
1092
+ * wiring is testable without spawning ffmpeg.
1093
+ */
1094
+ export function spanFaceMaskFromFaces(faces: ReadonlyArray<WindowFace | null>): boolean[] {
1095
+ return faces.map((f) => segmentIsFaceOnly(f));
1096
+ }
1097
+
1098
+ /**
1099
+ * Cache key for the measured per-span mask: the spans' SOURCE ranges plus
1100
+ * the source content hash. `measureFaceInWindows` itself does not cache
1101
+ * (its other caller feeds a bake output that is cached downstream), and a
1102
+ * ~55-span take is a few hundred single-frame ffmpeg spawns — too much to
1103
+ * repeat on every warm re-run. Keyed on source ranges so a re-cut
1104
+ * re-measures, and on the source identity so a same-shape cut of a
1105
+ * different take cannot borrow verdicts. Pure so the key's inputs are
1106
+ * pinned by a test.
1107
+ */
1108
+ export function spanFaceCacheKey(spans: readonly KeptSpan[], sourceHash: string): string {
1109
+ return createHash("sha1")
1110
+ .update(JSON.stringify([sourceHash, spans.map((sp) => [sp.srcIn, sp.srcOut])]))
1111
+ .digest("hex")
1112
+ .slice(0, 12);
1113
+ }
1114
+
324
1115
  /**
325
1116
  * Whether captions are hidden this run: the flag saying OFF, or the editor's
326
1117
  * doc-global `captionsHidden` override saying hidden. An OR, deliberately
@@ -341,6 +1132,28 @@ export function resolveCaptionsHidden(
341
1132
  return flag === false || overrideHidden === true;
342
1133
  }
343
1134
 
1135
+ /**
1136
+ * Caption packing per orientation (2026-08-16 v2 review, user screenshot:
1137
+ * "we can actually even have more letters at a time on the screen").
1138
+ * Landscape draws captions at 44px on a 1920px frame against portrait's
1139
+ * 64px on 1080px (`captionFontSizeFor`) — roughly 2.6× the horizontal text
1140
+ * budget (1920/44 ≈ 44 character-widths vs 1080/64 ≈ 17) — so the portrait
1141
+ * default's 3-word lines look sparse there; landscape packs 6 words over
1142
+ * 2.4s, double the core defaults. Portrait returns those defaults VERBATIM
1143
+ * — stated explicitly at the call site rather than changed in captions.ts,
1144
+ * because the core defaults are portrait's contract and its output must
1145
+ * stay byte-identical. Pure so the matrix is testable without a produce
1146
+ * run.
1147
+ */
1148
+ export function captionPackingFor(landscape: boolean): {
1149
+ maxWordsPerLine: number;
1150
+ maxLineDuration: number;
1151
+ } {
1152
+ return landscape
1153
+ ? { maxWordsPerLine: 6, maxLineDuration: 2.4 }
1154
+ : { maxWordsPerLine: 3, maxLineDuration: 1.2 };
1155
+ }
1156
+
344
1157
  function sha1File(path: string): Promise<string> {
345
1158
  return new Promise((res, rej) => {
346
1159
  const h = createHash("sha1");
@@ -411,9 +1224,12 @@ export function defaultOutPath(originalInput: string): string {
411
1224
  * directory (a folder run's clips folder, or — the reviewer's pre-existing
412
1225
  * "latent" case — a file run's own folder once a mezzanine gets built)
413
1226
  * 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`.
1227
+ * already spent the minutes getting there. The framing bake was the one path
1228
+ * that ever analysed a file other than `input` (always written into `work`);
1229
+ * since framing became render-props (2026-08-16) the caller passes
1230
+ * `inputIsAnalysisInput: true`, and the parameter survives as the contract —
1231
+ * any future non-input analysis file must live in `work` — with the
1232
+ * mezzanine build as the remaining path into `work`.
417
1233
  */
418
1234
  export function planRenderPublicDir(p: {
419
1235
  input: string;
@@ -538,6 +1354,33 @@ export function sideImageDestRel(src: string): string {
538
1354
  */
539
1355
  const PRIMARY_VIDEO_SLOT_AREA = 0.2;
540
1356
 
1357
+ /**
1358
+ * Every layout's video-slot shape for the producer's framing brief: aspect
1359
+ * in OUTPUT pixels, plus whether the slot is the SUBJECT (see
1360
+ * PRIMARY_VIDEO_SLOT_AREA above) rather than an inset. `frame` must reach
1361
+ * layoutSlots itself, not just the pixel multiply: layoutSlots defaults to
1362
+ * PORTRAIT_FRAME, and the R15 split layouts change GEOMETRY with orientation
1363
+ * — split-left is a {w:1, h:0.5} stack in portrait but a {w:0.5, h:1} side
1364
+ * panel in landscape — so omitting it fed portrait slot fractions times
1365
+ * landscape pixel dims to the brief, marking the wrong layouts UNAVAILABLE
1366
+ * on every 16:9 run (latent since R15 landscape support; surfaced by the
1367
+ * 2026-08-16 incident audit). Pure so both orientations are testable
1368
+ * without an LLM run.
1369
+ */
1370
+ export function layoutSlotAspects(frame: {
1371
+ width: number;
1372
+ height: number;
1373
+ }): { layout: Layout; slotAspect: number; primary: boolean }[] {
1374
+ return LayoutSchema.options.map((layout) => {
1375
+ const v = layoutSlots(layout, DEFAULT_FACE, [], frame).video;
1376
+ return {
1377
+ layout,
1378
+ slotAspect: (v.rect.w * frame.width) / (v.rect.h * frame.height),
1379
+ primary: v.opacity > 0 && v.rect.w * v.rect.h >= PRIMARY_VIDEO_SLOT_AREA,
1380
+ };
1381
+ });
1382
+ }
1383
+
541
1384
  async function preflight(bin: string, hint: string): Promise<void> {
542
1385
  try {
543
1386
  await run(bin, ["-version"], { allowNonZero: true });
@@ -564,7 +1407,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
564
1407
  // video" image lookup both used to read the REASSIGNED `input`, which for a
565
1408
  // folder run is a file inside the hidden workdir, not anything the user
566
1409
  // would recognise.
567
- const originalInput = isAbsolute(inputArg) ? inputArg : resolve(baseCwd, inputArg);
1410
+ // expandHome first (2026-08-16 incident, see paths.ts): a `~/` path that
1411
+ // reaches us unexpanded — the wizard's text prompts, a quoted argv — must
1412
+ // never be resolved against cwd.
1413
+ const originalInput = isAbsolute(inputArg)
1414
+ ? inputArg
1415
+ : resolve(baseCwd, expandHome(inputArg));
568
1416
  let input = originalInput;
569
1417
  if (!existsSync(input)) throw new Error(`input not found: ${input}`);
570
1418
 
@@ -588,6 +1436,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
588
1436
  throw new Error("--clip-window is recorded by --clip runs for replay — pass --clip too.");
589
1437
  }
590
1438
 
1439
+ // Resolved HERE, not at the render section (2026-08-16 field incident): a
1440
+ // bad out path must fail (or be healed) in the first second, not at the
1441
+ // rename after a 50-minute render — a wizard-typed `~/Downloads/...`
1442
+ // resolved against cwd and the end-of-run rename ENOENT'd because the
1443
+ // parent never existed. mkdir over refusal: the path is the user's explicit
1444
+ // intent and creating a folder is what they'd do by hand; a genuinely
1445
+ // un-creatable path (permissions) still fails loudly, now upfront.
1446
+ const outArg = opts.out !== undefined ? expandHome(opts.out) : undefined;
1447
+ const outPath = outArg
1448
+ ? isAbsolute(outArg)
1449
+ ? outArg
1450
+ : resolve(baseCwd, outArg)
1451
+ : resolve(defaultOutPath(originalInput));
1452
+ ensureParentDir(outPath);
1453
+
591
1454
  await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
592
1455
  await preflight(cfg.ffprobePath, "Run `ossclip setup`, install ffmpeg (provides ffprobe), or set OSSCLIP_FFPROBE.");
593
1456
 
@@ -598,6 +1461,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
598
1461
 
599
1462
  const tools = { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath };
600
1463
 
1464
+ // Resolved ONCE for the whole run — whisper biasing, repair vouching and
1465
+ // caption casing must all see the same list, or the passes disagree about
1466
+ // what a term is spelled like. A typed --dictionary wholesale beats the
1467
+ // config (resolveWatermark's typed-beats-config precedence, no merging).
1468
+ const configDictionary = validDictionary(cfg.dictionary);
1469
+ if (opts.dictionary === undefined && cfg.dictionary !== undefined && configDictionary === undefined) {
1470
+ console.log("⚠ config dictionary ignored — expected an array of non-empty strings");
1471
+ }
1472
+ const dictionary = opts.dictionary ?? configDictionary ?? [];
1473
+ if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
1474
+
1475
+ // The run's base theme (F6): config theme over defaultTheme, resolved once
1476
+ // and used for BOTH resolveTheme's base and props.baseTheme below — the
1477
+ // editor's reset must land on the user's global colors, not the factory's.
1478
+ const { theme: configBaseTheme, warning: themeWarning } = configuredBaseTheme(cfg.theme);
1479
+ if (themeWarning) console.log(themeWarning);
1480
+
601
1481
  // Folder input (folder-input-brief.md, 2026-08-05 field request). The
602
1482
  // workdir hash is derived from the folder's CONTENT — `folderManifestKey`
603
1483
  // over the enumerated clips — not the folder path. Review fix: a path-only
@@ -629,7 +1509,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
629
1509
  } else {
630
1510
  hash = (await sha1File(input)).slice(0, 8);
631
1511
  }
632
- const work = deriveWorkdir(input, hash, opts.workdir, landscape);
1512
+ // expandHome at the call site so deriveWorkdir stays homedir-free.
1513
+ const work = deriveWorkdir(
1514
+ input,
1515
+ hash,
1516
+ opts.workdir !== undefined ? expandHome(opts.workdir) : undefined,
1517
+ landscape,
1518
+ );
633
1519
  await mkdir(work, { recursive: true });
634
1520
  console.log(`▸ workdir ${work}`);
635
1521
 
@@ -756,9 +1642,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
756
1642
  // the flag exists for — and equally defeated the model A/B the
757
1643
  // --whisper-model help text advertises.
758
1644
  const transcriptKeyPath = join(work, "transcript-key.json");
1645
+ const requestedModel = opts.whisperModel ?? cfg.model;
1646
+ // Flag > config > the curated table's model-implied language (a non-English
1647
+ // fine-tune without `-l` decodes garbage — Urdu field test 2026-08-05).
1648
+ // Resolved BEFORE the key so a config/model-sourced language re-keys the
1649
+ // cache exactly like the typed flag does.
1650
+ const whisperLang = resolveWhisperLanguage(
1651
+ opts.whisperLanguage,
1652
+ cfg.language,
1653
+ modelImpliedLanguage(requestedModel),
1654
+ );
1655
+ if (whisperLang.warning) console.log(whisperLang.warning);
1656
+ if (whisperLang.source === "config" || whisperLang.source === "model") {
1657
+ console.log(
1658
+ `▸ whisper language: ${whisperLang.language} ` +
1659
+ `(from ${whisperLang.source === "config" ? "config" : `model ${requestedModel}`}; ` +
1660
+ `--whisper-language overrides)`,
1661
+ );
1662
+ }
759
1663
  const requestedKey: TranscriptKey = {
760
- model: opts.whisperModel ?? cfg.model,
761
- ...(opts.whisperLanguage !== undefined ? { language: opts.whisperLanguage } : {}),
1664
+ model: requestedModel,
1665
+ ...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
1666
+ // Omitted when empty, not written as [] — pre-dictionary key files have
1667
+ // no `dictionary` at all, and transcriptCacheReusable reads absent and
1668
+ // empty as the same "no biasing", so old workdirs must not re-transcribe.
1669
+ ...(dictionary.length > 0 ? { dictionary } : {}),
762
1670
  };
763
1671
  let cacheVerdict: ReturnType<typeof transcriptCacheReusable> | null = null;
764
1672
  if (!opts.transcript && existsSync(transcriptCache)) {
@@ -768,7 +1676,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
768
1676
  cacheVerdict = transcriptCacheReusable(recorded, requestedKey, cfg.model);
769
1677
  }
770
1678
  if (opts.transcript) {
771
- transcript = TranscriptSchema.parse(JSON.parse(await readFile(resolve(opts.transcript), "utf8")));
1679
+ // expandHome before resolve — same 2026-08-16 rule as --out (paths.ts).
1680
+ transcript = TranscriptSchema.parse(
1681
+ JSON.parse(await readFile(resolve(expandHome(opts.transcript)), "utf8")),
1682
+ );
772
1683
  console.log(`▸ transcript injected from ${opts.transcript} (${transcript.words.length} words)`);
773
1684
  // An injected transcript came from no whisper run at all, so any key left
774
1685
  // by an earlier one would mislabel the cache this branch overwrites below.
@@ -791,12 +1702,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
791
1702
  "Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
792
1703
  );
793
1704
  const model = requestedKey.model;
794
- const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
1705
+ // whisperModelPath/modelUrl are THE resolution and URL sources (shared
1706
+ // with doctor and setup) — this error used to hold its own copy of the
1707
+ // ggerganov URL, which 404'd for curated/custom names and the suggested
1708
+ // `curl -L` then saved the 404 HTML as a fake model.
1709
+ const modelPath = whisperModelPath(model, cfg.modelDir);
795
1710
  if (!existsSync(modelPath)) {
796
1711
  throw new Error(
797
1712
  `whisper model not found at ${modelPath}.\n` +
798
1713
  `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`,
1714
+ ` curl -L -o ${modelPath} ${modelUrl(model, validModelSources(cfg.modelSources))}`,
800
1715
  );
801
1716
  }
802
1717
  const whisperAnim = isInteractive()
@@ -813,7 +1728,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
813
1728
  whisperPath: cfg.whisperPath,
814
1729
  modelPath,
815
1730
  outBase: join(work, "whisper"),
816
- language: opts.whisperLanguage,
1731
+ // The RESOLVED language, not the raw flag — a config/model-implied
1732
+ // code must reach the spawn exactly as it reached the cache key.
1733
+ language: requestedKey.language,
1734
+ // Vocabulary biasing (F4) — undefined for an empty dictionary, so
1735
+ // the spawned args stay byte-identical to every pre-dictionary run.
1736
+ prompt: whisperPromptFor(dictionary),
817
1737
  },
818
1738
  audioPath,
819
1739
  ),
@@ -849,7 +1769,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
849
1769
  // repair — the repair pass reads a bare "blooper." as an oddity and has
850
1770
  // already been observed proposing "break loop." for it. Detecting first
851
1771
  // means the marker cannot be rewritten out from under the detector.
852
- let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker) : [];
1772
+ // `silences` rides along so the cut can extend through the marker's own
1773
+ // trailing dead air — §18 stamp-stretch put the stamped end of a spoken
1774
+ // "blooper." 0.4s before its acoustic end (2026-08-16 incident, see
1775
+ // MAX_MARKER_BLEED_SEC in blooper.ts).
1776
+ let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker, silences) : [];
853
1777
  if (opts.blooperMarker) {
854
1778
  console.log(
855
1779
  bloops.length > 0
@@ -861,24 +1785,30 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
861
1785
  // Deterministic retake collapse (R27 §128) — the flub the speaker did NOT
862
1786
  // mark. Same RAW-transcript-before-repair ordering as the blooper marker
863
1787
  // 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
1788
+ // oddity would rewrite the exact pattern this looks for. Gated on the
1789
+ // marker, not --collapse-retakes (inferredRetakesEnabled has the user's
1790
+ // verbatim rule); the legacy flag typed alone earns a notice, not silence.
1791
+ const retakesEnabled = inferredRetakesEnabled(opts.blooperMarker);
1792
+ if (opts.collapseRetakes && !retakesEnabled) {
1793
+ console.log("▸ collapse-retakes: skipped — retake detection runs only with --blooper-marker");
1794
+ }
1795
+ let retakeGroups = retakesEnabled
866
1796
  ? findRetakeGroups(transcript, analysis, { transparentMarker: opts.blooperMarker })
867
1797
  : [];
868
- let retakes = retakeGroups.flatMap((g) => g.cuts);
869
- if (opts.collapseRetakes) {
1798
+ let retakes = retakeCutsFor(retakeGroups);
1799
+ if (retakesEnabled) {
870
1800
  // `exact` never cuts anything — buildCutlist's own early return collapses
871
1801
  // to one whole-duration `keep` regardless of what's in `retakes` — so
872
1802
  // "N group(s), M take(s) cut" here was a claim the run never honored.
873
1803
  // Same fact `valveFired` below already checks; gated the same way
874
1804
  // (final-review fix wave, cheap minor b).
875
1805
  if (opts.cleanup === "exact") {
876
- console.log("▸ collapse-retakes: --cleanup exact wins — nothing cut");
1806
+ console.log("▸ retakes: --cleanup exact wins — nothing cut");
877
1807
  } else {
878
1808
  console.log(
879
1809
  retakeGroups.length > 0
880
- ? `▸ collapse-retakes: ${retakeGroups.length} group(s), ${retakes.length} take(s) cut`
881
- : "▸ collapse-retakes: no retakes found",
1810
+ ? `▸ retakes: ${retakeGroups.length} group(s), ${retakes.length} take(s) cut`
1811
+ : "▸ retakes: none found",
882
1812
  );
883
1813
  for (const g of retakeGroups) {
884
1814
  for (const line of formatRetakeGroup(transcript, g).split("\n")) console.log(` ▸ ${line}`);
@@ -910,7 +1840,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
910
1840
  const valveFired = opts.cleanup !== "exact" && cutlist.length === 1 && cutlist[0]!.kind === "keep";
911
1841
  if (retakes.length > 0 && valveFired) {
912
1842
  console.log(
913
- " ⚠ collapse-retakes found a retake, but the sanity valve reset the whole cutlist — nothing was cut",
1843
+ " ⚠ retake collapse found a retake, but the sanity valve reset the whole cutlist — nothing was cut",
914
1844
  );
915
1845
  }
916
1846
 
@@ -922,7 +1852,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
922
1852
  // mishearing can't reach the screen twice in two different spellings.
923
1853
  const providerName = opts.provider ?? defaultProviderName(process.env, binOnPath);
924
1854
  let provider: LlmProvider | null = null;
925
- const needsLlm = opts.produce === true;
1855
+ // --youtube brings its own provider (field gap, 2026-08-16): the user's
1856
+ // real command was `--youtube --llm antigravity` WITHOUT --produce, and the
1857
+ // pack skipped with "needs an LLM provider" — a flag that exists to call an
1858
+ // LLM must count as opting into one. This also turns transcript repair on
1859
+ // for such runs, which is the dictionary's caption-side fix ("Jason" →
1860
+ // "JSON") — biasing whisper alone does not correct what ASR already heard.
1861
+ const needsLlm = opts.produce === true || resolveYoutube(opts.youtube, cfg.youtube);
926
1862
  if (needsLlm) {
927
1863
  // Only when auto-detected: a typed --llm needs no explanation. The line
928
1864
  // itself lives in llm-detect.ts so a drift test covers every provider —
@@ -947,6 +1883,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
947
1883
  opts.llmModel,
948
1884
  opts.llmFastModel ?? cfg.fastModel,
949
1885
  opts.speaker ?? cfg.speaker,
1886
+ // The dictionary changes both the prompt and the vouched set (F4),
1887
+ // so cached repairs from a different vocabulary must not be reused.
1888
+ dictionary,
950
1889
  rawTranscript.words.map((w) => w.text),
951
1890
  ]),
952
1891
  )
@@ -958,6 +1897,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
958
1897
  transcript = applyRepairs(
959
1898
  rawTranscript,
960
1899
  repairs.filter((r) => r.applied),
1900
+ // The vouched set must survive the cache: a dictionary-vouched
1901
+ // correction re-runs applyRepairs' guards here, and without the
1902
+ // dictionary the phonetic gate would refuse on replay what it
1903
+ // accepted on the first run.
1904
+ { dictionary },
961
1905
  ).transcript;
962
1906
  console.log(`▸ repairs cached (${repairs.filter((r) => r.applied).length})`);
963
1907
  } else {
@@ -971,6 +1915,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
971
1915
  const result = await phases.time("llm", () =>
972
1916
  repairTranscript(provider!, rawTranscript, {
973
1917
  speaker: opts.speaker ?? cfg.speaker,
1918
+ // Vouched terms (F4): named in the prompt AND exempt, when a
1919
+ // correction is built entirely of them, from the phonetic gate.
1920
+ dictionary,
974
1921
  // A repair may not merge words across a cut.
975
1922
  isCut: (startSec, endSec) =>
976
1923
  cutlist.some(
@@ -1009,12 +1956,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1009
1956
  // ---- Framing measurement (PLAN Tasks A+B) --------------------------------
1010
1957
  /**
1011
1958
  * 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.
1959
+ * that alternates framings gets ONE field of view — every segment windowed
1960
+ * to the tightest framing the take ever shows, placed on that segment's own
1961
+ * measured face. The PLAN is computed here, before the producer, because
1962
+ * the producer needs the framing brief: which word ranges are close shots,
1963
+ * and which layouts those rule out. The plan used to be BAKED into a
1964
+ * re-encoded file after the scenes existed; since 2026-08-16 it is emitted
1965
+ * as `framingTimeline` render-props instead (see the props assembly below).
1018
1966
  */
1019
1967
  let framingPlan: NormalizePlan | null = null;
1020
1968
  if (!detection.uniform) {
@@ -1071,9 +2019,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1071
2019
  * `production.json`, the report, and the command.json pin below. */
1072
2020
  let clipWindow: ClipWindow | null = null;
1073
2021
  if (opts.scenes) {
1074
- scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(resolve(opts.scenes), "utf8")));
2022
+ // expandHome before resolve — same 2026-08-16 rule as --out (paths.ts).
2023
+ scenes = z.array(SceneSchema).parse(
2024
+ JSON.parse(await readFile(resolve(expandHome(opts.scenes)), "utf8")),
2025
+ );
1075
2026
  console.log(`▸ scenes injected from ${opts.scenes} (${scenes.length})`);
1076
- } else if (provider) {
2027
+ } else if (provider && opts.produce === true) {
2028
+ // `opts.produce`, not bare `provider` (2026-08-16): --youtube now brings
2029
+ // a provider for its metadata/repair, and the bare-provider gate silently
2030
+ // turned GRAPHICS on for a run that never asked for them — 12 surprise
2031
+ // scenes on a plain-cut video. A provider is a capability; --produce is
2032
+ // the consent.
1077
2033
  // Keyed on the repaired transcript's TEXT, not its word count: a repair
1078
2034
  // that swaps "coach and" for "code churn" leaves the count identical, and
1079
2035
  // a count-keyed cache would silently replan from the stale wording.
@@ -1090,14 +2046,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1090
2046
  faceFracOfCanvas: framingPlan.faceFracOfCanvas[i] ?? 0,
1091
2047
  })),
1092
2048
  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
- }),
2049
+ layouts: layoutSlotAspects(frame),
1101
2050
  zoom: ZOOM_MAX_SCALE,
1102
2051
  }
1103
2052
  : undefined;
@@ -1166,11 +2115,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1166
2115
  analysis = analyze(rawTranscript, silences, sourceProbe.duration, levels);
1167
2116
  // Re-detect on the SLICE: word indices moved, so the spans found against
1168
2117
  // the full take no longer address the same words.
1169
- bloops = opts.blooperMarker ? findBloopSpans(rawTranscript, opts.blooperMarker) : [];
1170
- retakeGroups = opts.collapseRetakes
2118
+ // Full-source `silences` on a sliced transcript is correct on purpose:
2119
+ // sliceRawTranscript keeps SOURCE seconds on every word stamp, so the
2120
+ // bleed extension compares like with like.
2121
+ bloops = opts.blooperMarker
2122
+ ? findBloopSpans(rawTranscript, opts.blooperMarker, silences)
2123
+ : [];
2124
+ retakeGroups = retakesEnabled
1171
2125
  ? findRetakeGroups(rawTranscript, analysis, { transparentMarker: opts.blooperMarker })
1172
2126
  : [];
1173
- retakes = retakeGroups.flatMap((g) => g.cuts);
2127
+ retakes = retakeCutsFor(retakeGroups);
1174
2128
  cutlist = boundCutlistToWindow(
1175
2129
  buildCutlist({
1176
2130
  transcript: rawTranscript,
@@ -1358,6 +2312,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1358
2312
  }
1359
2313
  }
1360
2314
 
2315
+ // Deterministic dictionary casing (F4) — LAST word edit before the caption
2316
+ // build: after the repair reassignment and reconcileCopy above, so nothing
2317
+ // can lower-case a term back after this. Exact-token matches only ("json."
2318
+ // → "JSON."; "Jason" stays — phonetic judgement is the repair pass's job,
2319
+ // see dictionary.ts). `rawTranscript` stays untouched on purpose:
2320
+ // production.json stores the RAW words that `analysis`/`cutlist` index.
2321
+ transcript = canonicalizeDictionaryCasing(transcript, dictionary);
2322
+
1361
2323
  // Landscape keeps the frame whole (R15): the split-screen layouts are
1362
2324
  // vertical-format answers, and applying them to 16:9 crops the picture into
1363
2325
  // a letterbox for no gain. Remapped here — before assembly — so cues,
@@ -1472,45 +2434,49 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1472
2434
  const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
1473
2435
  for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
1474
2436
 
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 = "";
2437
+ // ---- Framing plan → props (2026-08-16 incident) --------------------------
2438
+ // The plan used to be BAKED here: every window cropped, scaled and
2439
+ // re-encoded into a content-<hash>.mp4 that replaced the source for the
2440
+ // whole rest of the pipeline. That was irreversible — a bad crop's only
2441
+ // remedy was deleting the baked file — and invisible: the bake path also
2442
+ // suppressed the `sourceSize` keys in render-props, so the editor could
2443
+ // not even see the crop it was fighting. The plan now travels as DATA
2444
+ // (`framingTimeline`, emitted with the props below) and the renderer
2445
+ // applies each window as a transform the editor can see and counteract.
2446
+ // Old workdirs' baked content-*.mp4 stay on disk, inert: their own
2447
+ // render-props reference them by name and must keep rendering.
1484
2448
  let fitFallback = false;
1485
2449
  if (framingPlan) {
1486
2450
  const plan = framingPlan;
1487
2451
  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);
2452
+ console.log(
2453
+ `▸ framing: ${plan.segments.length} windows rendered from props (no re-encode)`,
2454
+ );
1503
2455
  } else {
1504
2456
  fitFallback = true;
2457
+ // A refusal names the number that tripped the gate. The upscale bound
2458
+ // is softness; the discard bound is picture loss — the 2026-08-16
2459
+ // incident's plan discarded 37% of the frame area and the old log
2460
+ // (upscale-only) never said so. The per-segment screen-loss bound has
2461
+ // no single headline number, so it reads as the residual case.
2462
+ const why = [
2463
+ ...(plan.coverUpscale > MAX_NORMALIZE_UPSCALE
2464
+ ? [`would upscale ×${plan.coverUpscale.toFixed(2)} > ${MAX_NORMALIZE_UPSCALE}`]
2465
+ : []),
2466
+ ...(plan.areaDiscardWeighted > MAX_MEAN_AREA_DISCARD
2467
+ ? [`would discard ${(plan.areaDiscardWeighted * 100).toFixed(0)}% of the picture`]
2468
+ : []),
2469
+ ];
1505
2470
  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; ` +
2471
+ ` ⚠ strip too small to unify (${
2472
+ why.length > 0 ? why.join("; ") : "a screen segment would lose its content"
2473
+ }) — letterboxed stretches render FITTED at natural size; ` +
1508
2474
  `framing will visibly change at ${contentTimeline.length - 1} boundaries`,
1509
2475
  );
1510
2476
  }
1511
2477
  }
1512
2478
  const contentRect: ContentRect = detection.uniform ?? {
1513
- x: 0, y: 0, w: analysisProbe.width, h: analysisProbe.height, full: true,
2479
+ x: 0, y: 0, w: sourceProbe.width, h: sourceProbe.height, full: true,
1514
2480
  };
1515
2481
  /** The picture's dimensions — what every geometric consumer reasons about. */
1516
2482
  const content = { width: contentRect.w, height: contentRect.h };
@@ -1519,7 +2485,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1519
2485
  // not two independent copies of the same condition that could silently
1520
2486
  // drift apart (Finding 3, final-review fix wave: that drift is exactly
1521
2487
  // what let an accepted image 404 inside Remotion's staticFile()).
1522
- const mezzanineWillBuild = analysisInput === input && (opts.mezzanine || !contentRect.full);
2488
+ // The old `analysisInput === input` term is gone WITH the bake: the bake
2489
+ // was the only thing that ever pointed analysis at a different file, so
2490
+ // with framing as props the analysis input IS the source, always.
2491
+ const mezzanineWillBuild = opts.mezzanine || !contentRect.full;
1523
2492
 
1524
2493
  // Face measurement (FINDINGS §13): one static crop offset per source,
1525
2494
  // measured rather than guessed; cached in the workdir like the transcript.
@@ -1531,10 +2500,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1531
2500
  "render",
1532
2501
  ).start()
1533
2502
  : null;
1534
- const faceBox = await measureFace(tools, analysisInput, analysisProbe.duration, {
2503
+ const faceBox = await measureFace(tools, input, sourceProbe.duration, {
1535
2504
  cacheDir: work,
1536
- cropVf: analysisCropVf,
1537
- cacheTag,
2505
+ cropVf,
1538
2506
  samples: faceSamples,
1539
2507
  });
1540
2508
  if (faceSampleAnim) faceSampleAnim.stop();
@@ -1550,21 +2518,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1550
2518
  "the crop may be wrong, check the output",
1551
2519
  );
1552
2520
 
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
- }
2521
+ // Whatever the source's aspect doesn't share with the frame, a cover crop
2522
+ // trims — and how much is arithmetic, not opinion. Said out loud because
2523
+ // the result LOOKS deliberate — a tight talking head — and nothing else in
2524
+ // the run would mention that the desk, the screen and the second person
2525
+ // are simply gone. Orientation-neutral on purpose: the old `!landscape`
2526
+ // gate assumed a 16:9 output never crops a 16:9-ish source, and the
2527
+ // 2026-08-16 incident (a 1.547:1 screen recording in a 16:9 frame — 13% of
2528
+ // the height silently gone, 28% post-normalization) shipped without a word.
2529
+ const coverKeep = coverKeepFraction(content, frame);
2530
+ if (opts.sourceFit !== "contain" && coverKeep && coverKeep.kept < 0.95) {
2531
+ console.log(
2532
+ `▸ source is ${(content.width / content.height).toFixed(2)}:1 — a full-frame crop keeps ` +
2533
+ `${(coverKeep.kept * 100).toFixed(0)}% of its ${coverKeep.axis}. ` +
2534
+ "Use --source-fit contain to show the whole frame instead.",
2535
+ );
1568
2536
  }
1569
2537
 
1570
2538
  // ---- Route around the source's own burned-in text (FINDINGS §26) --------
@@ -1582,11 +2550,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1582
2550
  // face. Routing around a hazard only pays when there is a hazard, and only
1583
2551
  // the user knows whether their source is already edited.
1584
2552
  const sourceText = opts.sourceIsEdited
1585
- ? await scanSourceText(tools, analysisInput, analysisProbe.duration, {
2553
+ ? await scanSourceText(tools, input, sourceProbe.duration, {
1586
2554
  cacheDir: work,
1587
2555
  assumeEdited: true,
1588
- cropVf: analysisCropVf,
1589
- cacheTag,
2556
+ cropVf,
1590
2557
  })
1591
2558
  : { regions: [], assumed: false, framesSampled: 0 };
1592
2559
  if (sourceText.regions.length > 0) {
@@ -1666,7 +2633,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1666
2633
  if (hiddenIds.length > 0) {
1667
2634
  console.log(`▸ ${hiddenIds.length} scene(s) hidden by the edit layer: ${hiddenIds.join(", ")}`);
1668
2635
  }
1669
- const theme = resolveTheme(defaultTheme, overrideDoc);
2636
+ // Config theme as the BASE (F6): overrides.json > config theme >
2637
+ // defaultTheme. The same `configBaseTheme` feeds props.baseTheme below.
2638
+ const theme = resolveTheme(configBaseTheme, overrideDoc);
1670
2639
 
1671
2640
  // A pin freezes a scene's ABSOLUTE time against whatever its neighbours'
1672
2641
  // timing was when it was set. This same plan may since have re-anchored
@@ -1825,7 +2794,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1825
2794
  const sideDirs = isFolder ? [work, originalInput] : [work, dirname(input)];
1826
2795
  const renderPublicDirPath = planRenderPublicDir({
1827
2796
  input,
1828
- inputIsAnalysisInput: analysisInput === input,
2797
+ // Literal since the framing bake became render-props (2026-08-16): the
2798
+ // bake was the only path that ever analysed a file other than the input.
2799
+ // The parameter (and its platform matrix test) stays, because it encodes
2800
+ // the contract "a non-input analysis file must live in `work`" — the
2801
+ // thing any future re-introduction of such a file has to get right.
2802
+ inputIsAnalysisInput: true,
1829
2803
  mezzanineWillBuild,
1830
2804
  work,
1831
2805
  });
@@ -1952,11 +2926,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1952
2926
  // §128: same reasoning as §122's block above, for the flub the speaker did
1953
2927
  // NOT say a marker over — kept / cut (with similarity) / ignored as a
1954
2928
  // 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.
2929
+ // already trusts. Runs automatically with --blooper-marker (2026-08-16
2930
+ // gate decision, inferredRetakesEnabled); a clean run recorded here is
2931
+ // still the promotion evidence the §128 appendix asks for.
1957
2932
  if (retakeGroups.length > 0) {
1958
2933
  report +=
1959
- "\nretakes collapsed (--collapse-retakes — FINDINGS §128):\n" +
2934
+ "\nretakes collapsed (runs with --blooper-marker — FINDINGS §128):\n" +
1960
2935
  retakeGroups.map((g) => formatRetakeGroup(rawTranscript, g)).join("\n") +
1961
2936
  "\n";
1962
2937
  }
@@ -2030,6 +3005,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2030
3005
  // the fill's derived boundaries re-split caption lines would change
2031
3006
  // caption output for zero visual reason (PLAN Task A4.4).
2032
3007
  breakpoints: graphicCues.flatMap((c) => [c.startSec, c.endSec]),
3008
+ // Orientation-dependent packing (captionPackingFor has the budget math):
3009
+ // portrait gets the core defaults verbatim, landscape doubles them.
3010
+ ...captionPackingFor(landscape),
2033
3011
  });
2034
3012
  // §137 (Task 6 review, Critical 1): the caption half of a run — migrate the
2035
3013
  // doc's keys, apply what applies, and account for the rest — is one pure
@@ -2062,21 +3040,111 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2062
3040
  const captionKeysReanchored = captionWork.reanchored;
2063
3041
  for (const line of captionWork.log) console.log(line);
2064
3042
 
3043
+ // Const re-binding of the accepted plan so its narrowing survives into the
3044
+ // `framingTimeline` map closure below — `framingPlan` is a `let`, and TS
3045
+ // drops a `let`'s narrowing inside callbacks.
3046
+ const acceptedFramingPlan = framingPlan?.ok ? framingPlan : null;
3047
+ // Hoisted out of the props spread because TWO consumers need it: the
3048
+ // render-props emission below and the per-span subject mask both motion
3049
+ // drivers gate on. Skipped under `--source-fit contain` — contain shows the
3050
+ // WHOLE frame, and a framing plan's cover windows would fight it — so the
3051
+ // subject gate reads the same timeline the renderer will actually see.
3052
+ const framingTimeline: FramingSegment[] | null =
3053
+ acceptedFramingPlan && opts.sourceFit !== "contain"
3054
+ ? acceptedFramingPlan.segments.map(
3055
+ (s, i): FramingSegment => ({
3056
+ startSec: s.startSec,
3057
+ endSec: s.endSec,
3058
+ window: s.window,
3059
+ subject: acceptedFramingPlan.subject[i] ?? "screen",
3060
+ bias: acceptedFramingPlan.bias[i] ?? { x: 0.5, y: 0.5 },
3061
+ }),
3062
+ )
3063
+ : null;
3064
+ const jumpCutsMode = resolveJumpCuts(opts.jumpCuts);
3065
+ const globalSubject = faceSubject(faceBox);
3066
+ // ONE verdict array feeds BOTH motion drivers (spanFaceMask has the why) —
3067
+ // computed before either plan so neither can be built from a stale or
3068
+ // re-derived copy that disagrees with the other.
3069
+ //
3070
+ // 2026-08-16 v2 review: with NO framing plan (uniform content rects) the
3071
+ // old flat `spanFaceMask(…, null, globalSubject)` let the whole-take PiP
3072
+ // verdict speak for the full-frame face stretches inside a screen
3073
+ // recording, so those spans lost punch concealment and idle zoom — the
3074
+ // mask must be MEASURED per span instead (spanFaceMaskFromFaces has the
3075
+ // full why). Cached: `measureFaceInWindows` itself does not cache, and a
3076
+ // ~55-span take is a few hundred single-frame ffmpeg spawns.
3077
+ let spanIsFaceOnly: boolean[];
3078
+ if (framingTimeline) {
3079
+ spanIsFaceOnly = spanFaceMask(map.spans, framingTimeline, globalSubject);
3080
+ } else {
3081
+ const spanFaceCache = join(work, `face-spans-${spanFaceCacheKey(map.spans, hash)}.json`);
3082
+ let measured: boolean[] | null = null;
3083
+ if (existsSync(spanFaceCache)) {
3084
+ measured = z.array(z.boolean()).length(map.spans.length)
3085
+ .parse(JSON.parse(await readFile(spanFaceCache, "utf8")));
3086
+ } else {
3087
+ const maskAnim = isInteractive()
3088
+ ? new StageAnimator(
3089
+ "SUBJECT TRACKING",
3090
+ `Measuring who the subject is across ${map.spans.length} kept spans...`,
3091
+ "render",
3092
+ ).start()
3093
+ : null;
3094
+ try {
3095
+ const spanFaces = await measureFaceInWindows(
3096
+ tools,
3097
+ input,
3098
+ spanFaceWindows(map.spans),
3099
+ { workDir: work },
3100
+ );
3101
+ measured = spanFaceMaskFromFaces(spanFaces);
3102
+ await writeFile(spanFaceCache, JSON.stringify(measured));
3103
+ } catch (err) {
3104
+ // NEVER cache a FAILURE (§106) — and the punch/zoom are polish, not
3105
+ // the product, so a dead measurement falls back to the whole-take
3106
+ // verdict (the pre-2026-08-16 behavior) rather than killing the run.
3107
+ console.log(
3108
+ ` ⚠ per-span face measurement failed (${err instanceof Error ? err.message : String(err)})` +
3109
+ " — every span shares the whole-take verdict this run",
3110
+ );
3111
+ } finally {
3112
+ if (maskAnim) maskAnim.stop();
3113
+ }
3114
+ }
3115
+ spanIsFaceOnly = measured ?? spanFaceMask(map.spans, null, globalSubject);
3116
+ if (measured) {
3117
+ const faceSpans = measured.filter(Boolean).length;
3118
+ console.log(
3119
+ `▸ subject per span (measured): ${faceSpans} face-only, ` +
3120
+ `${measured.length - faceSpans} screen`,
3121
+ );
3122
+ }
3123
+ }
3124
+ // The jump-cut punch plan (Task 6): mode from the flag pair, gated per
3125
+ // span by who the subject is where that span BEGINS — the frame at the
3126
+ // cut is what the punch scales. Without a framing plan every span shares
3127
+ // the whole-take verdict, the same `face.subject` the stage bias reads.
3128
+ const punch = punchPlanFor(map.spans, jumpCutsMode, spanIsFaceOnly);
3129
+
2065
3130
  // Micro zoom punches (FINDINGS §15) reversing at real phrase breaks (§18).
2066
3131
  // Breaths are source-time; TimeMap has no span mapper, so both ends go
2067
3132
  // through toOutputClamped — a pause that was cut collapses to one instant,
2068
3133
  // which is still a boundary (a jump cut is a phrase break too).
2069
3134
  // One move per cut-free clip: ramp in, then hold. The clip starts ARE the
2070
3135
  // cuts — every point the source jumps — so a take that removed nothing is
2071
- // one clip and gets exactly one slow push.
3136
+ // one clip and gets exactly one slow push. Face-only since 2026-08-16
3137
+ // (same mask as the punch): a screen-subject clip gets NO push at all.
2072
3138
  const zoomOff = opts.zoom === false;
2073
3139
  const zoom = buildZoomPlan(map.outputDuration, {
2074
3140
  clipStarts: map.spans.map((s) => s.outIn),
3141
+ allowedClips: spanIsFaceOnly,
2075
3142
  });
2076
3143
  console.log(
2077
3144
  zoomOff
2078
3145
  ? "▸ zoom: off (--no-zoom) — static camera; jump cuts land unconcealed"
2079
- : `▸ zoom: ${zoom.clips} clip(s), ${zoom.rampSec}s push then hold ` +
3146
+ : `▸ zoom: ${zoom.zoomedClips} clip(s) zoomed, ${zoom.staticClips} static ` +
3147
+ `(screen subject), ${zoom.rampSec}s push then hold ` +
2080
3148
  `(${zoom.segments.length} segments)`,
2081
3149
  );
2082
3150
 
@@ -2101,7 +3169,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2101
3169
  // same head-fits rule would report a defect for working as designed.
2102
3170
  const issues = assessCueFraming(
2103
3171
  graphicCues.flatMap((c) => {
2104
- const v = layoutSlots(c.layout).video;
3172
+ // `frame` must reach layoutSlots, not just the pixel multiply below —
3173
+ // it defaults to PORTRAIT_FRAME, and the R15 split layouts change
3174
+ // geometry with orientation (split-left: {w:1, h:0.5} stacked in
3175
+ // portrait, {w:0.5, h:1} side panel in landscape), so omitting it
3176
+ // judged 16:9 cues against portrait slot shapes. Latent since R15
3177
+ // landscape support; see layoutSlotAspects for the twin brief-side bug.
3178
+ const v = layoutSlots(c.layout, DEFAULT_FACE, [], frame).video;
2105
3179
  if (v.opacity <= 0 || v.rect.w * v.rect.h < PRIMARY_VIDEO_SLOT_AREA) return [];
2106
3180
  return [{
2107
3181
  id: c.id,
@@ -2133,20 +3207,35 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2133
3207
  }
2134
3208
  }
2135
3209
 
2136
- let renderVideo = analysisInput;
3210
+ let renderVideo = input;
2137
3211
  // A letterboxed source MUST go through the re-encode even under
2138
3212
  // --no-mezzanine: the bars are pixels in the file, and cropping them here is
2139
3213
  // what lets every layout and zoom downstream treat the picture as the frame.
2140
3214
  // 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
3215
  // `mezzanineWillBuild` (computed once, above, with `contentRect`) — not a
2145
3216
  // second copy of this condition — so this can't drift from what
2146
3217
  // `planRenderPublicDir` already decided the accepted-image check against
2147
3218
  // (Finding 3, final-review fix wave).
3219
+ // Display-sized mezzanine (2026-08-17 render-speed pass): computed on the
3220
+ // POST-CROP picture (the crop runs first in the same ffmpeg pass, so
3221
+ // `contentRect` IS what the scale filter sees) against the OUTPUT
3222
+ // frame+fps. Deliberately null when no mezzanine will build (--no-mezzanine
3223
+ // on a bar-free source): there is no re-encode to scale, the render plays
3224
+ // the source itself, and the window emissions below must then stay in true
3225
+ // source pixels — which the identity `mezzFactor` below guarantees.
3226
+ const mezzScale = mezzanineWillBuild
3227
+ ? mezzanineScale(
3228
+ { width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
3229
+ production.render,
3230
+ opts.sourceFit ?? "cover",
3231
+ )
3232
+ : null;
2148
3233
  if (mezzanineWillBuild) {
2149
- const mezz = join(work, contentRect.full ? "mezzanine.mp4" : "mezzanine-content.mp4");
3234
+ // The scale decision rides the FILENAME (`mezzanineFileName` has the
3235
+ // why): mezzanine caching is existence-keyed, so a pre-pass full-res
3236
+ // mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
3237
+ // windows — the scaled file rebuilds once under its own name.
3238
+ const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale));
2150
3239
  if (!existsSync(mezz)) {
2151
3240
  const mezzAnim = isInteractive()
2152
3241
  ? new StageAnimator(
@@ -2164,11 +3253,41 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2164
3253
  : "▸ building mezzanine (dense keyframes, letterbox bars trimmed)…",
2165
3254
  );
2166
3255
  }
2167
- await makeMezzanine(tools, input, mezz, { cropVf: cropVf || undefined });
3256
+ await makeMezzanine(tools, input, mezz, {
3257
+ cropVf: cropVf || undefined,
3258
+ scale: mezzScale ?? undefined,
3259
+ });
2168
3260
  if (mezzAnim) mezzAnim.stop();
2169
3261
  }
3262
+ if (mezzScale) {
3263
+ console.log(
3264
+ `▸ mezzanine: ${contentRect.w}x${contentRect.h}@${Math.round(sourceProbe.fps)} → ` +
3265
+ `${mezzScale.width}x${mezzScale.height}@${Math.round(mezzScale.fps)} ` +
3266
+ `(render-sized — decode is the render bottleneck)`,
3267
+ );
3268
+ }
2170
3269
  renderVideo = mezz;
2171
3270
  }
3271
+ // Window space must equal PLAYED-FILE space: the renderer's crop math
3272
+ // (`contentCoverBox` et al.) positions windows against the file it plays,
3273
+ // so a scaled mezzanine needs every pixel-space emission below scaled by
3274
+ // the same factor. Derived from the actual scaled dims — per axis, because
3275
+ // yuv420 even-rounding makes the two ratios differ by a hair — and
3276
+ // identity whenever the render plays an unscaled file (no mezzanine, or a
3277
+ // source already at display size). `playedFullFrame` is the matching
3278
+ // `sourceSize`: the framing/fit paths only ever fire with a FULL-frame
3279
+ // mezzanine (mixed framing ⇒ no uniform crop), so its base is the source's
3280
+ // own dims. Face fractions, `sourceAspect` and `sourceTextRegions` are
3281
+ // ratios/fractions — scale-invariant, untouched. The Premiere project
3282
+ // export stays in TRUE source space by construction: it cuts the ORIGINAL
3283
+ // file (`production.source`, path + probe) and consumes only seconds and
3284
+ // scales from render-props (spans, zoomPlan, punch), never these windows.
3285
+ const mezzFactor = mezzScale
3286
+ ? { x: mezzScale.width / contentRect.w, y: mezzScale.height / contentRect.h }
3287
+ : { x: 1, y: 1 };
3288
+ const playedFullFrame = mezzScale
3289
+ ? { width: mezzScale.width, height: mezzScale.height }
3290
+ : null;
2172
3291
 
2173
3292
  // Comment-CTA keyword (FINDINGS §16), scoped to the ask (FINDINGS §22).
2174
3293
  // Read off the timed CUE, not the untimed scene: the cue carries the same
@@ -2191,6 +3310,33 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2191
3310
  );
2192
3311
  }
2193
3312
 
3313
+ // Bundled Nastaliq for RTL captions (2026-08-17): the render must not
3314
+ // depend on the machine having an Arabic-script font — a Linux box has
3315
+ // none, and macOS/Windows each substitute a different one, so identical
3316
+ // render-props drew three different Urdu caption sets. Gated on the SAME
3317
+ // predicate CaptionTrack keys its @font-face on (`captionsNeedNastaliq`),
3318
+ // so pure-Latin runs copy nothing and render byte-identically. Staged into
3319
+ // the render's public dir AND the workdir when they differ (a
3320
+ // --no-mezzanine file run serves the render from the source's own folder,
3321
+ // but `ossclip edit` serves from the workdir — program.ts's
3322
+ // `dirname(propsPath)` — and both mounts fetch the same served URL).
3323
+ // `join` is correct here where NASTALIQ_FONT_REL itself must stay
3324
+ // POSIX-literal: these are filesystem paths, the REL is the served URL
3325
+ // (sideImageDestRel's Windows lesson).
3326
+ if (!captionsHidden && captionsNeedNastaliq(captionLines)) {
3327
+ const fontSrc = nastaliqFontFile();
3328
+ for (const dir of new Set([renderPublicDirPath, work])) {
3329
+ const dest = join(dir, NASTALIQ_FONT_REL);
3330
+ if (!existsSync(dest)) {
3331
+ mkdirSync(dirname(dest), { recursive: true });
3332
+ copyFileSync(fontSrc, dest);
3333
+ }
3334
+ }
3335
+ console.log(
3336
+ `▸ captions: RTL lines detected — bundled ${NASTALIQ_FONT_NAME} staged as ${NASTALIQ_FONT_REL}`,
3337
+ );
3338
+ }
3339
+
2194
3340
  const ctaCue = [...graphicCues]
2195
3341
  .reverse()
2196
3342
  .find((c) => typeof c.props?.keyword === "string" && (c.props.keyword as string).length > 0);
@@ -2236,7 +3382,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2236
3382
  // editing session would have nothing to fall back to and render as if
2237
3383
  // it never happened, even though `overrides.json` on disk is correct.
2238
3384
  baseSceneCues: routed.cues,
2239
- baseTheme: defaultTheme,
3385
+ // The CONFIG base, not defaultTheme (F6): the editor re-applies its
3386
+ // overrides onto this, so a theme reset there must land on the user's
3387
+ // global colors — falling to factory defaults would silently discard
3388
+ // ~/.ossclip/config.json's theme the first time anyone touched a color.
3389
+ baseTheme: configBaseTheme,
2240
3390
  baseCaptionLines,
2241
3391
  settings: production.render,
2242
3392
  outputDurationSec: map.outputDuration,
@@ -2250,8 +3400,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2250
3400
  centerXFrac: faceBox.centerXFrac,
2251
3401
  sizeFrac: faceBox.sizeFrac,
2252
3402
  // The CONTENT's shape, not the container's — with bars trimmed the
2253
- // rendered video IS the content rect (PLAN Task 7).
3403
+ // rendered video IS the content rect (PLAN Task 7). Since the
3404
+ // framing bake became props (2026-08-16), this is always the RAW
3405
+ // source's picture: `content` derives from `sourceProbe`, never
3406
+ // from a re-encoded canvas, so a framing plan no longer distorts
3407
+ // the aspect the stage crops against.
2254
3408
  sourceAspect: content.height > 0 ? content.width / content.height : undefined,
3409
+ // Whether the face IS the subject, by the same rule the framing
3410
+ // plan applies per segment. 2026-08-16 incident: the global
3411
+ // 9-sample median landed on the camera PiP and pinned objectPosY
3412
+ // to 1.0, decapitating the speaker at the top of the frame — a
3413
+ // PiP-sized face must not steer the cover. Absent (old props)
3414
+ // means "face", so pre-existing render-props render unchanged.
3415
+ subject: faceSubject(faceBox),
2255
3416
  }
2256
3417
  : null,
2257
3418
  // Emptied, not flattened-to-1: a plan of flat segments still reads as "a
@@ -2265,25 +3426,57 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2265
3426
  ctaKeyword,
2266
3427
  ctaWindow,
2267
3428
  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.
3429
+ // Sent ONLY on the fit fallback (option (b)): a plan-framed mixed source
3430
+ // carries its windows in `framingTimeline` below, and a uniform source
3431
+ // had its bars cropped into the mezzanine — cropping either again at
3432
+ // render time would eat the picture twice. Rects and sourceSize are in
3433
+ // PLAYED-FILE pixels (`mezzFactor`/`playedFullFrame` above): the renderer
3434
+ // windows the file it plays, which a display-sized mezzanine has resampled.
2272
3435
  ...(fitFallback
2273
3436
  ? {
2274
- contentTimeline,
2275
- sourceSize: { width: sourceProbe.width, height: sourceProbe.height },
3437
+ contentTimeline: scaleContentTimeline(contentTimeline, mezzFactor),
3438
+ sourceSize:
3439
+ playedFullFrame ?? { width: sourceProbe.width, height: sourceProbe.height },
2276
3440
  contentCropMode: "fit" as const,
2277
3441
  }
2278
3442
  : {}),
3443
+ // The accepted framing plan, as DATA (2026-08-16 incident: the bake this
3444
+ // replaces was irreversible — deleting the re-encoded content-<hash>.mp4
3445
+ // was the only remedy — and it suppressed these very keys, so the editor
3446
+ // could not even see the crop). Mutually exclusive with the fit-fallback
3447
+ // spread by construction (`fitFallback` ⇔ `!plan.ok`), so `sourceSize`
3448
+ // is emitted by exactly one of them. Skipped under `--source-fit
3449
+ // contain`: contain shows the WHOLE frame, and a framing plan's cover
3450
+ // windows would fight it — the explicit flag wins over the inferred plan.
3451
+ ...(framingTimeline
3452
+ ? {
3453
+ // The plan's windows are TRUE source pixels (planNormalization
3454
+ // analyses the source); scaled here, at emission, into the pixel
3455
+ // space of the file the render plays — a display-sized mezzanine
3456
+ // resamples that space by `mezzFactor` (identity when unscaled).
3457
+ framingTimeline: scaleFramingWindows(framingTimeline, mezzFactor),
3458
+ // The PLAYED file's size — the scaled windows are in its pixels.
3459
+ sourceSize:
3460
+ playedFullFrame ?? { width: sourceProbe.width, height: sourceProbe.height },
3461
+ }
3462
+ : {}),
3463
+ // ALWAYS written, never absent-when-default like the flags around it:
3464
+ // an ABSENT `punch` is the LEGACY contract — EdlVideo's 1.07 punch on
3465
+ // every alternating span — kept so every pre-feature render-props.json
3466
+ // renders byte-identical to what it always did. Presence, even an
3467
+ // all-false "off" mask, is what opts a render into the face-only 1.015
3468
+ // behavior (punchPlanFor has the guard's why).
3469
+ punch,
2279
3470
  // `--source-fit contain`: show the whole frame instead of cropping it.
2280
3471
  // The size sent is the PICTURE's, not the container's — with bars trimmed
2281
3472
  // into the mezzanine the rendered video IS the content rect, and fitting
2282
3473
  // against the container's shape would inset a frame that no longer exists.
2283
3474
  // Listed after the fit fallback so it wins on a source that is both mixed
2284
- // and asked to be shown whole.
3475
+ // and asked to be shown whole. `playedFullFrame` when the mezzanine is
3476
+ // display-sized: it is that same picture, post-resample — the file the
3477
+ // renderer fits.
2285
3478
  ...(opts.sourceFit === "contain"
2286
- ? { sourceFit: "contain" as const, sourceSize: content }
3479
+ ? { sourceFit: "contain" as const, sourceSize: playedFullFrame ?? content }
2287
3480
  : {}),
2288
3481
  // Written only when ON, matching the field's absent-means-off contract:
2289
3482
  // an off run's render-props.json stays byte-identical to a pre-watermark
@@ -2365,13 +3558,112 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2365
3558
  };
2366
3559
  }
2367
3560
 
2368
- const outPath = opts.out
2369
- ? isAbsolute(opts.out)
2370
- ? opts.out
2371
- : resolve(baseCwd, opts.out)
2372
- : resolve(defaultOutPath(originalInput));
3561
+ // outPath was resolved (and its parent healed) at produce start — see the
3562
+ // 2026-08-16 fail-fast block up top.
2373
3563
  const rawPath = join(work, "render-raw.mp4");
2374
3564
  const interactive = isInteractive();
3565
+
3566
+ // ---- Thumbnail concept approval (thumbnail UX, 2026-08-16) --------------
3567
+ // BEFORE the render kickoff, after scene planning: the concept is the one
3568
+ // creative judgement the user previously only discovered after a
3569
+ // multi-minute render. Interactive runs approve (or edit, or skip) it
3570
+ // here; the file it writes is what thumbnailStep honors after render — and
3571
+ // what a non-TTY replay (the editor's Render) reuses, which is the whole
3572
+ // persistence story.
3573
+ //
3574
+ // Resolved HERE rather than at the pack section below so the gate and the
3575
+ // post-render consumers read one answer. Typed-beats-config, `typeof` not
3576
+ // truthiness (the `portrait` posture): config.json is hand-edited and
3577
+ // unparsed, and a `"audience": true` typo must resolve to "no audience".
3578
+ const youtube = resolveYoutube(opts.youtube, cfg.youtube);
3579
+ // resolvePortrait (portrait-override.ts) carries the expandHome treatment
3580
+ // of the flag and config paths, and puts the workdir's portrait-override
3581
+ // ABOVE both (editor face swap, 2026-08-17): a per-project expression
3582
+ // chosen in the editor must survive CLI re-renders — the flag/config
3583
+ // portrait is the fallback headshot, and a replay silently reverting the
3584
+ // swapped face would undo the one thing the swap exists for.
3585
+ const portrait = resolvePortrait({
3586
+ overridePath: portraitOverridePath(work),
3587
+ flagPortrait: opts.portrait,
3588
+ cfgPortrait: cfg.portrait,
3589
+ })?.path;
3590
+ const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
3591
+ const thumbnailBrief =
3592
+ opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
3593
+ const geminiKey = process.env.GEMINI_API_KEY;
3594
+ const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
3595
+ // The gate reuses thumbnailDecision (plus the mime check) so the prompt
3596
+ // never asks about a thumbnail the post-render step would skip anyway.
3597
+ const thumbnailWouldGenerate =
3598
+ provider != null &&
3599
+ portrait !== undefined &&
3600
+ thumbnailDecision(
3601
+ youtube,
3602
+ portrait,
3603
+ geminiKey !== undefined && geminiKey !== "",
3604
+ existsSync(portrait),
3605
+ ) === "generate" &&
3606
+ portraitMimeType(portrait) !== undefined;
3607
+ if (interactive && thumbnailWouldGenerate) {
3608
+ if (existsSync(approvedConceptPath)) {
3609
+ // A decision already on file IS the answer — re-asking a question the
3610
+ // user settled would make every warm re-run nag. The line names the
3611
+ // escape hatch instead.
3612
+ console.log(
3613
+ `▸ thumbnail: concept already decided (${THUMBNAIL_APPROVED_BASENAME} — delete it to revisit)`,
3614
+ );
3615
+ } else {
3616
+ // Seed from the concept cache when this exact steer was asked before
3617
+ // (a prior non-TTY run) — no titleAngle: the pack generates after
3618
+ // render, so the pre-render call cannot carry it and passes the hook
3619
+ // instead (see generateConcept below).
3620
+ const conceptCache = join(
3621
+ work,
3622
+ thumbnailConceptCacheName({
3623
+ providerName,
3624
+ llmModel: opts.llmModel,
3625
+ intent: opts.intent,
3626
+ hook: beatSheet?.hook,
3627
+ audience,
3628
+ brief: thumbnailBrief,
3629
+ transcriptWords: transcript.words.map((w) => w.text),
3630
+ }),
3631
+ );
3632
+ const initial = existsSync(conceptCache)
3633
+ ? ThumbnailConceptSchema.parse(JSON.parse(await readFile(conceptCache, "utf8")))
3634
+ : undefined;
3635
+ try {
3636
+ const approved = await approveThumbnailConcept({
3637
+ initial,
3638
+ generateConcept: async (note) => {
3639
+ const fresh = await phases.time("llm", () =>
3640
+ generateThumbnailConcept(provider!, {
3641
+ hook: beatSheet?.hook,
3642
+ intent: opts.intent,
3643
+ audience,
3644
+ brief: thumbnailBrief,
3645
+ note,
3646
+ transcriptText: transcript.words.map((w) => w.text).join(" "),
3647
+ }),
3648
+ );
3649
+ // The §35 word cap, thumbnailStep's exact treatment — approved
3650
+ // text must be the text the image is prompted with.
3651
+ return { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
3652
+ },
3653
+ });
3654
+ await writeFile(approvedConceptPath, JSON.stringify(approved, null, 2));
3655
+ } catch (err) {
3656
+ // A concept-call failure must not block the render the user is
3657
+ // waiting on (§112 posture) — no approved file is written, and the
3658
+ // post-render step retries the concept on its own.
3659
+ console.log(
3660
+ `▸ thumbnail: concept approval unavailable (${err instanceof Error ? err.message : String(err)}) ` +
3661
+ "— the post-render step will try again",
3662
+ );
3663
+ }
3664
+ }
3665
+ }
3666
+
2375
3667
  let renderHud: RenderTimelineHUD | null = null;
2376
3668
  if (interactive) {
2377
3669
  renderHud = new RenderTimelineHUD({
@@ -2384,11 +3676,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2384
3676
  console.log("▸ rendering…");
2385
3677
  }
2386
3678
  let lastPct = -10;
3679
+ // cpus-2 with a floor of 2 (resolveRenderConcurrency has the why: leave
3680
+ // cores for the ffmpeg decode workers every tab waits on). Resolved here,
3681
+ // next to the call it feeds, so the warning prints once per run.
3682
+ const renderConcurrency = resolveRenderConcurrency(cfg.renderConcurrency, cpus().length);
3683
+ if (renderConcurrency.warning) console.log(renderConcurrency.warning);
2387
3684
  await phases.time("render", () =>
2388
3685
  renderProduction(props, {
2389
3686
  publicDir: dirname(renderVideo),
2390
3687
  outPath: rawPath,
2391
3688
  browserExecutable: cfg.browserExecutable,
3689
+ concurrency: renderConcurrency.concurrency,
2392
3690
  onProgress: (p) => {
2393
3691
  if (renderHud) {
2394
3692
  renderHud.setProgress(p);
@@ -2415,7 +3713,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2415
3713
  const normPath = join(work, "render-norm.mp4");
2416
3714
  await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
2417
3715
  if (masterAnim) masterAnim.stop();
2418
- await rename(normPath, outPath);
3716
+ // moveFile, not fs rename: an --out on another volume (external drive)
3717
+ // throws EXDEV at the very end of the run — the sibling trap to the
3718
+ // ENOENT ensureParentDir prevents upfront (paths.ts).
3719
+ await moveFile(normPath, outPath);
2419
3720
 
2420
3721
  // ---- Cover image (FINDINGS §31) -----------------------------------------
2421
3722
  // A separate file, not a burned-in intro: both platforms accept a custom
@@ -2432,9 +3733,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2432
3733
  const cover = coverDecision(opts.cover !== false, coverText);
2433
3734
  if (cover !== "none") {
2434
3735
  const detector = await createFaceDetector();
2435
- const pick = await pickCoverFrame(tools, analysisInput, analysisProbe.duration, {
3736
+ const pick = await pickCoverFrame(tools, input, sourceProbe.duration, {
2436
3737
  cacheDir: work,
2437
- cropVf: analysisCropVf,
3738
+ cropVf,
3739
+ // On a screen-subject take the face weight is zeroed (2026-08-16: a
3740
+ // Facebook reel face visible IN the screen recording won the cover
3741
+ // — scoreCandidate has the incident). Same whole-take verdict the
3742
+ // stage bias and the span mask fallback read.
3743
+ subject: faceSubject(faceBox),
2438
3744
  detectFace: (pixels, w, h) => {
2439
3745
  const d = detector(pixels, w, h);
2440
3746
  // pico returns [row, col, size, score] in detection-frame pixels,
@@ -2450,13 +3756,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2450
3756
  await run(cfg.ffmpegPath, [
2451
3757
  "-v", "error",
2452
3758
  "-ss", pick.timeSec.toFixed(3),
2453
- "-i", analysisInput,
3759
+ "-i", input,
2454
3760
  "-frames:v", "1",
2455
- "-vf", `${analysisCropVf ? `${analysisCropVf},` : ""}scale=${frame.width}:${frame.height}:force_original_aspect_ratio=increase,crop=${frame.width}:${frame.height}`,
3761
+ "-vf", `${cropVf ? `${cropVf},` : ""}scale=${frame.width}:${frame.height}:force_original_aspect_ratio=increase,crop=${frame.width}:${frame.height}`,
2456
3762
  "-y", join(work, frameName),
2457
3763
  ]);
3764
+ // expandHome on the user half only — the artifactPath default derives
3765
+ // from the already-expanded outPath (2026-08-16, paths.ts).
2458
3766
  const coverPath = resolve(
2459
- opts.coverPath ?? outPath.replace(/(\.[^.]+)?$/, ".cover.jpg"),
3767
+ opts.coverPath !== undefined
3768
+ ? expandHome(opts.coverPath)
3769
+ : artifactPath(outPath, ".cover.jpg"),
2460
3770
  );
2461
3771
  // The §34 dedupe check and the band-placement log exist only to
2462
3772
  // route a banner around the frame's contents — a textless cover
@@ -2497,6 +3807,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2497
3807
  {
2498
3808
  frameFileName: frameName,
2499
3809
  text: bannerText,
3810
+ // The RESOLVED theme — so the cover's banner already carries the
3811
+ // config theme (F6) via resolveTheme's base, no separate wiring.
2500
3812
  theme,
2501
3813
  face: pick.face,
2502
3814
  // The cover is the OUTPUT's thumbnail — a landscape render gets a
@@ -2510,6 +3822,148 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2510
3822
  }
2511
3823
  }
2512
3824
  }
3825
+
3826
+ // ---- YouTube pack (Y2, 2026-08-16) --------------------------------------
3827
+ // AFTER the cover block and additive to it: the pack never changes the
3828
+ // video or the cover, it only writes siblings — so a failure here degrades
3829
+ // to "no metadata file" on a render that already succeeded, never a dead
3830
+ // run (§112 posture). `youtube`/`portrait`/`audience`/`thumbnailBrief`
3831
+ // were resolved before the render kickoff — the concept-approval gate and
3832
+ // this section must read one answer.
3833
+ let youtubeMdPath: string | undefined;
3834
+ let thumbnailPath: string | undefined;
3835
+ // The pack's FIRST title, when it generated — the thumbnail concept's
3836
+ // titleAngle, so thumbnail and title tell one story. (The pre-render
3837
+ // approval could not carry it: the pack writes here, after render.)
3838
+ let packTitle: string | undefined;
3839
+ if (youtube) {
3840
+ // The approved file wins outright (readApprovedYoutubePack): no cache
3841
+ // lookup, no LLM call — and no provider needed, so an edited pack still
3842
+ // writes its markdown on a --youtube run that carries no --produce.
3843
+ let pack: YoutubePack | undefined = await readApprovedYoutubePack(work);
3844
+ if (pack) {
3845
+ console.log(
3846
+ "▸ youtube: metadata from your edited pack " +
3847
+ `(delete ${YOUTUBE_APPROVED_BASENAME} to regenerate)`,
3848
+ );
3849
+ } else if (!provider) {
3850
+ // The metadata call rides the run's LLM provider; a run without one
3851
+ // (no --produce) has nothing to call. Said out loud rather than
3852
+ // silently — the user typed/configured --youtube. The thumbnail (Y3)
3853
+ // is a separate API keyed by GEMINI_API_KEY and still attempts.
3854
+ console.log("▸ youtube: metadata needs an LLM provider — skipped (thumbnail unaffected)");
3855
+ } else {
3856
+ // Beat-sheet cache shape: keyed on everything that changes the answer —
3857
+ // who is asked, with what editorial steer, about which words.
3858
+ const packKey = createHash("sha1")
3859
+ .update(
3860
+ JSON.stringify([
3861
+ // Prompt changes change the answer (the §78 posture): the v2
3862
+ // rewrite must not serve a pack cached under v1's questions.
3863
+ YOUTUBE_PROMPT_VERSION,
3864
+ providerName,
3865
+ opts.llmModel ?? "",
3866
+ opts.intent ?? "",
3867
+ // Steer, so part of the key — a changed audience is a different
3868
+ // pack, not a cache hit.
3869
+ audience ?? "",
3870
+ transcript.words.map((w) => w.text),
3871
+ // The stamped transcript's [m:ss] marks come from the CUT MAP,
3872
+ // not the words — a re-cut with identical words moves every
3873
+ // chapter stamp, and a word-only key would serve the stale
3874
+ // chapters (v2 review gap, 2026-08-17). Spans, rounded to ms,
3875
+ // pin the timeline the stamps were computed on.
3876
+ map.spans.map((s) => [Math.round(s.srcIn * 1000), Math.round(s.outIn * 1000)]),
3877
+ ]),
3878
+ )
3879
+ .digest("hex")
3880
+ .slice(0, 8);
3881
+ const packCache = join(work, `youtube-${packKey}.json`);
3882
+ if (existsSync(packCache)) {
3883
+ pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
3884
+ console.log("▸ youtube: metadata cached");
3885
+ } else {
3886
+ try {
3887
+ pack = await phases.time("llm", () =>
3888
+ generateYoutubePack(provider!, {
3889
+ // Sentence lines stamped with OUTPUT-clock times (prompt v2):
3890
+ // the words carry SOURCE seconds and `map` translates them, so
3891
+ // the chapters the model returns are measured, not guessed —
3892
+ // the one thing a paste-a-transcript prompt tool cannot do.
3893
+ transcriptText: stampedTranscript(transcript.words, map),
3894
+ intent: opts.intent,
3895
+ hook: beatSheet?.hook,
3896
+ coverText: beatSheet?.coverText,
3897
+ audience,
3898
+ durationSec: map.outputDuration,
3899
+ }),
3900
+ );
3901
+ await writeFile(packCache, JSON.stringify(pack, null, 2));
3902
+ } catch (err) {
3903
+ // NEVER cache a failure (§106), and never fail the produce that
3904
+ // just rendered over a metadata sidecar: one loud line, the video
3905
+ // and cover stand, the next run retries the call.
3906
+ console.log(
3907
+ ` ⚠ youtube metadata unavailable: ${err instanceof Error ? err.message : String(err)}\n` +
3908
+ " (not cached — the next run retries the pass)",
3909
+ );
3910
+ }
3911
+ }
3912
+ }
3913
+ if (pack) {
3914
+ youtubeMdPath = artifactPath(outPath, ".youtube.md");
3915
+ await writeFile(youtubeMdPath, formatYoutubeMarkdown(pack));
3916
+ console.log(`✓ youtube pack → ${youtubeMdPath}`);
3917
+ packTitle = pack.titles[0];
3918
+ }
3919
+ // ---- AI thumbnail (Y3, 2026-08-16) ------------------------------------
3920
+ // Shares the gate and `portrait` above but not the provider's KEY — its
3921
+ // credential is GEMINI_API_KEY, env-only (secrets never in config.json,
3922
+ // env.ts:7-9 rule; env-file loading already ran at CLI entry), so a
3923
+ // metadata skip must not skip it. The concept call does still need the
3924
+ // run's text provider; thumbnailStep says so out loud when it's absent.
3925
+ // Consumer-side validation, the `portrait` posture above: config.json
3926
+ // is hand-edited and unparsed, so a non-string `thumbnailModel` falls
3927
+ // back to the default rather than reaching the API as garbage.
3928
+ const thumbnailModel =
3929
+ typeof cfg.thumbnailModel === "string" ? cfg.thumbnailModel : THUMBNAIL_MODEL_DEFAULT;
3930
+ const thumbnail = await thumbnailStep({
3931
+ youtube,
3932
+ portraitPath: portrait,
3933
+ apiKey: geminiKey,
3934
+ model: thumbnailModel,
3935
+ work,
3936
+ outPath,
3937
+ // The run's provider is `LlmProvider | null`; the step's "absent" is
3938
+ // undefined, matching thumbnailDecision's optional-argument shape.
3939
+ provider: provider ?? undefined,
3940
+ providerName,
3941
+ llmModel: opts.llmModel,
3942
+ intent: opts.intent,
3943
+ hook: beatSheet?.hook,
3944
+ audience,
3945
+ brief: thumbnailBrief,
3946
+ titleAngle: packTitle,
3947
+ transcriptWords: transcript.words.map((w) => w.text),
3948
+ time: (fn) => phases.time("llm", fn),
3949
+ });
3950
+ thumbnailPath = thumbnail?.path;
3951
+ if (thumbnail && interactive && geminiKey) {
3952
+ // Post-generation retry (thumbnail UX, 2026-08-16): the image call is
3953
+ // seconds where the render was minutes, so an unwanted result is cheap
3954
+ // to redo NOW — the concept stays fixed, only the image re-rolls with
3955
+ // the user's note.
3956
+ await thumbnailRetryLoop({
3957
+ imagePath: thumbnail.path,
3958
+ imageCachePath: thumbnail.imageCachePath,
3959
+ concept: thumbnail.concept,
3960
+ apiKey: geminiKey,
3961
+ model: thumbnailModel,
3962
+ portrait: thumbnail.portrait,
3963
+ generate: generateThumbnailImage,
3964
+ });
3965
+ }
3966
+ }
2513
3967
  // Record THIS invocation so the editor's Render button can replay it (R11
2514
3968
  // Task 4). Nothing else can reconstruct it — production.json has the
2515
3969
  // source path, cleanup and intent, but not --produce, --out or the LLM
@@ -2550,11 +4004,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2550
4004
  // because the EDITOR hid them would freeze an edit the user may later
2551
4005
  // undo in that same editor. See recordedProduceArgs for why the pin is
2552
4006
  // unconditional even though captions' default is config-independent today.
4007
+ // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4008
+ // argv — "auto" has no flag spelling and stays unpinned (see
4009
+ // recordedProduceArgs for why that is safe today).
4010
+ // Youtube pin: the watermark's config-dependent-default rationale exactly —
4011
+ // resolved both ways, so a later config edit can't flip what Render
4012
+ // replays. Portrait and dictionary pin the RESOLVED values (a path and
4013
+ // terms, never a secret) for the same reason; recordedProduceArgs owns
4014
+ // the non-empty/includes guards.
2553
4015
  const recordedArgs = recordedProduceArgs({
2554
4016
  llm: provider ? providerName : undefined,
2555
4017
  clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
2556
4018
  watermark,
2557
4019
  captions: opts.captions ?? true,
4020
+ jumpCuts: jumpCutsMode,
4021
+ dictionary,
4022
+ youtube,
4023
+ portrait,
4024
+ audience,
4025
+ thumbnailBrief,
2558
4026
  });
2559
4027
  await writeFile(
2560
4028
  join(work, "command.json"),
@@ -2579,7 +4047,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2579
4047
  if (isInteractive()) {
2580
4048
  printProductionCompleteBanner({
2581
4049
  outPath,
2582
- coverPath: opts.cover !== false ? (typeof opts.cover === "string" ? opts.cover : outPath.replace(/(\.[^.]+)?$/, ".cover.jpg")) : undefined,
4050
+ // `opts.coverPath ?? artifactPath(...)`, matching the cover write above.
4051
+ // The old check here was `typeof opts.cover === "string"` — stale since
4052
+ // the cover/coverPath split, so an explicit --cover <path> banner'd the
4053
+ // default path instead of the file actually written.
4054
+ coverPath: opts.cover !== false ? opts.coverPath ?? artifactPath(outPath, ".cover.jpg") : undefined,
4055
+ youtubePath: youtubeMdPath,
4056
+ thumbnailPath,
2583
4057
  sourceDurationSec: sourceProbe.duration,
2584
4058
  outputDurationSec: map.outputDuration,
2585
4059
  sceneCount: scenes.length,