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/editor-dist/assets/index-BBJKC2Z2.js +164 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +846 -0
- package/src/edit.ts +204 -45
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/llm-detect.ts +11 -0
- package/src/produce.ts +706 -187
- package/src/program.ts +153 -26
- package/src/replay-argv.ts +22 -1
- package/src/setup/download.ts +24 -0
- package/src/setup/manifest.ts +30 -12
- package/src/setup/setup.ts +47 -4
- package/editor-dist/assets/index-DVI51_2u.js +0 -163
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
|
-
|
|
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
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
)
|
|
2296
|
-
|
|
2297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
3798
|
-
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
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 =
|
|
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
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-${
|
|
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
|
-
|
|
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
|
-
//
|
|
4304
|
-
//
|
|
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()) {
|