ossclip 0.1.27 → 0.1.29
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/edit.ts +42 -0
- package/src/interactive/produce-argv.ts +14 -0
- package/src/interactive/produce-wizard.ts +28 -0
- package/src/llm-detect.ts +11 -0
- package/src/produce.ts +638 -177
- package/src/program.ts +48 -4
- 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-CWIUcpJC.js +0 -163
package/src/produce.ts
CHANGED
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
createProvider,
|
|
53
53
|
createTieredProvider,
|
|
54
54
|
defaultProviderName,
|
|
55
|
+
fallbackProviderName,
|
|
55
56
|
defaultTheme,
|
|
56
57
|
detectSilences,
|
|
57
58
|
dropHiddenCues,
|
|
@@ -91,6 +92,8 @@ import {
|
|
|
91
92
|
portraitMimeType,
|
|
92
93
|
thumbnailDecision,
|
|
93
94
|
thumbnailImageCacheName,
|
|
95
|
+
applyCleanupChoices,
|
|
96
|
+
vetoedRemovals,
|
|
94
97
|
applyUserCuts,
|
|
95
98
|
pruneHidesInsideCuts,
|
|
96
99
|
loadConfig,
|
|
@@ -118,6 +121,8 @@ import {
|
|
|
118
121
|
measureLevels,
|
|
119
122
|
probe,
|
|
120
123
|
produceScenes,
|
|
124
|
+
PRODUCER_PROMPT_VERSION,
|
|
125
|
+
type FramingContext,
|
|
121
126
|
reclampPinnedTiming,
|
|
122
127
|
reconcileCopy,
|
|
123
128
|
repairTranscript,
|
|
@@ -144,7 +149,10 @@ import {
|
|
|
144
149
|
type CleanupLevel,
|
|
145
150
|
type ClipWindow,
|
|
146
151
|
type Layout,
|
|
152
|
+
type LlmEffort,
|
|
147
153
|
type LlmProvider,
|
|
154
|
+
type LlmUsage,
|
|
155
|
+
AGY_PRINT_TIMEOUT,
|
|
148
156
|
type Production,
|
|
149
157
|
ossclipOutputPathFor,
|
|
150
158
|
type ProviderName,
|
|
@@ -154,7 +162,7 @@ import {
|
|
|
154
162
|
type Transcript,
|
|
155
163
|
} from "@ossclip/core";
|
|
156
164
|
import { recordRecentProject } from "./edit";
|
|
157
|
-
import { binOnPath, detectionLine } from "./llm-detect";
|
|
165
|
+
import { binOnPath, detectionLine, fallbackLine } from "./llm-detect";
|
|
158
166
|
import {
|
|
159
167
|
modelImpliedLanguage,
|
|
160
168
|
modelUrl,
|
|
@@ -272,11 +280,165 @@ export function transcriptCacheReusable(
|
|
|
272
280
|
};
|
|
273
281
|
}
|
|
274
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The beat-sheet/scenes cache key: everything that changes the plan — which
|
|
285
|
+
* prompt asked, who was asked, with what editorial steer, about which words,
|
|
286
|
+
* in what frame. Pure and exported so the §78 posture ("a change that changes
|
|
287
|
+
* the answer must change the key") is testable without a workdir.
|
|
288
|
+
*
|
|
289
|
+
* Two of these are new, and only one of them was a live bug:
|
|
290
|
+
* - `promptVersion` (the caller passes PRODUCER_PROMPT_VERSION) is the §78
|
|
291
|
+
* fix proper — before it, a warm workdir kept serving a sheet the OLD
|
|
292
|
+
* prompt wrote, exactly the failure YOUTUBE_PROMPT_VERSION exists for.
|
|
293
|
+
* - `aspect` is LATENT rather than active: it changes the user prompt (the
|
|
294
|
+
* LANDSCAPE block, R21 §101), but `--aspect 16:9` also derives its own
|
|
295
|
+
* `-16x9` workdir and this cache is a file inside it, so a portrait and a
|
|
296
|
+
* landscape plan of the same source cannot meet today. Keyed anyway —
|
|
297
|
+
* the collision is one workdir-naming change away, and the key should not
|
|
298
|
+
* depend on a different module's directory scheme to stay correct.
|
|
299
|
+
*/
|
|
300
|
+
export function beatSheetCacheKey(parts: {
|
|
301
|
+
promptVersion: string;
|
|
302
|
+
/** The primary on reads; on a §143 fallback WRITE, the provider that
|
|
303
|
+
* actually answered — a plain name from the usage records. */
|
|
304
|
+
providerName: string;
|
|
305
|
+
llmModel?: string;
|
|
306
|
+
/** The §143 effort knob — it steers the editorial call, so it changes the plan. */
|
|
307
|
+
llmEffort?: LlmEffort;
|
|
308
|
+
intent?: string;
|
|
309
|
+
cleanup: CleanupLevel;
|
|
310
|
+
forceComponent?: SceneComponentId;
|
|
311
|
+
/** Framing constraints steer layout choice, so a re-measure must replan. */
|
|
312
|
+
framing?: FramingContext;
|
|
313
|
+
clipTargetSec?: number;
|
|
314
|
+
clipWindow?: ClipWindow | null;
|
|
315
|
+
/** The repaired transcript's TEXT — see the call site on why not the count. */
|
|
316
|
+
words: readonly string[];
|
|
317
|
+
aspect: "9:16" | "16:9";
|
|
318
|
+
}): string {
|
|
319
|
+
return createHash("sha1")
|
|
320
|
+
.update(
|
|
321
|
+
JSON.stringify([
|
|
322
|
+
parts.promptVersion,
|
|
323
|
+
parts.providerName,
|
|
324
|
+
parts.llmModel,
|
|
325
|
+
parts.intent,
|
|
326
|
+
parts.cleanup,
|
|
327
|
+
parts.forceComponent ?? null,
|
|
328
|
+
parts.framing ?? null,
|
|
329
|
+
// §93f: the clip target and the RESOLVED window key the plan too —
|
|
330
|
+
// without them a clip run and a full run of the same source would
|
|
331
|
+
// collide and answer from each other's cache (the §78 failure
|
|
332
|
+
// mode). Keyed POST-resolution so a replay that derives the same
|
|
333
|
+
// window hits the same entries.
|
|
334
|
+
parts.clipTargetSec ?? null,
|
|
335
|
+
parts.clipWindow ? `${parts.clipWindow.startWord}:${parts.clipWindow.endWord}` : null,
|
|
336
|
+
parts.words,
|
|
337
|
+
parts.aspect,
|
|
338
|
+
// The §143 effort knob — appended at the END, and only when SET: an
|
|
339
|
+
// unconditional `?? null` would change the serialization of every
|
|
340
|
+
// existing key and silently re-plan every warm workdir for users who
|
|
341
|
+
// never touched the knob. §78 only demands that a DIFFERENT effort
|
|
342
|
+
// miss; an unset one must keep hitting what it always hit.
|
|
343
|
+
...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
|
|
344
|
+
]),
|
|
345
|
+
)
|
|
346
|
+
.digest("hex")
|
|
347
|
+
.slice(0, 8);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The clip-window cache key (`clipwindow-<hash>.json`), which answers a
|
|
352
|
+
* different question — WHICH ~Ns of the take to keep (R19 §93) — but asks it
|
|
353
|
+
* with the SAME producer prompt, via `produceScenes(…, clip: {…})`. So it
|
|
354
|
+
* carries the same two fields `beatSheetCacheKey` gained, for the same
|
|
355
|
+
* reasons:
|
|
356
|
+
* - `promptVersion`: without it, editing the producer prompt leaves every
|
|
357
|
+
* warm workdir serving a window that was resolved under the OLD prompt —
|
|
358
|
+
* the §78 failure mode, one call above where it was just fixed. The
|
|
359
|
+
* tradeoff is accepted deliberately: a version bump DOES throw away an
|
|
360
|
+
* already-resolved window and costs one LLM call to re-select it. That is
|
|
361
|
+
* the correct price. Planning a whole video against a window the current
|
|
362
|
+
* prompt would not have chosen is worse than an LLM call.
|
|
363
|
+
* - `aspect`: LATENT today, exactly as it is one function down — `--aspect
|
|
364
|
+
* 16:9` derives its own `-16x9` workdir and this cache is a file inside
|
|
365
|
+
* it, so a portrait and a landscape selection of the same source cannot
|
|
366
|
+
* meet. Keyed anyway: the aspect reaches the prompt (both halves of it
|
|
367
|
+
* since the producerSystem change), and the key should not depend on a
|
|
368
|
+
* different module's directory scheme to stay correct.
|
|
369
|
+
*
|
|
370
|
+
* Deliberately NOT keyed, matching what the call site passes to the selection
|
|
371
|
+
* call: cleanup, forced component and the clip window itself. The first two
|
|
372
|
+
* do not reach this prompt, and the third is what it returns.
|
|
373
|
+
*/
|
|
374
|
+
export function clipWindowCacheKey(parts: {
|
|
375
|
+
promptVersion: string;
|
|
376
|
+
/** Same read/write split as `beatSheetCacheKey` (§143). */
|
|
377
|
+
providerName: string;
|
|
378
|
+
llmModel?: string;
|
|
379
|
+
/** The §143 effort knob — the selection rides the same editorial call. */
|
|
380
|
+
llmEffort?: LlmEffort;
|
|
381
|
+
intent?: string;
|
|
382
|
+
clipTargetSec: number;
|
|
383
|
+
/** Framing constraints steer the selection call the same way (see above). */
|
|
384
|
+
framing?: FramingContext;
|
|
385
|
+
/** The repaired transcript's TEXT — the window is word-indexed into it. */
|
|
386
|
+
words: readonly string[];
|
|
387
|
+
aspect: "9:16" | "16:9";
|
|
388
|
+
}): string {
|
|
389
|
+
return createHash("sha1")
|
|
390
|
+
.update(
|
|
391
|
+
JSON.stringify([
|
|
392
|
+
parts.promptVersion,
|
|
393
|
+
parts.providerName,
|
|
394
|
+
parts.llmModel,
|
|
395
|
+
parts.intent,
|
|
396
|
+
parts.clipTargetSec,
|
|
397
|
+
parts.framing ?? null,
|
|
398
|
+
parts.words,
|
|
399
|
+
parts.aspect,
|
|
400
|
+
// Appended at the END, only when SET — beatSheetCacheKey's rule: an
|
|
401
|
+
// unset effort must keep every existing key byte-identical.
|
|
402
|
+
...(parts.llmEffort !== undefined ? [parts.llmEffort] : []),
|
|
403
|
+
]),
|
|
404
|
+
)
|
|
405
|
+
.digest("hex")
|
|
406
|
+
.slice(0, 8);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The provider that actually answered the call whose output is being cached
|
|
411
|
+
* (2026-08-22, FINDINGS §143): after a timeout fallback the resolved
|
|
412
|
+
* `providerName` names the provider that FAILED, and keying a cache write on
|
|
413
|
+
* it would attribute the fallback's plan to a provider that never produced
|
|
414
|
+
* it. Last matching record wins — retries and the fallback both append to the
|
|
415
|
+
* usage log, so the last writer is the one whose answer survived. Pure and
|
|
416
|
+
* exported so the attribution is testable without a workdir.
|
|
417
|
+
*/
|
|
418
|
+
export function actualProvider(
|
|
419
|
+
usage: readonly LlmUsage[],
|
|
420
|
+
schemaName: string,
|
|
421
|
+
defaultName: string,
|
|
422
|
+
): string {
|
|
423
|
+
for (let i = usage.length - 1; i >= 0; i--) {
|
|
424
|
+
if (usage[i]!.schemaName === schemaName) return usage[i]!.provider;
|
|
425
|
+
}
|
|
426
|
+
return defaultName;
|
|
427
|
+
}
|
|
428
|
+
|
|
275
429
|
export interface ProduceOptions {
|
|
276
430
|
out?: string;
|
|
277
431
|
cleanup: CleanupLevel;
|
|
278
432
|
transcript?: string;
|
|
279
433
|
render: boolean;
|
|
434
|
+
/**
|
|
435
|
+
* This run is a `--review` (cut review step 1/3): `render` is already
|
|
436
|
+
* false (reviewFlag resolved that in the action) and the editor opens on
|
|
437
|
+
* the workdir afterwards. Produce only reads it to phrase the no-render
|
|
438
|
+
* exit — "the editor is opening" instead of the `--no-render` skip line
|
|
439
|
+
* plus an `ossclip edit` hint for an editor that is about to open itself.
|
|
440
|
+
*/
|
|
441
|
+
review?: boolean;
|
|
280
442
|
mezzanine: boolean;
|
|
281
443
|
workdir?: string;
|
|
282
444
|
inspect?: boolean;
|
|
@@ -291,6 +453,12 @@ export interface ProduceOptions {
|
|
|
291
453
|
llmModel?: string;
|
|
292
454
|
/** Model for mechanical calls; "same" sends everything to the main model. */
|
|
293
455
|
llmFastModel?: string;
|
|
456
|
+
/**
|
|
457
|
+
* `agy --effort` for the antigravity provider (§143). Already zod-parsed to
|
|
458
|
+
* the union by program.ts — the CONFIG's `llmEffort` arrives separately, as
|
|
459
|
+
* an unvalidated string, and `resolveLlmEffort` arbitrates.
|
|
460
|
+
*/
|
|
461
|
+
llmEffort?: LlmEffort;
|
|
294
462
|
/** Who is on camera — steers repair and exempts their name from grounding. */
|
|
295
463
|
speaker?: string;
|
|
296
464
|
/** Repair ASR mishearings before captions/producer/grounding (default on). */
|
|
@@ -483,6 +651,32 @@ export function resolveYoutube(
|
|
|
483
651
|
return flag ?? configValue === true;
|
|
484
652
|
}
|
|
485
653
|
|
|
654
|
+
/**
|
|
655
|
+
* The effective `--llm-effort` — reasoning effort for the antigravity
|
|
656
|
+
* provider's `agy --effort` flag (§143: exposed after the hang incident;
|
|
657
|
+
* untested at real scale whether it moves the hang, but the knob existed and
|
|
658
|
+
* we passed nothing). A TYPED flag always wins, and it arrives already
|
|
659
|
+
* zod-parsed by program.ts; only the config value is checked here — the
|
|
660
|
+
* `dictionary` posture, since it comes from hand-editable JSON loadConfig
|
|
661
|
+
* doesn't zod-parse: exactly low|medium|high, or one warning and agy's
|
|
662
|
+
* default, never a coerced effort level. Pure so the flag × config matrix is
|
|
663
|
+
* testable without a config file on disk.
|
|
664
|
+
*/
|
|
665
|
+
export function resolveLlmEffort(
|
|
666
|
+
flag: LlmEffort | undefined,
|
|
667
|
+
configValue: unknown,
|
|
668
|
+
): { effort?: LlmEffort; warning?: string } {
|
|
669
|
+
// Typed-beats-config, and typed also beats a MALFORMED config: the user
|
|
670
|
+
// asking for an effort on the command line gets it, not a warning about a
|
|
671
|
+
// config key they did not touch this run.
|
|
672
|
+
if (flag !== undefined) return { effort: flag };
|
|
673
|
+
if (configValue === undefined) return {};
|
|
674
|
+
if (configValue === "low" || configValue === "medium" || configValue === "high") {
|
|
675
|
+
return { effort: configValue };
|
|
676
|
+
}
|
|
677
|
+
return { warning: "⚠ config llmEffort ignored — expected low|medium|high" };
|
|
678
|
+
}
|
|
679
|
+
|
|
486
680
|
/**
|
|
487
681
|
* How many browser tabs the render runs in parallel (2026-08-17 render-speed
|
|
488
682
|
* pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
|
|
@@ -965,7 +1159,23 @@ export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailS
|
|
|
965
1159
|
// ceiling). Capped BEFORE caching so the cache and the image key hold
|
|
966
1160
|
// what is used.
|
|
967
1161
|
concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
|
|
968
|
-
|
|
1162
|
+
// §143 read/write split, same as the plan and repair caches: the
|
|
1163
|
+
// concept call rides the editorial provider and can fall back, so the
|
|
1164
|
+
// file goes under whoever actually answered.
|
|
1165
|
+
await writeFile(
|
|
1166
|
+
join(
|
|
1167
|
+
args.work,
|
|
1168
|
+
thumbnailConceptCacheName({
|
|
1169
|
+
...args,
|
|
1170
|
+
providerName: actualProvider(
|
|
1171
|
+
args.provider.usage,
|
|
1172
|
+
"thumbnail_concept",
|
|
1173
|
+
args.providerName,
|
|
1174
|
+
),
|
|
1175
|
+
}),
|
|
1176
|
+
),
|
|
1177
|
+
JSON.stringify(concept, null, 2),
|
|
1178
|
+
);
|
|
969
1179
|
} catch (err) {
|
|
970
1180
|
log(
|
|
971
1181
|
`▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
|
|
@@ -1149,6 +1359,54 @@ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
|
|
|
1149
1359
|
return "auto";
|
|
1150
1360
|
}
|
|
1151
1361
|
|
|
1362
|
+
/**
|
|
1363
|
+
* Resolves `--review` against the two flags it overlaps: produce without
|
|
1364
|
+
* rendering, then open the editor to review the cut — so the ONE render
|
|
1365
|
+
* happens from the editor's Render button, not before the user could look.
|
|
1366
|
+
*
|
|
1367
|
+
* `--review --no-render` is agreement, not a contradiction — both point the
|
|
1368
|
+
* same way (don't render here), unlike the jump-cuts pair — so it resolves
|
|
1369
|
+
* silently. `--review --no-open-editor` IS the contradiction (reviewing is
|
|
1370
|
+
* opening the editor) and follows jumpCutsFlag's rule: a loud error, never a
|
|
1371
|
+
* precedence the user has to memorize. Without --review everything passes
|
|
1372
|
+
* through untouched, tri-states included. Pure so the whole matrix is
|
|
1373
|
+
* assertable without commander or a TTY.
|
|
1374
|
+
*/
|
|
1375
|
+
export function reviewFlag(
|
|
1376
|
+
review: boolean,
|
|
1377
|
+
render: boolean,
|
|
1378
|
+
openEditor: boolean | undefined,
|
|
1379
|
+
): { render: boolean; openEditor: boolean | undefined } {
|
|
1380
|
+
if (!review) return { render, openEditor };
|
|
1381
|
+
if (openEditor === false) {
|
|
1382
|
+
throw new Error("--review contradicts --no-open-editor — reviewing means opening the editor");
|
|
1383
|
+
}
|
|
1384
|
+
return { render: false, openEditor: true };
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* The one loud line for cleanup vetoes actually changing this run's cut (cut
|
|
1389
|
+
* review step 3) — the `▸ N user cut(s) removed …` voice, inverted: per
|
|
1390
|
+
* declined reason, how many removals came back and how much source time they
|
|
1391
|
+
* restore. Pure so the whole phrasing is assertable without running produce;
|
|
1392
|
+
* callers only print it when `vetoed` is non-empty (a silent no-change run
|
|
1393
|
+
* must stay silent, like the user-cut line's own `cuts.length > 0` gate).
|
|
1394
|
+
*/
|
|
1395
|
+
export function cleanupChoicesLine(vetoed: readonly Segment[], outputDuration: number): string {
|
|
1396
|
+
const byReason = new Map<string, { count: number; sec: number }>();
|
|
1397
|
+
for (const seg of vetoed) {
|
|
1398
|
+
const key = seg.reason ?? "unlabeled";
|
|
1399
|
+
const entry = byReason.get(key) ?? { count: 0, sec: 0 };
|
|
1400
|
+
entry.count += 1;
|
|
1401
|
+
entry.sec += seg.srcOut - seg.srcIn;
|
|
1402
|
+
byReason.set(key, entry);
|
|
1403
|
+
}
|
|
1404
|
+
const parts = [...byReason.entries()].map(
|
|
1405
|
+
([reason, { count, sec }]) => `${count} ${reason} removal(s) (+${sec.toFixed(1)}s)`,
|
|
1406
|
+
);
|
|
1407
|
+
return `▸ cleanup choices kept ${parts.join(", ")} — ${outputDuration.toFixed(1)}s output`;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1152
1410
|
/**
|
|
1153
1411
|
* The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
|
|
1154
1412
|
* (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
|
|
@@ -1156,6 +1414,14 @@ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
|
|
|
1156
1414
|
* and even on a talking head a 7% lurch reads as the camera stumbling. Big
|
|
1157
1415
|
* enough to break up the jump, small enough to pass as sensor noise.
|
|
1158
1416
|
*/
|
|
1417
|
+
/**
|
|
1418
|
+
* How long a silent antigravity call runs before the spinner admits it (§149).
|
|
1419
|
+
* Well clear of the 17-46s a healthy call takes, so a normal run never shows
|
|
1420
|
+
* the notice, and well short of AGY_PRINT_TIMEOUT, so it lands while the wait
|
|
1421
|
+
* still has somewhere to go.
|
|
1422
|
+
*/
|
|
1423
|
+
export const AGY_SLOW_NOTICE_MS = 30_000;
|
|
1424
|
+
|
|
1159
1425
|
export const FACE_PUNCH_SCALE = 1.015;
|
|
1160
1426
|
|
|
1161
1427
|
/**
|
|
@@ -1678,6 +1944,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1678
1944
|
const dictionary = opts.dictionary ?? configDictionary ?? [];
|
|
1679
1945
|
if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
|
|
1680
1946
|
|
|
1947
|
+
// Resolved ONCE for the whole run (§143): the provider call, both plan
|
|
1948
|
+
// cache keys and the command.json pin must all see the same effort, or a
|
|
1949
|
+
// replay re-plans under a knob the run never used.
|
|
1950
|
+
const { effort: llmEffort, warning: llmEffortWarning } = resolveLlmEffort(
|
|
1951
|
+
opts.llmEffort,
|
|
1952
|
+
cfg.llmEffort,
|
|
1953
|
+
);
|
|
1954
|
+
if (llmEffortWarning) console.log(llmEffortWarning);
|
|
1955
|
+
|
|
1681
1956
|
// The run's base theme (F6): config theme over defaultTheme, resolved once
|
|
1682
1957
|
// and used for BOTH resolveTheme's base and props.baseTheme below — the
|
|
1683
1958
|
// editor's reset must land on the user's global colors, not the factory's.
|
|
@@ -2088,31 +2363,62 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2088
2363
|
if (!opts.provider) {
|
|
2089
2364
|
console.log(detectionLine(providerName));
|
|
2090
2365
|
}
|
|
2366
|
+
// Timeout fallback (2026-08-22, FINDINGS §143): agy hangs persistently on
|
|
2367
|
+
// the real beat-sheet call — 10-minute --print-timeout expiries while
|
|
2368
|
+
// claude-cli planned the same video — and auto-detection picks agy
|
|
2369
|
+
// whenever the CLI is on PATH. When the editorial call times out, ONE
|
|
2370
|
+
// other provider answers it instead of the run dying, announced out loud:
|
|
2371
|
+
// the user must know which model planned their video.
|
|
2372
|
+
const llmFallbackName =
|
|
2373
|
+
providerName === "antigravity"
|
|
2374
|
+
? fallbackProviderName(providerName, process.env, binOnPath)
|
|
2375
|
+
: undefined;
|
|
2091
2376
|
provider = createTieredProvider(providerName, {
|
|
2092
2377
|
model: opts.llmModel,
|
|
2093
2378
|
fastModel: opts.llmFastModel ?? cfg.fastModel,
|
|
2379
|
+
fallback: llmFallbackName,
|
|
2380
|
+
onFallback: (info) => console.log(fallbackLine(info.from, info.to, info.schemaName)),
|
|
2381
|
+
// §143: rides the editorial antigravity call only — see TieringOptions.
|
|
2382
|
+
effort: llmEffort,
|
|
2094
2383
|
});
|
|
2095
2384
|
}
|
|
2096
2385
|
|
|
2097
2386
|
let rawTranscript = transcript;
|
|
2098
2387
|
let repairs: AppliedRepair[] = [];
|
|
2099
2388
|
if (provider && opts.repair !== false) {
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2389
|
+
// Parameterized on the provider so the WRITE below can re-key on who
|
|
2390
|
+
// actually answered (§143) — the same read/write split as the beat-sheet
|
|
2391
|
+
// caches, and load-bearing beyond attribution: repairs REWRITE the words,
|
|
2392
|
+
// and the words are an input to every plan cache key downstream. A repair
|
|
2393
|
+
// set cached under the provider that timed out would fork the transcript
|
|
2394
|
+
// lineage, and the fallback-written plan could never be reached by a
|
|
2395
|
+
// later run that names the fallback provider directly (measured
|
|
2396
|
+
// 2026-08-22: two repair caches, 10 vs 11 repairs — one applied
|
|
2397
|
+
// "Llama," → "LLaVA," — put the same take's beat sheets under keys no
|
|
2398
|
+
// other run computed).
|
|
2399
|
+
const repairKeyFor = (p: string): string =>
|
|
2400
|
+
createHash("sha1")
|
|
2401
|
+
.update(
|
|
2402
|
+
JSON.stringify([
|
|
2403
|
+
p,
|
|
2404
|
+
opts.llmModel,
|
|
2405
|
+
opts.llmFastModel ?? cfg.fastModel,
|
|
2406
|
+
opts.speaker ?? cfg.speaker,
|
|
2407
|
+
// The dictionary changes both the prompt and the vouched set (F4),
|
|
2408
|
+
// so cached repairs from a different vocabulary must not be reused.
|
|
2409
|
+
dictionary,
|
|
2410
|
+
rawTranscript.words.map((w) => w.text),
|
|
2411
|
+
// Repair runs on the EDITORIAL tier (repair.ts: deciding what a
|
|
2412
|
+
// person actually said is semantic work), so the §143 effort knob
|
|
2413
|
+
// changes its answers. Appended at the END, only when SET —
|
|
2414
|
+
// beatSheetCacheKey's rule: an unset effort must keep serving the
|
|
2415
|
+
// repairs every existing workdir already cached.
|
|
2416
|
+
...(llmEffort !== undefined ? [llmEffort] : []),
|
|
2417
|
+
]),
|
|
2418
|
+
)
|
|
2419
|
+
.digest("hex")
|
|
2420
|
+
.slice(0, 8);
|
|
2421
|
+
const repairCache = join(work, `repairs-${repairKeyFor(providerName)}.json`);
|
|
2116
2422
|
if (existsSync(repairCache)) {
|
|
2117
2423
|
const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
|
|
2118
2424
|
// Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
|
|
@@ -2182,7 +2488,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2182
2488
|
" (not cached — the next run retries the pass)",
|
|
2183
2489
|
);
|
|
2184
2490
|
} else {
|
|
2185
|
-
|
|
2491
|
+
// Written under the ANSWERING provider's key (§143): after a timeout
|
|
2492
|
+
// fallback these are the fallback's repairs, and the transcript
|
|
2493
|
+
// lineage they start must be reachable by a run that asks that
|
|
2494
|
+
// provider by name. The primary-keyed read above stays deliberate.
|
|
2495
|
+
await writeFile(
|
|
2496
|
+
join(
|
|
2497
|
+
work,
|
|
2498
|
+
`repairs-${repairKeyFor(actualProvider(provider.usage, "transcript_repair", providerName))}.json`,
|
|
2499
|
+
),
|
|
2500
|
+
JSON.stringify(repairs, null, 2),
|
|
2501
|
+
);
|
|
2186
2502
|
}
|
|
2187
2503
|
}
|
|
2188
2504
|
for (const r of repairs) {
|
|
@@ -2298,19 +2614,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2298
2614
|
// calls; only a first run selects.
|
|
2299
2615
|
let clipFresh: Awaited<ReturnType<typeof produceScenes>> | null = null;
|
|
2300
2616
|
if (clipTargetSec !== undefined) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
)
|
|
2312
|
-
|
|
2313
|
-
|
|
2617
|
+
// Parts split from the key so the WRITE below can re-key on the
|
|
2618
|
+
// provider that actually answered (§143) without restating them.
|
|
2619
|
+
const windowKeyParts = {
|
|
2620
|
+
promptVersion: PRODUCER_PROMPT_VERSION,
|
|
2621
|
+
llmModel: opts.llmModel,
|
|
2622
|
+
llmEffort,
|
|
2623
|
+
intent: opts.intent,
|
|
2624
|
+
clipTargetSec,
|
|
2625
|
+
framing: framingCtx,
|
|
2626
|
+
words: transcript.words.map((w) => w.text),
|
|
2627
|
+
aspect: landscape ? ("16:9" as const) : ("9:16" as const),
|
|
2628
|
+
};
|
|
2629
|
+
const windowKey = clipWindowCacheKey({ ...windowKeyParts, providerName });
|
|
2314
2630
|
const clipWindowCache = join(work, `clipwindow-${windowKey}.json`);
|
|
2315
2631
|
if (opts.clipWindow) {
|
|
2316
2632
|
clipWindow = parseClipWindowPin(transcript, opts.clipWindow);
|
|
@@ -2337,7 +2653,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2337
2653
|
);
|
|
2338
2654
|
clipWindow = clipFresh.clip!.window;
|
|
2339
2655
|
for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
|
|
2340
|
-
|
|
2656
|
+
// The WRITE keys on the provider that actually answered; the READS
|
|
2657
|
+
// above stay keyed on the primary — both deliberate (2026-08-22,
|
|
2658
|
+
// FINDINGS §143). After a timeout fallback the window is the
|
|
2659
|
+
// fallback's work: filing it under agy would let an agy-keyed read
|
|
2660
|
+
// claim a window agy never chose, and would hide it from a later
|
|
2661
|
+
// `--llm claude-cli` run that should hit it. The price of the
|
|
2662
|
+
// primary-keyed read is that a repeat agy run re-attempts agy (10
|
|
2663
|
+
// minutes, today) before falling back again — accepted over ever
|
|
2664
|
+
// serving a cache whose label lies.
|
|
2665
|
+
await writeFile(
|
|
2666
|
+
join(
|
|
2667
|
+
work,
|
|
2668
|
+
`clipwindow-${clipWindowCacheKey({
|
|
2669
|
+
...windowKeyParts,
|
|
2670
|
+
providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
|
|
2671
|
+
})}.json`,
|
|
2672
|
+
),
|
|
2673
|
+
JSON.stringify(clipWindow, null, 2),
|
|
2674
|
+
);
|
|
2341
2675
|
}
|
|
2342
2676
|
|
|
2343
2677
|
// Slice the pipeline state to the window (§93.1), then let everything
|
|
@@ -2381,29 +2715,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2381
2715
|
map = new TimeMap(cutlist);
|
|
2382
2716
|
}
|
|
2383
2717
|
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
clipTargetSec ?? null,
|
|
2401
|
-
clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : null,
|
|
2402
|
-
transcript.words.map((w) => w.text),
|
|
2403
|
-
]),
|
|
2404
|
-
)
|
|
2405
|
-
.digest("hex")
|
|
2406
|
-
.slice(0, 8);
|
|
2718
|
+
// Parts split from the key for the same §143 reason as the clip window:
|
|
2719
|
+
// the writes below re-key on the provider that actually answered.
|
|
2720
|
+
const beatKeyParts = {
|
|
2721
|
+
promptVersion: PRODUCER_PROMPT_VERSION,
|
|
2722
|
+
llmModel: opts.llmModel,
|
|
2723
|
+
llmEffort,
|
|
2724
|
+
intent: opts.intent,
|
|
2725
|
+
cleanup: opts.cleanup,
|
|
2726
|
+
forceComponent: opts.forceComponent,
|
|
2727
|
+
framing: framingCtx,
|
|
2728
|
+
clipTargetSec,
|
|
2729
|
+
clipWindow,
|
|
2730
|
+
words: transcript.words.map((w) => w.text),
|
|
2731
|
+
aspect: landscape ? ("16:9" as const) : ("9:16" as const),
|
|
2732
|
+
};
|
|
2733
|
+
const cacheKey = beatSheetCacheKey({ ...beatKeyParts, providerName });
|
|
2407
2734
|
const sceneCache = join(work, `scenes-${cacheKey}.json`);
|
|
2408
2735
|
// The cover needs the editorial copy, which is not in the scene list — a
|
|
2409
2736
|
// cached run must still be able to write one.
|
|
@@ -2431,9 +2758,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2431
2758
|
clipFresh.graphics.asked,
|
|
2432
2759
|
transcript,
|
|
2433
2760
|
);
|
|
2434
|
-
|
|
2761
|
+
// Written under the ANSWERING provider's key (§143, same rule as the
|
|
2762
|
+
// clip-window write above): the adopted sheet came from the ONE
|
|
2763
|
+
// clip_beat_sheet call, which may have been the fallback's.
|
|
2764
|
+
const adoptKey = beatSheetCacheKey({
|
|
2765
|
+
...beatKeyParts,
|
|
2766
|
+
providerName: actualProvider(provider.usage, "clip_beat_sheet", providerName),
|
|
2767
|
+
});
|
|
2768
|
+
await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
|
|
2435
2769
|
await writeFile(
|
|
2436
|
-
|
|
2770
|
+
join(work, `beatsheet-${adoptKey}.json`),
|
|
2437
2771
|
JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
|
|
2438
2772
|
);
|
|
2439
2773
|
} else if (existsSync(sceneCache)) {
|
|
@@ -2462,17 +2796,38 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2462
2796
|
: null;
|
|
2463
2797
|
if (!aiAnim) console.log(`▸ producing scenes (${providerName})…`);
|
|
2464
2798
|
if (opts.forceComponent) console.log(`▸ forcing every graphic to ${opts.forceComponent}`);
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2799
|
+
// A hung agy is indistinguishable from a working one on screen: the
|
|
2800
|
+
// 2026-08-23 field run sat on this spinner for 605.9s with no hint that
|
|
2801
|
+
// a budget existed or that a recovery was coming, which reads as a
|
|
2802
|
+
// freeze rather than as waiting (§149). Only antigravity has a
|
|
2803
|
+
// print-timeout and a fallback, so only it gets the notice — and only
|
|
2804
|
+
// once the call is actually slow, so a healthy run never sees it.
|
|
2805
|
+
const slowNotice =
|
|
2806
|
+
aiAnim && providerName === "antigravity"
|
|
2807
|
+
? setTimeout(
|
|
2808
|
+
() =>
|
|
2809
|
+
// Short on purpose: StageAnimator clamps the subtitle to the
|
|
2810
|
+
// terminal width and floors that at 40 columns, and the first
|
|
2811
|
+
// draft lost the budget to "falling back to the n...". The
|
|
2812
|
+
// number is the only part that changes what the user does
|
|
2813
|
+
// (wait vs. Ctrl-C), so it has to survive the clamp.
|
|
2814
|
+
aiAnim.update(`agy not replying — falling back at ${AGY_PRINT_TIMEOUT}...`),
|
|
2815
|
+
AGY_SLOW_NOTICE_MS,
|
|
2816
|
+
)
|
|
2817
|
+
: undefined;
|
|
2818
|
+
const result = await phases
|
|
2819
|
+
.time("llm", () =>
|
|
2820
|
+
produceScenes(provider!, {
|
|
2821
|
+
transcript,
|
|
2822
|
+
outputDuration: map.outputDuration,
|
|
2823
|
+
intent: opts.intent,
|
|
2824
|
+
speaker: opts.speaker ?? cfg.speaker,
|
|
2825
|
+
forceComponent: opts.forceComponent,
|
|
2826
|
+
framing: framingCtx,
|
|
2827
|
+
aspect: landscape ? "16:9" : "9:16",
|
|
2828
|
+
}),
|
|
2829
|
+
)
|
|
2830
|
+
.finally(() => clearTimeout(slowNotice));
|
|
2476
2831
|
if (aiAnim) aiAnim.stop();
|
|
2477
2832
|
scenes = result.scenes;
|
|
2478
2833
|
beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
|
|
@@ -2493,9 +2848,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2493
2848
|
// Cache props only — overrides are user-owned and live in overrides.json,
|
|
2494
2849
|
// never in production.json (that file is derived and every `produce`
|
|
2495
2850
|
// run overwrites it, per the merge rule in `overrides.ts`).
|
|
2496
|
-
|
|
2851
|
+
// Keyed on who answered the beat_sheet call (§143) — see the clip-window
|
|
2852
|
+
// write above for why reads stay primary-keyed while writes do not.
|
|
2853
|
+
const freshKey = beatSheetCacheKey({
|
|
2854
|
+
...beatKeyParts,
|
|
2855
|
+
providerName: actualProvider(provider.usage, "beat_sheet", providerName),
|
|
2856
|
+
});
|
|
2857
|
+
await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
|
|
2497
2858
|
await writeFile(
|
|
2498
|
-
|
|
2859
|
+
join(work, `beatsheet-${freshKey}.json`),
|
|
2499
2860
|
JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
|
|
2500
2861
|
);
|
|
2501
2862
|
}
|
|
@@ -2531,8 +2892,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2531
2892
|
// …and stamp it onto the artefact it explains, so `production.json` says
|
|
2532
2893
|
// who planned it without a second file to cross-reference.
|
|
2533
2894
|
const last = log.runs[log.runs.length - 1]!;
|
|
2895
|
+
// `last.provider` derives from records[0] — the FIRST call's provider,
|
|
2896
|
+
// which after a §143 timeout fallback is the primary that failed the
|
|
2897
|
+
// editorial call. The stamp answers "who planned this", so it is built
|
|
2898
|
+
// from the records that ANSWERED (`failed` attempts stay in usage.json
|
|
2899
|
+
// and every report — their cost is real — but a stamp listing the failed
|
|
2900
|
+
// attempt's placeholder model read "planned by claude-cli
|
|
2901
|
+
// (antigravity-default)" after a fallback run, 2026-08-22). Providers in
|
|
2902
|
+
// first-seen order, the same " → " rendering the usage report uses;
|
|
2903
|
+
// single-provider runs stamp exactly as before.
|
|
2904
|
+
const answered = provider.usage.filter((r) => !r.failed);
|
|
2905
|
+
const providersSeen = [...new Set(answered.map((r) => r.provider))];
|
|
2534
2906
|
producerStamp = {
|
|
2535
|
-
provider:
|
|
2907
|
+
provider:
|
|
2908
|
+
providersSeen.length > 1
|
|
2909
|
+
? providersSeen.join(" → ")
|
|
2910
|
+
: providersSeen[0] ?? last.provider ?? providerName,
|
|
2911
|
+
// `last.models` already excludes failed attempts' models (usage.ts,
|
|
2912
|
+
// same §143 rule) — a stamp that listed the timed-out placeholder read
|
|
2913
|
+
// "planned by claude-cli (antigravity-default)" after a fallback run.
|
|
2536
2914
|
models: last.models,
|
|
2537
2915
|
cached: last.cached,
|
|
2538
2916
|
at: last.at,
|
|
@@ -2632,6 +3010,28 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2632
3010
|
}
|
|
2633
3011
|
overrideDoc = parsed.data;
|
|
2634
3012
|
}
|
|
3013
|
+
// ---- Cleanup choices: the user's veto over the AUTOMATIC cutlist (cut
|
|
3014
|
+
// review step 3) ------------------------------------------------------
|
|
3015
|
+
// Applied here — the same load `cuts` rides, before `applyUserCuts` — and
|
|
3016
|
+
// in exactly this order on purpose: cleanup vetoes reshape the PROPOSAL,
|
|
3017
|
+
// then user cuts subtract from the result as they always did, so a user
|
|
3018
|
+
// cut drawn over a vetoed pause still cuts (an explicit user action
|
|
3019
|
+
// outranks a veto). The proposal itself is captured FIRST for
|
|
3020
|
+
// `production.json`'s `cutlistProposed` — the resolution is lossy (a
|
|
3021
|
+
// vetoed removal merges into a plain keep), and the editor's checkboxes
|
|
3022
|
+
// re-derive the veto state from proposal + choices through the same
|
|
3023
|
+
// `applyCleanupChoices` this run used (ProductionSchema has the full why).
|
|
3024
|
+
// Consumers of `map` ABOVE this point (the repair pass's isCut guard, the
|
|
3025
|
+
// producer's outputDuration hint) saw the pre-veto map: both are
|
|
3026
|
+
// conservative uses — a refused word-merge across a span that comes back,
|
|
3027
|
+
// a duration hint a few seconds short — never a wrong cut.
|
|
3028
|
+
const cutlistProposed = cutlist;
|
|
3029
|
+
const cleanupVetoed = vetoedRemovals(cutlist, overrideDoc.cleanup);
|
|
3030
|
+
if (cleanupVetoed.length > 0) {
|
|
3031
|
+
cutlist = applyCleanupChoices(cutlist, overrideDoc.cleanup);
|
|
3032
|
+
map = new TimeMap(cutlist);
|
|
3033
|
+
console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
|
|
3034
|
+
}
|
|
2635
3035
|
// `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
|
|
2636
3036
|
// no `src` yet) and any already-re-anchored splits/pins are expressed
|
|
2637
3037
|
// relative to whatever render-props the user was LAST looking at, not the
|
|
@@ -3150,7 +3550,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3150
3550
|
transcript: rawTranscript,
|
|
3151
3551
|
repairs: repairs.length > 0 ? repairs : undefined,
|
|
3152
3552
|
analysis,
|
|
3553
|
+
// The APPLIED truth vs the PROPOSAL — see ProductionSchema for why both
|
|
3554
|
+
// are recorded: `cutlist` keeps the report/exporters honest about what
|
|
3555
|
+
// happened; `cutlistProposed` keeps the declined categories recoverable
|
|
3556
|
+
// for the editor (a vetoed removal merges into a plain keep, so the
|
|
3557
|
+
// resolved list alone cannot name what was declined).
|
|
3153
3558
|
cutlist,
|
|
3559
|
+
cutlistProposed,
|
|
3154
3560
|
...(clipWindow && clipTargetSec !== undefined
|
|
3155
3561
|
? { clip: { targetSec: clipTargetSec, ...clipWindow } }
|
|
3156
3562
|
: {}),
|
|
@@ -3806,12 +4212,147 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3806
4212
|
console.log(overridesWriteLine(cutResult.changed));
|
|
3807
4213
|
}
|
|
3808
4214
|
|
|
4215
|
+
// Resolved HERE — above the --no-render exit rather than at the thumbnail
|
|
4216
|
+
// gate / pack section below — so the gate, the post-render consumers AND
|
|
4217
|
+
// the command.json record (which both exits now write) all read one
|
|
4218
|
+
// answer. Typed-beats-config, `typeof` not truthiness (the `portrait`
|
|
4219
|
+
// posture): config.json is hand-edited and unparsed, and a
|
|
4220
|
+
// `"audience": true` typo must resolve to "no audience".
|
|
4221
|
+
const youtube = resolveYoutube(opts.youtube, cfg.youtube);
|
|
4222
|
+
// resolvePortrait (portrait-override.ts) carries the expandHome treatment
|
|
4223
|
+
// of the flag and config paths, and puts the workdir's portrait-override
|
|
4224
|
+
// ABOVE both (editor face swap, 2026-08-17): a per-project expression
|
|
4225
|
+
// chosen in the editor must survive CLI re-renders — the flag/config
|
|
4226
|
+
// portrait is the fallback headshot, and a replay silently reverting the
|
|
4227
|
+
// swapped face would undo the one thing the swap exists for.
|
|
4228
|
+
const portrait = resolvePortrait({
|
|
4229
|
+
overridePath: portraitOverridePath(work),
|
|
4230
|
+
flagPortrait: opts.portrait,
|
|
4231
|
+
cfgPortrait: cfg.portrait,
|
|
4232
|
+
})?.path;
|
|
4233
|
+
const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
|
|
4234
|
+
const thumbnailBrief =
|
|
4235
|
+
opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
|
|
4236
|
+
|
|
4237
|
+
// Record THIS invocation so the editor's Render button can replay it (R11
|
|
4238
|
+
// Task 4). Nothing else can reconstruct it — production.json has the
|
|
4239
|
+
// source path, cleanup and intent, but not --produce, --out or the LLM
|
|
4240
|
+
// flags — and guessing would silently render a different video than the
|
|
4241
|
+
// one on screen. execArgv carries the module loader (tsx in dev), so the
|
|
4242
|
+
// replay works from source and from a compiled build alike.
|
|
4243
|
+
//
|
|
4244
|
+
// Written BEFORE the render/no-render fork, not after the render (cut-review
|
|
4245
|
+
// step 1): a --no-render workdir used to carry NO command.json at all, so
|
|
4246
|
+
// the editor's Render button 412'd with "run `ossclip produce` once from
|
|
4247
|
+
// the terminal" — a dead end that made `--review` (produce without
|
|
4248
|
+
// rendering, review in the editor, render ONCE from its Render button)
|
|
4249
|
+
// impossible. Every pin below is resolved by this point, so the record is
|
|
4250
|
+
// byte-identical to what the post-render write produced; the side effect is
|
|
4251
|
+
// that a crashed render now leaves a record its own Render button can
|
|
4252
|
+
// retry. recordedProduceArgs strips --review/--no-render at record, so the
|
|
4253
|
+
// replay actually renders instead of looping.
|
|
4254
|
+
//
|
|
4255
|
+
// The provider may have been AUTO-DETECTED from this shell's environment
|
|
4256
|
+
// (a GEMINI_/ANTHROPIC_ key exported here). The editor's Render replays
|
|
4257
|
+
// this argv from the EDIT SERVER's environment, which may not have that
|
|
4258
|
+
// key — and the auto-detection would then silently pick a DIFFERENT
|
|
4259
|
+
// provider (R16 §75). Pin the RESOLVED choice into the recorded args —
|
|
4260
|
+
// never the key itself; secrets stay out of the workdir — so a replay
|
|
4261
|
+
// uses the same configuration or fails loudly asking for it.
|
|
4262
|
+
// §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
|
|
4263
|
+
// editor's Render replays this argv; if replay re-asked the model and got a
|
|
4264
|
+
// slightly different window, every saved override — anchored to scene ids
|
|
4265
|
+
// and word indices — would land on the wrong words. The word range, not
|
|
4266
|
+
// just `--clip 60`, is what makes replay deterministic with zero LLM calls.
|
|
4267
|
+
//
|
|
4268
|
+
// §129: NOT process.argv. A wizard or bare-path run re-enters commander
|
|
4269
|
+
// with a BUILT argv while process.argv still holds the original invocation
|
|
4270
|
+
// (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
|
|
4271
|
+
// recording process.argv shipped a command that replays as
|
|
4272
|
+
// `ossclip <path> --llm …` and dies on "unknown option '--llm'".
|
|
4273
|
+
// recordedProduceArgs prefers the argv the re-entry stashed and falls back
|
|
4274
|
+
// to process.argv for a directly typed `ossclip produce …`, which stays
|
|
4275
|
+
// byte-identical to what was always recorded.
|
|
4276
|
+
// Watermark pin, same §75 shape, and in BOTH directions (review,
|
|
4277
|
+
// Important): the effective default comes from THIS machine's
|
|
4278
|
+
// ~/.ossclip/config.json, so an unpinned record replays differently
|
|
4279
|
+
// wherever that config differs — an off-run would silently gain a credit
|
|
4280
|
+
// under a later/foreign config-on, an on-run would silently lose it. The
|
|
4281
|
+
// RESOLVED state is always pinned; a typed flag is already in the argv and
|
|
4282
|
+
// the includes-guard leaves it alone.
|
|
4283
|
+
// Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
|
|
4284
|
+
// the override-inclusive `captionsHidden` — overrides.json travels with
|
|
4285
|
+
// the workdir and is re-read on every replay, so pinning --no-captions
|
|
4286
|
+
// because the EDITOR hid them would freeze an edit the user may later
|
|
4287
|
+
// undo in that same editor. See recordedProduceArgs for why the pin is
|
|
4288
|
+
// unconditional even though captions' default is config-independent today.
|
|
4289
|
+
// Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
|
|
4290
|
+
// argv — "auto" has no flag spelling and stays unpinned (see
|
|
4291
|
+
// recordedProduceArgs for why that is safe today).
|
|
4292
|
+
// Youtube pin: the watermark's config-dependent-default rationale exactly —
|
|
4293
|
+
// resolved both ways, so a later config edit can't flip what Render
|
|
4294
|
+
// replays. Portrait and dictionary pin the RESOLVED values (a path and
|
|
4295
|
+
// terms, never a secret) for the same reason; recordedProduceArgs owns
|
|
4296
|
+
// the non-empty/includes guards.
|
|
4297
|
+
const recordedArgs = recordedProduceArgs({
|
|
4298
|
+
llm: provider ? providerName : undefined,
|
|
4299
|
+
// The RESOLVED effort (§143), pinned like the dictionary: it may have
|
|
4300
|
+
// come from this machine's config, and it keys the plan caches — an
|
|
4301
|
+
// unpinned record would re-plan on replay after a config edit.
|
|
4302
|
+
llmEffort,
|
|
4303
|
+
clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
|
|
4304
|
+
watermark,
|
|
4305
|
+
captions: opts.captions ?? true,
|
|
4306
|
+
jumpCuts: jumpCutsMode,
|
|
4307
|
+
dictionary,
|
|
4308
|
+
youtube,
|
|
4309
|
+
portrait,
|
|
4310
|
+
audience,
|
|
4311
|
+
thumbnailBrief,
|
|
4312
|
+
});
|
|
4313
|
+
// produce is the ONLY command that may write command.json — edit.ts's §129
|
|
4314
|
+
// heal prepends the "produce" literal to any record that doesn't start
|
|
4315
|
+
// with it, so a record made by `transcribe`/`analyze` (which run this same
|
|
4316
|
+
// pipeline with render: false, and used to be kept out by the write
|
|
4317
|
+
// sitting after the early return below) would heal into
|
|
4318
|
+
// `produce transcribe …` and replay garbage. Their argv cannot start with
|
|
4319
|
+
// "produce" (§129: the stash mirrors the parse that ran, and only
|
|
4320
|
+
// produce's re-entries stash), which is the gate.
|
|
4321
|
+
if (recordedArgs[0] === "produce") {
|
|
4322
|
+
await writeFile(
|
|
4323
|
+
join(work, "command.json"),
|
|
4324
|
+
JSON.stringify(
|
|
4325
|
+
{
|
|
4326
|
+
execPath: process.execPath,
|
|
4327
|
+
execArgv: process.execArgv,
|
|
4328
|
+
script: process.argv[1],
|
|
4329
|
+
args: recordedArgs,
|
|
4330
|
+
cwd: process.cwd(),
|
|
4331
|
+
out: outPath,
|
|
4332
|
+
},
|
|
4333
|
+
null,
|
|
4334
|
+
2,
|
|
4335
|
+
),
|
|
4336
|
+
);
|
|
4337
|
+
// Every produce run is a project the picker should offer (R17 §83) —
|
|
4338
|
+
// best-effort, so a read-only home dir never fails the render.
|
|
4339
|
+
await recordRecentProject(work);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
3809
4342
|
if (!opts.render) {
|
|
3810
4343
|
// §140: the breakdown goes above the closing lines on both exits, so the
|
|
3811
4344
|
// last thing on screen stays the success line and the edit hint.
|
|
3812
4345
|
console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
|
|
3813
|
-
|
|
3814
|
-
|
|
4346
|
+
if (opts.review === true) {
|
|
4347
|
+
// --review (step 1's report, fixed in step 3): the editor opens itself
|
|
4348
|
+
// right after this return, so the --no-render skip line plus an
|
|
4349
|
+
// `ossclip edit …` hint would tell the user to do what is already
|
|
4350
|
+
// happening. One line that says what comes next instead.
|
|
4351
|
+
console.log("▸ review: opening the editor — render once from its Render button");
|
|
4352
|
+
} else {
|
|
4353
|
+
console.log(`▸ skipping render (--no-render). Props at ${join(work, "render-props.json")}`);
|
|
4354
|
+
console.log(editHint(work));
|
|
4355
|
+
}
|
|
3815
4356
|
return {
|
|
3816
4357
|
workdir: work,
|
|
3817
4358
|
rendered: false,
|
|
@@ -3833,27 +4374,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3833
4374
|
// multi-minute render. Interactive runs approve (or edit, or skip) it
|
|
3834
4375
|
// here; the file it writes is what thumbnailStep honors after render — and
|
|
3835
4376
|
// what a non-TTY replay (the editor's Render) reuses, which is the whole
|
|
3836
|
-
// persistence story.
|
|
3837
|
-
//
|
|
3838
|
-
//
|
|
3839
|
-
// post-render consumers read one answer. Typed-beats-config, `typeof` not
|
|
3840
|
-
// truthiness (the `portrait` posture): config.json is hand-edited and
|
|
3841
|
-
// unparsed, and a `"audience": true` typo must resolve to "no audience".
|
|
3842
|
-
const youtube = resolveYoutube(opts.youtube, cfg.youtube);
|
|
3843
|
-
// resolvePortrait (portrait-override.ts) carries the expandHome treatment
|
|
3844
|
-
// of the flag and config paths, and puts the workdir's portrait-override
|
|
3845
|
-
// ABOVE both (editor face swap, 2026-08-17): a per-project expression
|
|
3846
|
-
// chosen in the editor must survive CLI re-renders — the flag/config
|
|
3847
|
-
// portrait is the fallback headshot, and a replay silently reverting the
|
|
3848
|
-
// swapped face would undo the one thing the swap exists for.
|
|
3849
|
-
const portrait = resolvePortrait({
|
|
3850
|
-
overridePath: portraitOverridePath(work),
|
|
3851
|
-
flagPortrait: opts.portrait,
|
|
3852
|
-
cfgPortrait: cfg.portrait,
|
|
3853
|
-
})?.path;
|
|
3854
|
-
const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
|
|
3855
|
-
const thumbnailBrief =
|
|
3856
|
-
opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
|
|
4377
|
+
// persistence story. The youtube/portrait/audience/brief resolutions this
|
|
4378
|
+
// gate reads moved above the --no-render exit (cut-review step 1) so the
|
|
4379
|
+
// command.json record pins the same answers on both exits.
|
|
3857
4380
|
const geminiKey = process.env.GEMINI_API_KEY;
|
|
3858
4381
|
const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
|
|
3859
4382
|
// The gate reuses thumbnailDecision (plus the mime check) so the prompt
|
|
@@ -4279,14 +4802,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4279
4802
|
} else {
|
|
4280
4803
|
// Beat-sheet cache shape: keyed on everything that changes the answer —
|
|
4281
4804
|
// who is asked, with what editorial steer, about which words.
|
|
4282
|
-
|
|
4805
|
+
// Parameterized on the provider for the §143 read/write split: the
|
|
4806
|
+
// write below re-keys on who actually answered the pack call.
|
|
4807
|
+
const packKeyFor = (p: string): string => createHash("sha1")
|
|
4283
4808
|
.update(
|
|
4284
4809
|
JSON.stringify([
|
|
4285
4810
|
// Prompt changes change the answer (the §78 posture): the v2
|
|
4286
4811
|
// rewrite must not serve a pack cached under v1's questions.
|
|
4287
4812
|
YOUTUBE_PROMPT_VERSION,
|
|
4288
|
-
|
|
4813
|
+
p,
|
|
4289
4814
|
opts.llmModel ?? "",
|
|
4815
|
+
// Appended only when set — the plan-cache rule (§78 via §143's
|
|
4816
|
+
// effort knob): an unset effort must keep every warm workdir's
|
|
4817
|
+
// key byte-identical, and a changed effort is a different answer.
|
|
4818
|
+
...(llmEffort !== undefined ? [llmEffort] : []),
|
|
4290
4819
|
opts.intent ?? "",
|
|
4291
4820
|
// Steer, so part of the key — a changed audience is a different
|
|
4292
4821
|
// pack, not a cache hit.
|
|
@@ -4302,7 +4831,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4302
4831
|
)
|
|
4303
4832
|
.digest("hex")
|
|
4304
4833
|
.slice(0, 8);
|
|
4305
|
-
const packCache = join(work, `youtube-${
|
|
4834
|
+
const packCache = join(work, `youtube-${packKeyFor(providerName)}.json`);
|
|
4306
4835
|
if (existsSync(packCache)) {
|
|
4307
4836
|
pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
|
|
4308
4837
|
console.log("▸ youtube: metadata cached");
|
|
@@ -4322,7 +4851,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4322
4851
|
durationSec: map.outputDuration,
|
|
4323
4852
|
}),
|
|
4324
4853
|
);
|
|
4325
|
-
|
|
4854
|
+
// §143 read/write split, same as the plan and repair caches: the
|
|
4855
|
+
// pack files under whoever wrote it.
|
|
4856
|
+
await writeFile(
|
|
4857
|
+
join(
|
|
4858
|
+
work,
|
|
4859
|
+
`youtube-${packKeyFor(actualProvider(provider!.usage, "youtube_pack", providerName))}.json`,
|
|
4860
|
+
),
|
|
4861
|
+
JSON.stringify(pack, null, 2),
|
|
4862
|
+
);
|
|
4326
4863
|
} catch (err) {
|
|
4327
4864
|
// NEVER cache a failure (§106), and never fail the produce that
|
|
4328
4865
|
// just rendered over a metadata sidecar: one loud line, the video
|
|
@@ -4388,84 +4925,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4388
4925
|
});
|
|
4389
4926
|
}
|
|
4390
4927
|
}
|
|
4391
|
-
//
|
|
4392
|
-
//
|
|
4393
|
-
// source path, cleanup and intent, but not --produce, --out or the LLM
|
|
4394
|
-
// flags — and guessing would silently render a different video than the
|
|
4395
|
-
// one on screen. execArgv carries the module loader (tsx in dev), so the
|
|
4396
|
-
// replay works from source and from a compiled build alike.
|
|
4397
|
-
// The provider may have been AUTO-DETECTED from this shell's environment
|
|
4398
|
-
// (a GEMINI_/ANTHROPIC_ key exported here). The editor's Render replays
|
|
4399
|
-
// this argv from the EDIT SERVER's environment, which may not have that
|
|
4400
|
-
// key — and the auto-detection would then silently pick a DIFFERENT
|
|
4401
|
-
// provider (R16 §75). Pin the RESOLVED choice into the recorded args —
|
|
4402
|
-
// never the key itself; secrets stay out of the workdir — so a replay
|
|
4403
|
-
// uses the same configuration or fails loudly asking for it.
|
|
4404
|
-
// §93g: pin the RESOLVED window, exactly as §75 pinned the provider. The
|
|
4405
|
-
// editor's Render replays this argv; if replay re-asked the model and got a
|
|
4406
|
-
// slightly different window, every saved override — anchored to scene ids
|
|
4407
|
-
// and word indices — would land on the wrong words. The word range, not
|
|
4408
|
-
// just `--clip 60`, is what makes replay deterministic with zero LLM calls.
|
|
4409
|
-
//
|
|
4410
|
-
// §129: NOT process.argv. A wizard or bare-path run re-enters commander
|
|
4411
|
-
// with a BUILT argv while process.argv still holds the original invocation
|
|
4412
|
-
// (`ossclip <path>`, no `produce` literal, none of the wizard's answers) —
|
|
4413
|
-
// recording process.argv shipped a command that replays as
|
|
4414
|
-
// `ossclip <path> --llm …` and dies on "unknown option '--llm'".
|
|
4415
|
-
// recordedProduceArgs prefers the argv the re-entry stashed and falls back
|
|
4416
|
-
// to process.argv for a directly typed `ossclip produce …`, which stays
|
|
4417
|
-
// byte-identical to what was always recorded.
|
|
4418
|
-
// Watermark pin, same §75 shape, and in BOTH directions (review,
|
|
4419
|
-
// Important): the effective default comes from THIS machine's
|
|
4420
|
-
// ~/.ossclip/config.json, so an unpinned record replays differently
|
|
4421
|
-
// wherever that config differs — an off-run would silently gain a credit
|
|
4422
|
-
// under a later/foreign config-on, an on-run would silently lose it. The
|
|
4423
|
-
// RESOLVED state is always pinned; a typed flag is already in the argv and
|
|
4424
|
-
// the includes-guard leaves it alone.
|
|
4425
|
-
// Captions pin: the FLAG's resolved state (`opts.captions ?? true`), never
|
|
4426
|
-
// the override-inclusive `captionsHidden` — overrides.json travels with
|
|
4427
|
-
// the workdir and is re-read on every replay, so pinning --no-captions
|
|
4428
|
-
// because the EDITOR hid them would freeze an edit the user may later
|
|
4429
|
-
// undo in that same editor. See recordedProduceArgs for why the pin is
|
|
4430
|
-
// unconditional even though captions' default is config-independent today.
|
|
4431
|
-
// Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
|
|
4432
|
-
// argv — "auto" has no flag spelling and stays unpinned (see
|
|
4433
|
-
// recordedProduceArgs for why that is safe today).
|
|
4434
|
-
// Youtube pin: the watermark's config-dependent-default rationale exactly —
|
|
4435
|
-
// resolved both ways, so a later config edit can't flip what Render
|
|
4436
|
-
// replays. Portrait and dictionary pin the RESOLVED values (a path and
|
|
4437
|
-
// terms, never a secret) for the same reason; recordedProduceArgs owns
|
|
4438
|
-
// the non-empty/includes guards.
|
|
4439
|
-
const recordedArgs = recordedProduceArgs({
|
|
4440
|
-
llm: provider ? providerName : undefined,
|
|
4441
|
-
clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
|
|
4442
|
-
watermark,
|
|
4443
|
-
captions: opts.captions ?? true,
|
|
4444
|
-
jumpCuts: jumpCutsMode,
|
|
4445
|
-
dictionary,
|
|
4446
|
-
youtube,
|
|
4447
|
-
portrait,
|
|
4448
|
-
audience,
|
|
4449
|
-
thumbnailBrief,
|
|
4450
|
-
});
|
|
4451
|
-
await writeFile(
|
|
4452
|
-
join(work, "command.json"),
|
|
4453
|
-
JSON.stringify(
|
|
4454
|
-
{
|
|
4455
|
-
execPath: process.execPath,
|
|
4456
|
-
execArgv: process.execArgv,
|
|
4457
|
-
script: process.argv[1],
|
|
4458
|
-
args: recordedArgs,
|
|
4459
|
-
cwd: process.cwd(),
|
|
4460
|
-
out: outPath,
|
|
4461
|
-
},
|
|
4462
|
-
null,
|
|
4463
|
-
2,
|
|
4464
|
-
),
|
|
4465
|
-
);
|
|
4466
|
-
// Every produce run is a project the picker should offer (R17 §83) —
|
|
4467
|
-
// best-effort, so a read-only home dir never fails the render.
|
|
4468
|
-
await recordRecentProject(work);
|
|
4928
|
+
// command.json was recorded above the --no-render exit (cut-review step 1)
|
|
4929
|
+
// — see the block before that fork for the §75/§93g/§129 pinning story.
|
|
4469
4930
|
console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
|
|
4470
4931
|
console.log(`✓ done → ${outPath}`);
|
|
4471
4932
|
if (isInteractive()) {
|