ossclip 0.1.26 → 0.1.28

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
@@ -39,8 +39,11 @@ import {
39
39
  listFolderVideos,
40
40
  outInsideInputFolderMessage,
41
41
  outPathInsideInput,
42
+ COVER_PROVENANCE_BASENAME,
42
43
  coverDecision,
43
44
  coverHeadline,
45
+ readCoverProvenance,
46
+ writeCoverProvenance,
44
47
  cropFilter,
45
48
  detectContentRect,
46
49
  letterboxedSeconds,
@@ -49,6 +52,7 @@ import {
49
52
  createProvider,
50
53
  createTieredProvider,
51
54
  defaultProviderName,
55
+ fallbackProviderName,
52
56
  defaultTheme,
53
57
  detectSilences,
54
58
  dropHiddenCues,
@@ -88,6 +92,8 @@ import {
88
92
  portraitMimeType,
89
93
  thumbnailDecision,
90
94
  thumbnailImageCacheName,
95
+ applyCleanupChoices,
96
+ vetoedRemovals,
91
97
  applyUserCuts,
92
98
  pruneHidesInsideCuts,
93
99
  loadConfig,
@@ -115,6 +121,8 @@ import {
115
121
  measureLevels,
116
122
  probe,
117
123
  produceScenes,
124
+ PRODUCER_PROMPT_VERSION,
125
+ type FramingContext,
118
126
  reclampPinnedTiming,
119
127
  reconcileCopy,
120
128
  repairTranscript,
@@ -141,7 +149,9 @@ import {
141
149
  type CleanupLevel,
142
150
  type ClipWindow,
143
151
  type Layout,
152
+ type LlmEffort,
144
153
  type LlmProvider,
154
+ type LlmUsage,
145
155
  type Production,
146
156
  ossclipOutputPathFor,
147
157
  type ProviderName,
@@ -151,7 +161,7 @@ import {
151
161
  type Transcript,
152
162
  } from "@ossclip/core";
153
163
  import { recordRecentProject } from "./edit";
154
- import { binOnPath, detectionLine } from "./llm-detect";
164
+ import { binOnPath, detectionLine, fallbackLine } from "./llm-detect";
155
165
  import {
156
166
  modelImpliedLanguage,
157
167
  modelUrl,
@@ -165,6 +175,13 @@ import {
165
175
  workdirBaseName,
166
176
  } from "./stranded-overrides";
167
177
  import { editHint } from "./interactive/edit-hint";
178
+ import {
179
+ COVER_FRAME_BASENAME,
180
+ buildCoverRender,
181
+ coverBannerText,
182
+ coverTextHold,
183
+ provenanceVideoPath,
184
+ } from "./cover";
168
185
  import { artifactPath, ensureParentDir, expandHome, moveFile } from "./paths";
169
186
  import { portraitOverridePath, resolvePortrait } from "./portrait-override";
170
187
  import { approveThumbnailConcept, thumbnailRetryLoop } from "./interactive/thumbnail-approve";
@@ -262,11 +279,165 @@ export function transcriptCacheReusable(
262
279
  };
263
280
  }
264
281
 
282
+ /**
283
+ * The beat-sheet/scenes cache key: everything that changes the plan — which
284
+ * prompt asked, who was asked, with what editorial steer, about which words,
285
+ * in what frame. Pure and exported so the §78 posture ("a change that changes
286
+ * the answer must change the key") is testable without a workdir.
287
+ *
288
+ * Two of these are new, and only one of them was a live bug:
289
+ * - `promptVersion` (the caller passes PRODUCER_PROMPT_VERSION) is the §78
290
+ * fix proper — before it, a warm workdir kept serving a sheet the OLD
291
+ * prompt wrote, exactly the failure YOUTUBE_PROMPT_VERSION exists for.
292
+ * - `aspect` is LATENT rather than active: it changes the user prompt (the
293
+ * LANDSCAPE block, R21 §101), but `--aspect 16:9` also derives its own
294
+ * `-16x9` workdir and this cache is a file inside it, so a portrait and a
295
+ * landscape plan of the same source cannot meet today. Keyed anyway —
296
+ * the collision is one workdir-naming change away, and the key should not
297
+ * depend on a different module's directory scheme to stay correct.
298
+ */
299
+ export function beatSheetCacheKey(parts: {
300
+ promptVersion: string;
301
+ /** The primary on reads; on a §143 fallback WRITE, the provider that
302
+ * actually answered — a plain name from the usage records. */
303
+ providerName: string;
304
+ llmModel?: string;
305
+ /** The §143 effort knob — it steers the editorial call, so it changes the plan. */
306
+ llmEffort?: LlmEffort;
307
+ intent?: string;
308
+ cleanup: CleanupLevel;
309
+ forceComponent?: SceneComponentId;
310
+ /** Framing constraints steer layout choice, so a re-measure must replan. */
311
+ framing?: FramingContext;
312
+ clipTargetSec?: number;
313
+ clipWindow?: ClipWindow | null;
314
+ /** The repaired transcript's TEXT — see the call site on why not the count. */
315
+ words: readonly string[];
316
+ aspect: "9:16" | "16:9";
317
+ }): string {
318
+ return createHash("sha1")
319
+ .update(
320
+ JSON.stringify([
321
+ parts.promptVersion,
322
+ parts.providerName,
323
+ parts.llmModel,
324
+ parts.intent,
325
+ parts.cleanup,
326
+ parts.forceComponent ?? null,
327
+ parts.framing ?? null,
328
+ // §93f: the clip target and the RESOLVED window key the plan too —
329
+ // without them a clip run and a full run of the same source would
330
+ // collide and answer from each other's cache (the §78 failure
331
+ // mode). Keyed POST-resolution so a replay that derives the same
332
+ // window hits the same entries.
333
+ parts.clipTargetSec ?? null,
334
+ parts.clipWindow ? `${parts.clipWindow.startWord}:${parts.clipWindow.endWord}` : null,
335
+ parts.words,
336
+ parts.aspect,
337
+ // The §143 effort knob — appended at the END, and only when SET: an
338
+ // unconditional `?? null` would change the serialization of every
339
+ // existing key and silently re-plan every warm workdir for users who
340
+ // never touched the knob. §78 only demands that a DIFFERENT effort
341
+ // miss; an unset one must keep hitting what it always hit.
342
+ ...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
343
+ ]),
344
+ )
345
+ .digest("hex")
346
+ .slice(0, 8);
347
+ }
348
+
349
+ /**
350
+ * The clip-window cache key (`clipwindow-<hash>.json`), which answers a
351
+ * different question — WHICH ~Ns of the take to keep (R19 §93) — but asks it
352
+ * with the SAME producer prompt, via `produceScenes(…, clip: {…})`. So it
353
+ * carries the same two fields `beatSheetCacheKey` gained, for the same
354
+ * reasons:
355
+ * - `promptVersion`: without it, editing the producer prompt leaves every
356
+ * warm workdir serving a window that was resolved under the OLD prompt —
357
+ * the §78 failure mode, one call above where it was just fixed. The
358
+ * tradeoff is accepted deliberately: a version bump DOES throw away an
359
+ * already-resolved window and costs one LLM call to re-select it. That is
360
+ * the correct price. Planning a whole video against a window the current
361
+ * prompt would not have chosen is worse than an LLM call.
362
+ * - `aspect`: LATENT today, exactly as it is one function down — `--aspect
363
+ * 16:9` derives its own `-16x9` workdir and this cache is a file inside
364
+ * it, so a portrait and a landscape selection of the same source cannot
365
+ * meet. Keyed anyway: the aspect reaches the prompt (both halves of it
366
+ * since the producerSystem change), and the key should not depend on a
367
+ * different module's directory scheme to stay correct.
368
+ *
369
+ * Deliberately NOT keyed, matching what the call site passes to the selection
370
+ * call: cleanup, forced component and the clip window itself. The first two
371
+ * do not reach this prompt, and the third is what it returns.
372
+ */
373
+ export function clipWindowCacheKey(parts: {
374
+ promptVersion: string;
375
+ /** Same read/write split as `beatSheetCacheKey` (§143). */
376
+ providerName: string;
377
+ llmModel?: string;
378
+ /** The §143 effort knob — the selection rides the same editorial call. */
379
+ llmEffort?: LlmEffort;
380
+ intent?: string;
381
+ clipTargetSec: number;
382
+ /** Framing constraints steer the selection call the same way (see above). */
383
+ framing?: FramingContext;
384
+ /** The repaired transcript's TEXT — the window is word-indexed into it. */
385
+ words: readonly string[];
386
+ aspect: "9:16" | "16:9";
387
+ }): string {
388
+ return createHash("sha1")
389
+ .update(
390
+ JSON.stringify([
391
+ parts.promptVersion,
392
+ parts.providerName,
393
+ parts.llmModel,
394
+ parts.intent,
395
+ parts.clipTargetSec,
396
+ parts.framing ?? null,
397
+ parts.words,
398
+ parts.aspect,
399
+ // Appended at the END, only when SET — beatSheetCacheKey's rule: an
400
+ // unset effort must keep every existing key byte-identical.
401
+ ...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
402
+ ]),
403
+ )
404
+ .digest("hex")
405
+ .slice(0, 8);
406
+ }
407
+
408
+ /**
409
+ * The provider that actually answered the call whose output is being cached
410
+ * (2026-08-22, FINDINGS §143): after a timeout fallback the resolved
411
+ * `providerName` names the provider that FAILED, and keying a cache write on
412
+ * it would attribute the fallback's plan to a provider that never produced
413
+ * it. Last matching record wins — retries and the fallback both append to the
414
+ * usage log, so the last writer is the one whose answer survived. Pure and
415
+ * exported so the attribution is testable without a workdir.
416
+ */
417
+ export function actualProvider(
418
+ usage: readonly LlmUsage[],
419
+ schemaName: string,
420
+ defaultName: string,
421
+ ): string {
422
+ for (let i = usage.length - 1; i >= 0; i--) {
423
+ if (usage[i]!.schemaName === schemaName) return usage[i]!.provider;
424
+ }
425
+ return defaultName;
426
+ }
427
+
265
428
  export interface ProduceOptions {
266
429
  out?: string;
267
430
  cleanup: CleanupLevel;
268
431
  transcript?: string;
269
432
  render: boolean;
433
+ /**
434
+ * This run is a `--review` (cut review step 1/3): `render` is already
435
+ * false (reviewFlag resolved that in the action) and the editor opens on
436
+ * the workdir afterwards. Produce only reads it to phrase the no-render
437
+ * exit — "the editor is opening" instead of the `--no-render` skip line
438
+ * plus an `ossclip edit` hint for an editor that is about to open itself.
439
+ */
440
+ review?: boolean;
270
441
  mezzanine: boolean;
271
442
  workdir?: string;
272
443
  inspect?: boolean;
@@ -281,6 +452,12 @@ export interface ProduceOptions {
281
452
  llmModel?: string;
282
453
  /** Model for mechanical calls; "same" sends everything to the main model. */
283
454
  llmFastModel?: string;
455
+ /**
456
+ * `agy --effort` for the antigravity provider (§143). Already zod-parsed to
457
+ * the union by program.ts — the CONFIG's `llmEffort` arrives separately, as
458
+ * an unvalidated string, and `resolveLlmEffort` arbitrates.
459
+ */
460
+ llmEffort?: LlmEffort;
284
461
  /** Who is on camera — steers repair and exempts their name from grounding. */
285
462
  speaker?: string;
286
463
  /** Repair ASR mishearings before captions/producer/grounding (default on). */
@@ -306,6 +483,12 @@ export interface ProduceOptions {
306
483
  cover?: boolean;
307
484
  /** Explicit cover output path, overriding <out>.cover.jpg. */
308
485
  coverPath?: string;
486
+ /**
487
+ * `--cover-text-reset` — opt back into the GENERATED cover headline on a
488
+ * workdir whose `cover.json` holds a user-typed one (`coverTextHold`).
489
+ * Deleting `cover.json` does the same thing.
490
+ */
491
+ coverTextReset?: boolean;
309
492
  /** Treat the source as an already-edited reel with burned-in graphics. */
310
493
  sourceIsEdited?: boolean;
311
494
  /**
@@ -467,6 +650,32 @@ export function resolveYoutube(
467
650
  return flag ?? configValue === true;
468
651
  }
469
652
 
653
+ /**
654
+ * The effective `--llm-effort` — reasoning effort for the antigravity
655
+ * provider's `agy --effort` flag (§143: exposed after the hang incident;
656
+ * untested at real scale whether it moves the hang, but the knob existed and
657
+ * we passed nothing). A TYPED flag always wins, and it arrives already
658
+ * zod-parsed by program.ts; only the config value is checked here — the
659
+ * `dictionary` posture, since it comes from hand-editable JSON loadConfig
660
+ * doesn't zod-parse: exactly low|medium|high, or one warning and agy's
661
+ * default, never a coerced effort level. Pure so the flag × config matrix is
662
+ * testable without a config file on disk.
663
+ */
664
+ export function resolveLlmEffort(
665
+ flag: LlmEffort | undefined,
666
+ configValue: unknown,
667
+ ): { effort?: LlmEffort; warning?: string } {
668
+ // Typed-beats-config, and typed also beats a MALFORMED config: the user
669
+ // asking for an effort on the command line gets it, not a warning about a
670
+ // config key they did not touch this run.
671
+ if (flag !== undefined) return { effort: flag };
672
+ if (configValue === undefined) return {};
673
+ if (configValue === "low" || configValue === "medium" || configValue === "high") {
674
+ return { effort: configValue };
675
+ }
676
+ return { warning: "⚠ config llmEffort ignored — expected low|medium|high" };
677
+ }
678
+
470
679
  /**
471
680
  * How many browser tabs the render runs in parallel (2026-08-17 render-speed
472
681
  * pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
@@ -949,7 +1158,23 @@ export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailS
949
1158
  // ceiling). Capped BEFORE caching so the cache and the image key hold
950
1159
  // what is used.
951
1160
  concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
952
- await writeFile(conceptCache, JSON.stringify(concept, null, 2));
1161
+ // §143 read/write split, same as the plan and repair caches: the
1162
+ // concept call rides the editorial provider and can fall back, so the
1163
+ // file goes under whoever actually answered.
1164
+ await writeFile(
1165
+ join(
1166
+ args.work,
1167
+ thumbnailConceptCacheName({
1168
+ ...args,
1169
+ providerName: actualProvider(
1170
+ args.provider.usage,
1171
+ "thumbnail_concept",
1172
+ args.providerName,
1173
+ ),
1174
+ }),
1175
+ ),
1176
+ JSON.stringify(concept, null, 2),
1177
+ );
953
1178
  } catch (err) {
954
1179
  log(
955
1180
  `▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
@@ -1133,6 +1358,54 @@ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
1133
1358
  return "auto";
1134
1359
  }
1135
1360
 
1361
+ /**
1362
+ * Resolves `--review` against the two flags it overlaps: produce without
1363
+ * rendering, then open the editor to review the cut — so the ONE render
1364
+ * happens from the editor's Render button, not before the user could look.
1365
+ *
1366
+ * `--review --no-render` is agreement, not a contradiction — both point the
1367
+ * same way (don't render here), unlike the jump-cuts pair — so it resolves
1368
+ * silently. `--review --no-open-editor` IS the contradiction (reviewing is
1369
+ * opening the editor) and follows jumpCutsFlag's rule: a loud error, never a
1370
+ * precedence the user has to memorize. Without --review everything passes
1371
+ * through untouched, tri-states included. Pure so the whole matrix is
1372
+ * assertable without commander or a TTY.
1373
+ */
1374
+ export function reviewFlag(
1375
+ review: boolean,
1376
+ render: boolean,
1377
+ openEditor: boolean | undefined,
1378
+ ): { render: boolean; openEditor: boolean | undefined } {
1379
+ if (!review) return { render, openEditor };
1380
+ if (openEditor === false) {
1381
+ throw new Error("--review contradicts --no-open-editor — reviewing means opening the editor");
1382
+ }
1383
+ return { render: false, openEditor: true };
1384
+ }
1385
+
1386
+ /**
1387
+ * The one loud line for cleanup vetoes actually changing this run's cut (cut
1388
+ * review step 3) — the `▸ N user cut(s) removed …` voice, inverted: per
1389
+ * declined reason, how many removals came back and how much source time they
1390
+ * restore. Pure so the whole phrasing is assertable without running produce;
1391
+ * callers only print it when `vetoed` is non-empty (a silent no-change run
1392
+ * must stay silent, like the user-cut line's own `cuts.length > 0` gate).
1393
+ */
1394
+ export function cleanupChoicesLine(vetoed: readonly Segment[], outputDuration: number): string {
1395
+ const byReason = new Map<string, { count: number; sec: number }>();
1396
+ for (const seg of vetoed) {
1397
+ const key = seg.reason ?? "unlabeled";
1398
+ const entry = byReason.get(key) ?? { count: 0, sec: 0 };
1399
+ entry.count += 1;
1400
+ entry.sec += seg.srcOut - seg.srcIn;
1401
+ byReason.set(key, entry);
1402
+ }
1403
+ const parts = [...byReason.entries()].map(
1404
+ ([reason, { count, sec }]) => `${count} ${reason} removal(s) (+${sec.toFixed(1)}s)`,
1405
+ );
1406
+ return `▸ cleanup choices kept ${parts.join(", ")} — ${outputDuration.toFixed(1)}s output`;
1407
+ }
1408
+
1136
1409
  /**
1137
1410
  * The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
1138
1411
  * (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
@@ -1662,6 +1935,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1662
1935
  const dictionary = opts.dictionary ?? configDictionary ?? [];
1663
1936
  if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
1664
1937
 
1938
+ // Resolved ONCE for the whole run (§143): the provider call, both plan
1939
+ // cache keys and the command.json pin must all see the same effort, or a
1940
+ // replay re-plans under a knob the run never used.
1941
+ const { effort: llmEffort, warning: llmEffortWarning } = resolveLlmEffort(
1942
+ opts.llmEffort,
1943
+ cfg.llmEffort,
1944
+ );
1945
+ if (llmEffortWarning) console.log(llmEffortWarning);
1946
+
1665
1947
  // The run's base theme (F6): config theme over defaultTheme, resolved once
1666
1948
  // and used for BOTH resolveTheme's base and props.baseTheme below — the
1667
1949
  // editor's reset must land on the user's global colors, not the factory's.
@@ -2072,31 +2354,62 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2072
2354
  if (!opts.provider) {
2073
2355
  console.log(detectionLine(providerName));
2074
2356
  }
2357
+ // Timeout fallback (2026-08-22, FINDINGS §143): agy hangs persistently on
2358
+ // the real beat-sheet call — 10-minute --print-timeout expiries while
2359
+ // claude-cli planned the same video — and auto-detection picks agy
2360
+ // whenever the CLI is on PATH. When the editorial call times out, ONE
2361
+ // other provider answers it instead of the run dying, announced out loud:
2362
+ // the user must know which model planned their video.
2363
+ const llmFallbackName =
2364
+ providerName === "antigravity"
2365
+ ? fallbackProviderName(providerName, process.env, binOnPath)
2366
+ : undefined;
2075
2367
  provider = createTieredProvider(providerName, {
2076
2368
  model: opts.llmModel,
2077
2369
  fastModel: opts.llmFastModel ?? cfg.fastModel,
2370
+ fallback: llmFallbackName,
2371
+ onFallback: (info) => console.log(fallbackLine(info.from, info.to, info.schemaName)),
2372
+ // §143: rides the editorial antigravity call only — see TieringOptions.
2373
+ effort: llmEffort,
2078
2374
  });
2079
2375
  }
2080
2376
 
2081
2377
  let rawTranscript = transcript;
2082
2378
  let repairs: AppliedRepair[] = [];
2083
2379
  if (provider && opts.repair !== false) {
2084
- const rawKey = createHash("sha1")
2085
- .update(
2086
- JSON.stringify([
2087
- providerName,
2088
- opts.llmModel,
2089
- opts.llmFastModel ?? cfg.fastModel,
2090
- opts.speaker ?? cfg.speaker,
2091
- // The dictionary changes both the prompt and the vouched set (F4),
2092
- // so cached repairs from a different vocabulary must not be reused.
2093
- dictionary,
2094
- rawTranscript.words.map((w) => w.text),
2095
- ]),
2096
- )
2097
- .digest("hex")
2098
- .slice(0, 8);
2099
- const repairCache = join(work, `repairs-${rawKey}.json`);
2380
+ // Parameterized on the provider so the WRITE below can re-key on who
2381
+ // actually answered (§143) — the same read/write split as the beat-sheet
2382
+ // caches, and load-bearing beyond attribution: repairs REWRITE the words,
2383
+ // and the words are an input to every plan cache key downstream. A repair
2384
+ // set cached under the provider that timed out would fork the transcript
2385
+ // lineage, and the fallback-written plan could never be reached by a
2386
+ // later run that names the fallback provider directly (measured
2387
+ // 2026-08-22: two repair caches, 10 vs 11 repairs — one applied
2388
+ // "Llama," → "LLaVA," — put the same take's beat sheets under keys no
2389
+ // other run computed).
2390
+ const repairKeyFor = (p: string): string =>
2391
+ createHash("sha1")
2392
+ .update(
2393
+ JSON.stringify([
2394
+ p,
2395
+ opts.llmModel,
2396
+ opts.llmFastModel ?? cfg.fastModel,
2397
+ opts.speaker ?? cfg.speaker,
2398
+ // The dictionary changes both the prompt and the vouched set (F4),
2399
+ // so cached repairs from a different vocabulary must not be reused.
2400
+ dictionary,
2401
+ rawTranscript.words.map((w) => w.text),
2402
+ // Repair runs on the EDITORIAL tier (repair.ts: deciding what a
2403
+ // person actually said is semantic work), so the §143 effort knob
2404
+ // changes its answers. Appended at the END, only when SET —
2405
+ // beatSheetCacheKey's rule: an unset effort must keep serving the
2406
+ // repairs every existing workdir already cached.
2407
+ ...(llmEffort !== undefined ? [llmEffort] : []),
2408
+ ]),
2409
+ )
2410
+ .digest("hex")
2411
+ .slice(0, 8);
2412
+ const repairCache = join(work, `repairs-${repairKeyFor(providerName)}.json`);
2100
2413
  if (existsSync(repairCache)) {
2101
2414
  const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
2102
2415
  // Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
@@ -2166,7 +2479,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2166
2479
  " (not cached — the next run retries the pass)",
2167
2480
  );
2168
2481
  } else {
2169
- await writeFile(repairCache, JSON.stringify(repairs, null, 2));
2482
+ // Written under the ANSWERING provider's key (§143): after a timeout
2483
+ // fallback these are the fallback's repairs, and the transcript
2484
+ // lineage they start must be reachable by a run that asks that
2485
+ // provider by name. The primary-keyed read above stays deliberate.
2486
+ await writeFile(
2487
+ join(
2488
+ work,
2489
+ `repairs-${repairKeyFor(actualProvider(provider.usage, "transcript_repair", providerName))}.json`,
2490
+ ),
2491
+ JSON.stringify(repairs, null, 2),
2492
+ );
2170
2493
  }
2171
2494
  }
2172
2495
  for (const r of repairs) {
@@ -2282,19 +2605,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2282
2605
  // calls; only a first run selects.
2283
2606
  let clipFresh: Awaited<ReturnType<typeof produceScenes>> | null = null;
2284
2607
  if (clipTargetSec !== undefined) {
2285
- const windowKey = createHash("sha1")
2286
- .update(
2287
- JSON.stringify([
2288
- providerName,
2289
- opts.llmModel,
2290
- opts.intent,
2291
- clipTargetSec,
2292
- framingCtx ?? null,
2293
- transcript.words.map((w) => w.text),
2294
- ]),
2295
- )
2296
- .digest("hex")
2297
- .slice(0, 8);
2608
+ // Parts split from the key so the WRITE below can re-key on the
2609
+ // provider that actually answered (§143) without restating them.
2610
+ const windowKeyParts = {
2611
+ promptVersion: PRODUCER_PROMPT_VERSION,
2612
+ llmModel: opts.llmModel,
2613
+ llmEffort,
2614
+ intent: opts.intent,
2615
+ clipTargetSec,
2616
+ framing: framingCtx,
2617
+ words: transcript.words.map((w) => w.text),
2618
+ aspect: landscape ? ("16:9" as const) : ("9:16" as const),
2619
+ };
2620
+ const windowKey = clipWindowCacheKey({ ...windowKeyParts, providerName });
2298
2621
  const clipWindowCache = join(work, `clipwindow-${windowKey}.json`);
2299
2622
  if (opts.clipWindow) {
2300
2623
  clipWindow = parseClipWindowPin(transcript, opts.clipWindow);
@@ -2321,7 +2644,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2321
2644
  );
2322
2645
  clipWindow = clipFresh.clip!.window;
2323
2646
  for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
2324
- await writeFile(clipWindowCache, JSON.stringify(clipWindow, null, 2));
2647
+ // The WRITE keys on the provider that actually answered; the READS
2648
+ // above stay keyed on the primary — both deliberate (2026-08-22,
2649
+ // FINDINGS §143). After a timeout fallback the window is the
2650
+ // fallback's work: filing it under agy would let an agy-keyed read
2651
+ // claim a window agy never chose, and would hide it from a later
2652
+ // `--llm claude-cli` run that should hit it. The price of the
2653
+ // primary-keyed read is that a repeat agy run re-attempts agy (10
2654
+ // minutes, today) before falling back again — accepted over ever
2655
+ // serving a cache whose label lies.
2656
+ await writeFile(
2657
+ join(
2658
+ work,
2659
+ `clipwindow-${clipWindowCacheKey({
2660
+ ...windowKeyParts,
2661
+ providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
2662
+ })}.json`,
2663
+ ),
2664
+ JSON.stringify(clipWindow, null, 2),
2665
+ );
2325
2666
  }
2326
2667
 
2327
2668
  // Slice the pipeline state to the window (§93.1), then let everything
@@ -2365,29 +2706,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2365
2706
  map = new TimeMap(cutlist);
2366
2707
  }
2367
2708
 
2368
- const cacheKey = createHash("sha1")
2369
- .update(
2370
- JSON.stringify([
2371
- providerName,
2372
- opts.llmModel,
2373
- opts.intent,
2374
- opts.cleanup,
2375
- opts.forceComponent ?? null,
2376
- // The framing constraints steer layout choice, so a change in the
2377
- // measured framing must invalidate the cached plan.
2378
- framingCtx ?? null,
2379
- // §93f: the clip target and the RESOLVED window key the plan too —
2380
- // without them a clip run and a full run of the same source would
2381
- // collide and answer from each other's cache (the §78 failure
2382
- // mode). Keyed POST-resolution so a replay that derives the same
2383
- // window hits the same entries.
2384
- clipTargetSec ?? null,
2385
- clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : null,
2386
- transcript.words.map((w) => w.text),
2387
- ]),
2388
- )
2389
- .digest("hex")
2390
- .slice(0, 8);
2709
+ // Parts split from the key for the same §143 reason as the clip window:
2710
+ // the writes below re-key on the provider that actually answered.
2711
+ const beatKeyParts = {
2712
+ promptVersion: PRODUCER_PROMPT_VERSION,
2713
+ llmModel: opts.llmModel,
2714
+ llmEffort,
2715
+ intent: opts.intent,
2716
+ cleanup: opts.cleanup,
2717
+ forceComponent: opts.forceComponent,
2718
+ framing: framingCtx,
2719
+ clipTargetSec,
2720
+ clipWindow,
2721
+ words: transcript.words.map((w) => w.text),
2722
+ aspect: landscape ? ("16:9" as const) : ("9:16" as const),
2723
+ };
2724
+ const cacheKey = beatSheetCacheKey({ ...beatKeyParts, providerName });
2391
2725
  const sceneCache = join(work, `scenes-${cacheKey}.json`);
2392
2726
  // The cover needs the editorial copy, which is not in the scene list — a
2393
2727
  // cached run must still be able to write one.
@@ -2415,9 +2749,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2415
2749
  clipFresh.graphics.asked,
2416
2750
  transcript,
2417
2751
  );
2418
- await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
2752
+ // Written under the ANSWERING provider's key (§143, same rule as the
2753
+ // clip-window write above): the adopted sheet came from the ONE
2754
+ // clip_beat_sheet call, which may have been the fallback's.
2755
+ const adoptKey = beatSheetCacheKey({
2756
+ ...beatKeyParts,
2757
+ providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
2758
+ });
2759
+ await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
2419
2760
  await writeFile(
2420
- beatCache,
2761
+ join(work, `beatsheet-${adoptKey}.json`),
2421
2762
  JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
2422
2763
  );
2423
2764
  } else if (existsSync(sceneCache)) {
@@ -2477,9 +2818,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2477
2818
  // Cache props only — overrides are user-owned and live in overrides.json,
2478
2819
  // never in production.json (that file is derived and every `produce`
2479
2820
  // run overwrites it, per the merge rule in `overrides.ts`).
2480
- await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
2821
+ // Keyed on who answered the beat_sheet call (§143) — see the clip-window
2822
+ // write above for why reads stay primary-keyed while writes do not.
2823
+ const freshKey = beatSheetCacheKey({
2824
+ ...beatKeyParts,
2825
+ providerName: actualProvider(provider.usage, "beat_sheet", providerName),
2826
+ });
2827
+ await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
2481
2828
  await writeFile(
2482
- beatCache,
2829
+ join(work, `beatsheet-${freshKey}.json`),
2483
2830
  JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
2484
2831
  );
2485
2832
  }
@@ -2515,8 +2862,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2515
2862
  // …and stamp it onto the artefact it explains, so `production.json` says
2516
2863
  // who planned it without a second file to cross-reference.
2517
2864
  const last = log.runs[log.runs.length - 1]!;
2865
+ // `last.provider` derives from records[0] — the FIRST call's provider,
2866
+ // which after a §143 timeout fallback is the primary that failed the
2867
+ // editorial call. The stamp answers "who planned this", so it is built
2868
+ // from the records that ANSWERED (`failed` attempts stay in usage.json
2869
+ // and every report — their cost is real — but a stamp listing the failed
2870
+ // attempt's placeholder model read "planned by claude-cli
2871
+ // (antigravity-default)" after a fallback run, 2026-08-22). Providers in
2872
+ // first-seen order, the same " → " rendering the usage report uses;
2873
+ // single-provider runs stamp exactly as before.
2874
+ const answered = provider.usage.filter((r) => !r.failed);
2875
+ const providersSeen = [...new Set(answered.map((r) => r.provider))];
2518
2876
  producerStamp = {
2519
- provider: last.provider ?? providerName,
2877
+ provider:
2878
+ providersSeen.length > 1
2879
+ ? providersSeen.join(" → ")
2880
+ : providersSeen[0] ?? last.provider ?? providerName,
2881
+ // `last.models` already excludes failed attempts' models (usage.ts,
2882
+ // same §143 rule) — a stamp that listed the timed-out placeholder read
2883
+ // "planned by claude-cli (antigravity-default)" after a fallback run.
2520
2884
  models: last.models,
2521
2885
  cached: last.cached,
2522
2886
  at: last.at,
@@ -2616,6 +2980,28 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2616
2980
  }
2617
2981
  overrideDoc = parsed.data;
2618
2982
  }
2983
+ // ---- Cleanup choices: the user's veto over the AUTOMATIC cutlist (cut
2984
+ // review step 3) ------------------------------------------------------
2985
+ // Applied here — the same load `cuts` rides, before `applyUserCuts` — and
2986
+ // in exactly this order on purpose: cleanup vetoes reshape the PROPOSAL,
2987
+ // then user cuts subtract from the result as they always did, so a user
2988
+ // cut drawn over a vetoed pause still cuts (an explicit user action
2989
+ // outranks a veto). The proposal itself is captured FIRST for
2990
+ // `production.json`'s `cutlistProposed` — the resolution is lossy (a
2991
+ // vetoed removal merges into a plain keep), and the editor's checkboxes
2992
+ // re-derive the veto state from proposal + choices through the same
2993
+ // `applyCleanupChoices` this run used (ProductionSchema has the full why).
2994
+ // Consumers of `map` ABOVE this point (the repair pass's isCut guard, the
2995
+ // producer's outputDuration hint) saw the pre-veto map: both are
2996
+ // conservative uses — a refused word-merge across a span that comes back,
2997
+ // a duration hint a few seconds short — never a wrong cut.
2998
+ const cutlistProposed = cutlist;
2999
+ const cleanupVetoed = vetoedRemovals(cutlist, overrideDoc.cleanup);
3000
+ if (cleanupVetoed.length > 0) {
3001
+ cutlist = applyCleanupChoices(cutlist, overrideDoc.cleanup);
3002
+ map = new TimeMap(cutlist);
3003
+ console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
3004
+ }
2619
3005
  // `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
2620
3006
  // no `src` yet) and any already-re-anchored splits/pins are expressed
2621
3007
  // relative to whatever render-props the user was LAST looking at, not the
@@ -3134,7 +3520,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3134
3520
  transcript: rawTranscript,
3135
3521
  repairs: repairs.length > 0 ? repairs : undefined,
3136
3522
  analysis,
3523
+ // The APPLIED truth vs the PROPOSAL — see ProductionSchema for why both
3524
+ // are recorded: `cutlist` keeps the report/exporters honest about what
3525
+ // happened; `cutlistProposed` keeps the declined categories recoverable
3526
+ // for the editor (a vetoed removal merges into a plain keep, so the
3527
+ // resolved list alone cannot name what was declined).
3137
3528
  cutlist,
3529
+ cutlistProposed,
3138
3530
  ...(clipWindow && clipTargetSec !== undefined
3139
3531
  ? { clip: { targetSec: clipTargetSec, ...clipWindow } }
3140
3532
  : {}),
@@ -3790,12 +4182,147 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3790
4182
  console.log(overridesWriteLine(cutResult.changed));
3791
4183
  }
3792
4184
 
4185
+ // Resolved HERE — above the --no-render exit rather than at the thumbnail
4186
+ // gate / pack section below — so the gate, the post-render consumers AND
4187
+ // the command.json record (which both exits now write) all read one
4188
+ // answer. Typed-beats-config, `typeof` not truthiness (the `portrait`
4189
+ // posture): config.json is hand-edited and unparsed, and a
4190
+ // `"audience": true` typo must resolve to "no audience".
4191
+ const youtube = resolveYoutube(opts.youtube, cfg.youtube);
4192
+ // resolvePortrait (portrait-override.ts) carries the expandHome treatment
4193
+ // of the flag and config paths, and puts the workdir's portrait-override
4194
+ // ABOVE both (editor face swap, 2026-08-17): a per-project expression
4195
+ // chosen in the editor must survive CLI re-renders — the flag/config
4196
+ // portrait is the fallback headshot, and a replay silently reverting the
4197
+ // swapped face would undo the one thing the swap exists for.
4198
+ const portrait = resolvePortrait({
4199
+ overridePath: portraitOverridePath(work),
4200
+ flagPortrait: opts.portrait,
4201
+ cfgPortrait: cfg.portrait,
4202
+ })?.path;
4203
+ const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
4204
+ const thumbnailBrief =
4205
+ opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
4206
+
4207
+ // Record THIS invocation so the editor's Render button can replay it (R11
4208
+ // Task 4). Nothing else can reconstruct it — production.json has the
4209
+ // source path, cleanup and intent, but not --produce, --out or the LLM
4210
+ // flags — and guessing would silently render a different video than the
4211
+ // one on screen. execArgv carries the module loader (tsx in dev), so the
4212
+ // replay works from source and from a compiled build alike.
4213
+ //
4214
+ // Written BEFORE the render/no-render fork, not after the render (cut-review
4215
+ // step 1): a --no-render workdir used to carry NO command.json at all, so
4216
+ // the editor's Render button 412'd with "run `ossclip produce` once from
4217
+ // the terminal" — a dead end that made `--review` (produce without
4218
+ // rendering, review in the editor, render ONCE from its Render button)
4219
+ // impossible. Every pin below is resolved by this point, so the record is
4220
+ // byte-identical to what the post-render write produced; the side effect is
4221
+ // that a crashed render now leaves a record its own Render button can
4222
+ // retry. recordedProduceArgs strips --review/--no-render at record, so the
4223
+ // replay actually renders instead of looping.
4224
+ //
4225
+ // The provider may have been AUTO-DETECTED from this shell's environment
4226
+ // (a GEMINI_/ANTHROPIC_ key exported here). The editor's Render replays
4227
+ // this argv from the EDIT SERVER's environment, which may not have that
4228
+ // key — and the auto-detection would then silently pick a DIFFERENT
4229
+ // provider (R16 §75). Pin the RESOLVED choice into the recorded args —
4230
+ // never the key itself; secrets stay out of the workdir — so a replay
4231
+ // uses the same configuration or fails loudly asking for it.
4232
+ // §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
4233
+ // editor's Render replays this argv; if replay re-asked the model and got a
4234
+ // slightly different window, every saved override — anchored to scene ids
4235
+ // and word indices — would land on the wrong words. The word range, not
4236
+ // just `--clip 60`, is what makes replay deterministic with zero LLM calls.
4237
+ //
4238
+ // §129: NOT process.argv. A wizard or bare-path run re-enters commander
4239
+ // with a BUILT argv while process.argv still holds the original invocation
4240
+ // (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
4241
+ // recording process.argv shipped a command that replays as
4242
+ // `ossclip <path> --llm …` and dies on "unknown option '--llm'".
4243
+ // recordedProduceArgs prefers the argv the re-entry stashed and falls back
4244
+ // to process.argv for a directly typed `ossclip produce …`, which stays
4245
+ // byte-identical to what was always recorded.
4246
+ // Watermark pin, same §75 shape, and in BOTH directions (review,
4247
+ // Important): the effective default comes from THIS machine's
4248
+ // ~/.ossclip/config.json, so an unpinned record replays differently
4249
+ // wherever that config differs — an off-run would silently gain a credit
4250
+ // under a later/foreign config-on, an on-run would silently lose it. The
4251
+ // RESOLVED state is always pinned; a typed flag is already in the argv and
4252
+ // the includes-guard leaves it alone.
4253
+ // Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
4254
+ // the override-inclusive `captionsHidden` — overrides.json travels with
4255
+ // the workdir and is re-read on every replay, so pinning --no-captions
4256
+ // because the EDITOR hid them would freeze an edit the user may later
4257
+ // undo in that same editor. See recordedProduceArgs for why the pin is
4258
+ // unconditional even though captions' default is config-independent today.
4259
+ // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4260
+ // argv — "auto" has no flag spelling and stays unpinned (see
4261
+ // recordedProduceArgs for why that is safe today).
4262
+ // Youtube pin: the watermark's config-dependent-default rationale exactly —
4263
+ // resolved both ways, so a later config edit can't flip what Render
4264
+ // replays. Portrait and dictionary pin the RESOLVED values (a path and
4265
+ // terms, never a secret) for the same reason; recordedProduceArgs owns
4266
+ // the non-empty/includes guards.
4267
+ const recordedArgs = recordedProduceArgs({
4268
+ llm: provider ? providerName : undefined,
4269
+ // The RESOLVED effort (§143), pinned like the dictionary: it may have
4270
+ // come from this machine's config, and it keys the plan caches — an
4271
+ // unpinned record would re-plan on replay after a config edit.
4272
+ llmEffort,
4273
+ clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4274
+ watermark,
4275
+ captions: opts.captions ?? true,
4276
+ jumpCuts: jumpCutsMode,
4277
+ dictionary,
4278
+ youtube,
4279
+ portrait,
4280
+ audience,
4281
+ thumbnailBrief,
4282
+ });
4283
+ // produce is the ONLY command that may write command.json — edit.ts's §129
4284
+ // heal prepends the "produce" literal to any record that doesn't start
4285
+ // with it, so a record made by `transcribe`/`analyze` (which run this same
4286
+ // pipeline with render: false, and used to be kept out by the write
4287
+ // sitting after the early return below) would heal into
4288
+ // `produce transcribe …` and replay garbage. Their argv cannot start with
4289
+ // "produce" (§129: the stash mirrors the parse that ran, and only
4290
+ // produce's re-entries stash), which is the gate.
4291
+ if (recordedArgs[0] === "produce") {
4292
+ await writeFile(
4293
+ join(work, "command.json"),
4294
+ JSON.stringify(
4295
+ {
4296
+ execPath: process.execPath,
4297
+ execArgv: process.execArgv,
4298
+ script: process.argv[1],
4299
+ args: recordedArgs,
4300
+ cwd: process.cwd(),
4301
+ out: outPath,
4302
+ },
4303
+ null,
4304
+ 2,
4305
+ ),
4306
+ );
4307
+ // Every produce run is a project the picker should offer (R17 §83) —
4308
+ // best-effort, so a read-only home dir never fails the render.
4309
+ await recordRecentProject(work);
4310
+ }
4311
+
3793
4312
  if (!opts.render) {
3794
4313
  // §140: the breakdown goes above the closing lines on both exits, so the
3795
4314
  // last thing on screen stays the success line and the edit hint.
3796
4315
  console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
3797
- console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
3798
- console.log(editHint(work));
4316
+ if (opts.review === true) {
4317
+ // --review (step 1's report, fixed in step 3): the editor opens itself
4318
+ // right after this return, so the --no-render skip line plus an
4319
+ // `ossclip edit …` hint would tell the user to do what is already
4320
+ // happening. One line that says what comes next instead.
4321
+ console.log("▸ review: opening the editor — render once from its Render button");
4322
+ } else {
4323
+ console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
4324
+ console.log(editHint(work));
4325
+ }
3799
4326
  return {
3800
4327
  workdir: work,
3801
4328
  rendered: false,
@@ -3817,27 +4344,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3817
4344
  // multi-minute render. Interactive runs approve (or edit, or skip) it
3818
4345
  // here; the file it writes is what thumbnailStep honors after render — and
3819
4346
  // what a non-TTY replay (the editor's Render) reuses, which is the whole
3820
- // persistence story.
3821
- //
3822
- // Resolved HERE rather than at the pack section below so the gate and the
3823
- // post-render consumers read one answer. Typed-beats-config, `typeof` not
3824
- // truthiness (the `portrait` posture): config.json is hand-edited and
3825
- // unparsed, and a `"audience": true` typo must resolve to "no audience".
3826
- const youtube = resolveYoutube(opts.youtube, cfg.youtube);
3827
- // resolvePortrait (portrait-override.ts) carries the expandHome treatment
3828
- // of the flag and config paths, and puts the workdir's portrait-override
3829
- // ABOVE both (editor face swap, 2026-08-17): a per-project expression
3830
- // chosen in the editor must survive CLI re-renders — the flag/config
3831
- // portrait is the fallback headshot, and a replay silently reverting the
3832
- // swapped face would undo the one thing the swap exists for.
3833
- const portrait = resolvePortrait({
3834
- overridePath: portraitOverridePath(work),
3835
- flagPortrait: opts.portrait,
3836
- cfgPortrait: cfg.portrait,
3837
- })?.path;
3838
- const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
3839
- const thumbnailBrief =
3840
- opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
4347
+ // persistence story. The youtube/portrait/audience/brief resolutions this
4348
+ // gate reads moved above the --no-render exit (cut-review step 1) so the
4349
+ // command.json record pins the same answers on both exits.
3841
4350
  const geminiKey = process.env.GEMINI_API_KEY;
3842
4351
  const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
3843
4352
  // The gate reuses thumbnailDecision (plus the mime check) so the prompt
@@ -4061,7 +4570,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4061
4570
  {
4062
4571
  // §35's cap applies here too: a cached beat sheet from before the fix, or
4063
4572
  // the hook fallback, must not slip a 13-word paragraph onto a thumbnail.
4064
- const coverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
4573
+ const generatedCoverText = coverHeadline(beatSheet?.coverText ?? beatSheet?.hook ?? "");
4574
+ // A headline someone typed (`ossclip cover --text`, or the editor) is a
4575
+ // user-owned file, exactly like overrides.json and the approved thumbnail
4576
+ // concept: this run does NOT quietly replace it with a fresh beat sheet's
4577
+ // coverText. Read before the decision below, because it decides the text
4578
+ // the decision is made about.
4579
+ const priorCover = await readCoverProvenance(work);
4580
+ const heldCover = coverTextHold({
4581
+ generated: generatedCoverText,
4582
+ persisted: priorCover,
4583
+ reset: opts.coverTextReset === true,
4584
+ });
4585
+ if (heldCover.message) console.log(heldCover.message);
4586
+ const coverText = heldCover.text;
4065
4587
  // Urdu field run 2026-08-05: a run without --produce has no hook text,
4066
4588
  // and skipping the cover for that threw away the part that never needed
4067
4589
  // text — the sharpness-scored face frame. No headline now means a bare
@@ -4088,7 +4610,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4088
4610
  if (!pick) {
4089
4611
  console.log("▸ no usable cover frame found — skipping cover");
4090
4612
  } else {
4091
- const frameName = "cover-frame.png";
4613
+ const frameName = COVER_FRAME_BASENAME;
4092
4614
  await run(cfg.ffmpegPath, [
4093
4615
  "-v", "error",
4094
4616
  "-ss", pick.timeSec.toFixed(3),
@@ -4122,16 +4644,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4122
4644
  `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
4123
4645
  `(${pick.hasFace ? "face" : "no face"}, sharpness ${pick.sharpness.toFixed(0)})…`,
4124
4646
  );
4125
- if (sourceTitled) {
4126
- console.log(" ▸ source already has a title in this frame — shipping it without a banner");
4127
- } else if (pick.face) {
4647
+ // …unless the headline is the user's own, which §34 does not get to
4648
+ // erase (cover.ts's coverBannerText has the reasoning). Unchanged
4649
+ // for a generated headline, including the line it prints.
4650
+ const banner = coverBannerText({
4651
+ text: coverText,
4652
+ textSource: heldCover.textSource,
4653
+ sourceTitled,
4654
+ });
4655
+ if (banner.note) console.log(banner.note);
4656
+ // The band log is about routing a banner around the face, so it
4657
+ // follows whether there IS a banner — for a §34-suppressed cover
4658
+ // there is none, for a surviving user headline there is.
4659
+ if (banner.text !== "" && pick.face) {
4128
4660
  const band = coverTextRect(pick.face, frame);
4129
4661
  console.log(
4130
4662
  ` ▸ banner in the ${band.y + band.h / 2 < pick.face.centerYFrac ? "band above" : "band below"} ` +
4131
4663
  `the face (${(band.y * 100).toFixed(0)}-${((band.y + band.h) * 100).toFixed(0)}%)`,
4132
4664
  );
4133
4665
  }
4134
- bannerText = sourceTitled ? "" : coverText;
4666
+ bannerText = banner.text;
4135
4667
  } else {
4136
4668
  console.log(
4137
4669
  `▸ cover from ${pick.timeSec.toFixed(1)}s ` +
@@ -4139,22 +4671,71 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4139
4671
  `— no banner text (run --produce for one)`,
4140
4672
  );
4141
4673
  }
4142
- await renderCover(
4143
- {
4144
- frameFileName: frameName,
4145
- text: bannerText,
4146
- // The RESOLVED theme — so the cover's banner already carries the
4147
- // config theme (F6) via resolveTheme's base, no separate wiring.
4148
- theme,
4149
- face: pick.face,
4150
- // The cover is the OUTPUT's thumbnail — a landscape render gets a
4151
- // landscape cover (R16 §76). The still was already extracted at
4152
- // this size; only the composition disagreed.
4153
- frame: { width: frame.width, height: frame.height },
4154
- },
4155
- { publicDir: work, outPath: coverPath, browserExecutable: cfg.browserExecutable },
4156
- );
4674
+ // The shared builder, not an inline props literal: `ossclip cover`
4675
+ // and the editor's regenerate endpoint call the same one, and two
4676
+ // spellings of these arguments drifting apart is the defect that
4677
+ // whole feature exists to prevent (cover.ts).
4678
+ const coverRender = buildCoverRender({
4679
+ frameFileName: frameName,
4680
+ text: bannerText,
4681
+ // The RESOLVED theme — so the cover's banner already carries the
4682
+ // config theme (F6) via resolveTheme's base, no separate wiring.
4683
+ theme,
4684
+ face: pick.face,
4685
+ // The cover is the OUTPUT's thumbnail — a landscape render gets a
4686
+ // landscape cover (R16 §76). The still was already extracted at
4687
+ // this size; only the composition disagreed.
4688
+ frame: { width: frame.width, height: frame.height },
4689
+ publicDir: work,
4690
+ outPath: coverPath,
4691
+ browserExecutable: cfg.browserExecutable,
4692
+ });
4693
+ await renderCover(coverRender.props, coverRender.opts);
4157
4694
  console.log(`✓ cover → ${coverPath}`);
4695
+ // Provenance, so the next headline change costs seconds instead of a
4696
+ // full re-render. Written AFTER the render succeeded and describing
4697
+ // what that render actually used — `pick.face` is the cover-crop
4698
+ // geometry nothing else on disk carries, and `cropVf` is not
4699
+ // reconstructible from the workdir either.
4700
+ //
4701
+ // Additive by contract (§112, the posture the YouTube pack block
4702
+ // below states): the video and the cover are already on disk, so a
4703
+ // failed sidecar write is one loud line, never a dead run.
4704
+ try {
4705
+ await writeCoverProvenance(work, {
4706
+ version: 1,
4707
+ text: bannerText,
4708
+ // "user" only when THIS run kept a headline someone typed
4709
+ // (coverTextHold above) — produce itself never authors one.
4710
+ textSource: heldCover.textSource,
4711
+ frame: {
4712
+ // produce keeps picking from the SOURCE — zero perturbation for
4713
+ // existing users. `ossclip cover` is the one that defaults to
4714
+ // the finished render.
4715
+ source: "source",
4716
+ timeSec: pick.timeSec,
4717
+ face: pick.face ?? null,
4718
+ hasFace: pick.hasFace,
4719
+ sharpness: pick.sharpness,
4720
+ fileName: frameName,
4721
+ // Workdir-relative when the video IS a workdir intermediate (a
4722
+ // folder run's concat mezzanine), absolute otherwise — the rule
4723
+ // `ossclip cover` reads it back with.
4724
+ sourceVideo: provenanceVideoPath(work, input),
4725
+ // cropFilter returns "" for an uncropped source; null says "no
4726
+ // crop" without a caller having to know that convention.
4727
+ cropVf: cropVf || null,
4728
+ },
4729
+ size: { width: frame.width, height: frame.height },
4730
+ out: coverPath,
4731
+ });
4732
+ } catch (err) {
4733
+ console.log(
4734
+ ` ⚠ could not write ${COVER_PROVENANCE_BASENAME} ` +
4735
+ `(${err instanceof Error ? err.message : String(err)}) — ` +
4736
+ `the cover shipped; a later headline change will re-pick the frame`,
4737
+ );
4738
+ }
4158
4739
  }
4159
4740
  }
4160
4741
  }
@@ -4191,14 +4772,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4191
4772
  } else {
4192
4773
  // Beat-sheet cache shape: keyed on everything that changes the answer —
4193
4774
  // who is asked, with what editorial steer, about which words.
4194
- const packKey = createHash("sha1")
4775
+ // Parameterized on the provider for the §143 read/write split: the
4776
+ // write below re-keys on who actually answered the pack call.
4777
+ const packKeyFor = (p: string): string => createHash("sha1")
4195
4778
  .update(
4196
4779
  JSON.stringify([
4197
4780
  // Prompt changes change the answer (the §78 posture): the v2
4198
4781
  // rewrite must not serve a pack cached under v1's questions.
4199
4782
  YOUTUBE_PROMPT_VERSION,
4200
- providerName,
4783
+ p,
4201
4784
  opts.llmModel ?? "",
4785
+ // Appended only when set — the plan-cache rule (§78 via §143's
4786
+ // effort knob): an unset effort must keep every warm workdir's
4787
+ // key byte-identical, and a changed effort is a different answer.
4788
+ ...(llmEffort !== undefined ? [llmEffort] : []),
4202
4789
  opts.intent ?? "",
4203
4790
  // Steer, so part of the key — a changed audience is a different
4204
4791
  // pack, not a cache hit.
@@ -4214,7 +4801,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4214
4801
  )
4215
4802
  .digest("hex")
4216
4803
  .slice(0, 8);
4217
- const packCache = join(work, `youtube-${packKey}.json`);
4804
+ const packCache = join(work, `youtube-${packKeyFor(providerName)}.json`);
4218
4805
  if (existsSync(packCache)) {
4219
4806
  pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
4220
4807
  console.log("▸ youtube: metadata cached");
@@ -4234,7 +4821,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4234
4821
  durationSec: map.outputDuration,
4235
4822
  }),
4236
4823
  );
4237
- await writeFile(packCache, JSON.stringify(pack, null, 2));
4824
+ // §143 read/write split, same as the plan and repair caches: the
4825
+ // pack files under whoever wrote it.
4826
+ await writeFile(
4827
+ join(
4828
+ work,
4829
+ `youtube-${packKeyFor(actualProvider(provider!.usage, "youtube_pack", providerName))}.json`,
4830
+ ),
4831
+ JSON.stringify(pack, null, 2),
4832
+ );
4238
4833
  } catch (err) {
4239
4834
  // NEVER cache a failure (§106), and never fail the produce that
4240
4835
  // just rendered over a metadata sidecar: one loud line, the video
@@ -4300,84 +4895,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4300
4895
  });
4301
4896
  }
4302
4897
  }
4303
- // Record THIS invocation so the editor's Render button can replay it (R11
4304
- // Task 4). Nothing else can reconstruct it — production.json has the
4305
- // source path, cleanup and intent, but not --produce, --out or the LLM
4306
- // flags — and guessing would silently render a different video than the
4307
- // one on screen. execArgv carries the module loader (tsx in dev), so the
4308
- // replay works from source and from a compiled build alike.
4309
- // The provider may have been AUTO-DETECTED from this shell's environment
4310
- // (a GEMINI_/ANTHROPIC_ key exported here). The editor's Render replays
4311
- // this argv from the EDIT SERVER's environment, which may not have that
4312
- // key — and the auto-detection would then silently pick a DIFFERENT
4313
- // provider (R16 §75). Pin the RESOLVED choice into the recorded args —
4314
- // never the key itself; secrets stay out of the workdir — so a replay
4315
- // uses the same configuration or fails loudly asking for it.
4316
- // §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
4317
- // editor's Render replays this argv; if replay re-asked the model and got a
4318
- // slightly different window, every saved override — anchored to scene ids
4319
- // and word indices — would land on the wrong words. The word range, not
4320
- // just `--clip 60`, is what makes replay deterministic with zero LLM calls.
4321
- //
4322
- // §129: NOT process.argv. A wizard or bare-path run re-enters commander
4323
- // with a BUILT argv while process.argv still holds the original invocation
4324
- // (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
4325
- // recording process.argv shipped a command that replays as
4326
- // `ossclip <path> --llm …` and dies on "unknown option '--llm'".
4327
- // recordedProduceArgs prefers the argv the re-entry stashed and falls back
4328
- // to process.argv for a directly typed `ossclip produce …`, which stays
4329
- // byte-identical to what was always recorded.
4330
- // Watermark pin, same §75 shape, and in BOTH directions (review,
4331
- // Important): the effective default comes from THIS machine's
4332
- // ~/.ossclip/config.json, so an unpinned record replays differently
4333
- // wherever that config differs — an off-run would silently gain a credit
4334
- // under a later/foreign config-on, an on-run would silently lose it. The
4335
- // RESOLVED state is always pinned; a typed flag is already in the argv and
4336
- // the includes-guard leaves it alone.
4337
- // Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
4338
- // the override-inclusive `captionsHidden` — overrides.json travels with
4339
- // the workdir and is re-read on every replay, so pinning --no-captions
4340
- // because the EDITOR hid them would freeze an edit the user may later
4341
- // undo in that same editor. See recordedProduceArgs for why the pin is
4342
- // unconditional even though captions' default is config-independent today.
4343
- // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4344
- // argv — "auto" has no flag spelling and stays unpinned (see
4345
- // recordedProduceArgs for why that is safe today).
4346
- // Youtube pin: the watermark's config-dependent-default rationale exactly —
4347
- // resolved both ways, so a later config edit can't flip what Render
4348
- // replays. Portrait and dictionary pin the RESOLVED values (a path and
4349
- // terms, never a secret) for the same reason; recordedProduceArgs owns
4350
- // the non-empty/includes guards.
4351
- const recordedArgs = recordedProduceArgs({
4352
- llm: provider ? providerName : undefined,
4353
- clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4354
- watermark,
4355
- captions: opts.captions ?? true,
4356
- jumpCuts: jumpCutsMode,
4357
- dictionary,
4358
- youtube,
4359
- portrait,
4360
- audience,
4361
- thumbnailBrief,
4362
- });
4363
- await writeFile(
4364
- join(work, "command.json"),
4365
- JSON.stringify(
4366
- {
4367
- execPath: process.execPath,
4368
- execArgv: process.execArgv,
4369
- script: process.argv[1],
4370
- args: recordedArgs,
4371
- cwd: process.cwd(),
4372
- out: outPath,
4373
- },
4374
- null,
4375
- 2,
4376
- ),
4377
- );
4378
- // Every produce run is a project the picker should offer (R17 §83) —
4379
- // best-effort, so a read-only home dir never fails the render.
4380
- await recordRecentProject(work);
4898
+ // command.json was recorded above the --no-render exit (cut-review step 1)
4899
+ // — see the block before that fork for the §75/§93g/§129 pinning story.
4381
4900
  console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
4382
4901
  console.log(`✓ done → ${outPath}`);
4383
4902
  if (isInteractive()) {