ossclip 0.1.27 → 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/edit.ts +42 -0
- package/src/llm-detect.ts +11 -0
- package/src/produce.ts +597 -166
- 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,9 @@ import {
|
|
|
144
149
|
type CleanupLevel,
|
|
145
150
|
type ClipWindow,
|
|
146
151
|
type Layout,
|
|
152
|
+
type LlmEffort,
|
|
147
153
|
type LlmProvider,
|
|
154
|
+
type LlmUsage,
|
|
148
155
|
type Production,
|
|
149
156
|
ossclipOutputPathFor,
|
|
150
157
|
type ProviderName,
|
|
@@ -154,7 +161,7 @@ import {
|
|
|
154
161
|
type Transcript,
|
|
155
162
|
} from "@ossclip/core";
|
|
156
163
|
import { recordRecentProject } from "./edit";
|
|
157
|
-
import { binOnPath, detectionLine } from "./llm-detect";
|
|
164
|
+
import { binOnPath, detectionLine, fallbackLine } from "./llm-detect";
|
|
158
165
|
import {
|
|
159
166
|
modelImpliedLanguage,
|
|
160
167
|
modelUrl,
|
|
@@ -272,11 +279,165 @@ export function transcriptCacheReusable(
|
|
|
272
279
|
};
|
|
273
280
|
}
|
|
274
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
|
+
|
|
275
428
|
export interface ProduceOptions {
|
|
276
429
|
out?: string;
|
|
277
430
|
cleanup: CleanupLevel;
|
|
278
431
|
transcript?: string;
|
|
279
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;
|
|
280
441
|
mezzanine: boolean;
|
|
281
442
|
workdir?: string;
|
|
282
443
|
inspect?: boolean;
|
|
@@ -291,6 +452,12 @@ export interface ProduceOptions {
|
|
|
291
452
|
llmModel?: string;
|
|
292
453
|
/** Model for mechanical calls; "same" sends everything to the main model. */
|
|
293
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;
|
|
294
461
|
/** Who is on camera — steers repair and exempts their name from grounding. */
|
|
295
462
|
speaker?: string;
|
|
296
463
|
/** Repair ASR mishearings before captions/producer/grounding (default on). */
|
|
@@ -483,6 +650,32 @@ export function resolveYoutube(
|
|
|
483
650
|
return flag ?? configValue === true;
|
|
484
651
|
}
|
|
485
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
|
+
|
|
486
679
|
/**
|
|
487
680
|
* How many browser tabs the render runs in parallel (2026-08-17 render-speed
|
|
488
681
|
* pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
|
|
@@ -965,7 +1158,23 @@ export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailS
|
|
|
965
1158
|
// ceiling). Capped BEFORE caching so the cache and the image key hold
|
|
966
1159
|
// what is used.
|
|
967
1160
|
concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
|
|
968
|
-
|
|
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
|
+
);
|
|
969
1178
|
} catch (err) {
|
|
970
1179
|
log(
|
|
971
1180
|
`▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
|
|
@@ -1149,6 +1358,54 @@ export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
|
|
|
1149
1358
|
return "auto";
|
|
1150
1359
|
}
|
|
1151
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
|
+
|
|
1152
1409
|
/**
|
|
1153
1410
|
* The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
|
|
1154
1411
|
* (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
|
|
@@ -1678,6 +1935,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1678
1935
|
const dictionary = opts.dictionary ?? configDictionary ?? [];
|
|
1679
1936
|
if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
|
|
1680
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
|
+
|
|
1681
1947
|
// The run's base theme (F6): config theme over defaultTheme, resolved once
|
|
1682
1948
|
// and used for BOTH resolveTheme's base and props.baseTheme below — the
|
|
1683
1949
|
// editor's reset must land on the user's global colors, not the factory's.
|
|
@@ -2088,31 +2354,62 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2088
2354
|
if (!opts.provider) {
|
|
2089
2355
|
console.log(detectionLine(providerName));
|
|
2090
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;
|
|
2091
2367
|
provider = createTieredProvider(providerName, {
|
|
2092
2368
|
model: opts.llmModel,
|
|
2093
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,
|
|
2094
2374
|
});
|
|
2095
2375
|
}
|
|
2096
2376
|
|
|
2097
2377
|
let rawTranscript = transcript;
|
|
2098
2378
|
let repairs: AppliedRepair[] = [];
|
|
2099
2379
|
if (provider && opts.repair !== false) {
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
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`);
|
|
2116
2413
|
if (existsSync(repairCache)) {
|
|
2117
2414
|
const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
|
|
2118
2415
|
// Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
|
|
@@ -2182,7 +2479,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2182
2479
|
" (not cached — the next run retries the pass)",
|
|
2183
2480
|
);
|
|
2184
2481
|
} else {
|
|
2185
|
-
|
|
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
|
+
);
|
|
2186
2493
|
}
|
|
2187
2494
|
}
|
|
2188
2495
|
for (const r of repairs) {
|
|
@@ -2298,19 +2605,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2298
2605
|
// calls; only a first run selects.
|
|
2299
2606
|
let clipFresh: Awaited<ReturnType<typeof produceScenes>> | null = null;
|
|
2300
2607
|
if (clipTargetSec !== undefined) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
)
|
|
2312
|
-
|
|
2313
|
-
|
|
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 });
|
|
2314
2621
|
const clipWindowCache = join(work, `clipwindow-${windowKey}.json`);
|
|
2315
2622
|
if (opts.clipWindow) {
|
|
2316
2623
|
clipWindow = parseClipWindowPin(transcript, opts.clipWindow);
|
|
@@ -2337,7 +2644,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2337
2644
|
);
|
|
2338
2645
|
clipWindow = clipFresh.clip!.window;
|
|
2339
2646
|
for (const note of clipFresh.clip!.notes) console.log(` ▸ ${note}`);
|
|
2340
|
-
|
|
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
|
+
);
|
|
2341
2666
|
}
|
|
2342
2667
|
|
|
2343
2668
|
// Slice the pipeline state to the window (§93.1), then let everything
|
|
@@ -2381,29 +2706,22 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2381
2706
|
map = new TimeMap(cutlist);
|
|
2382
2707
|
}
|
|
2383
2708
|
|
|
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);
|
|
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 });
|
|
2407
2725
|
const sceneCache = join(work, `scenes-${cacheKey}.json`);
|
|
2408
2726
|
// The cover needs the editorial copy, which is not in the scene list — a
|
|
2409
2727
|
// cached run must still be able to write one.
|
|
@@ -2431,9 +2749,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2431
2749
|
clipFresh.graphics.asked,
|
|
2432
2750
|
transcript,
|
|
2433
2751
|
);
|
|
2434
|
-
|
|
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));
|
|
2435
2760
|
await writeFile(
|
|
2436
|
-
|
|
2761
|
+
join(work, `beatsheet-${adoptKey}.json`),
|
|
2437
2762
|
JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
|
|
2438
2763
|
);
|
|
2439
2764
|
} else if (existsSync(sceneCache)) {
|
|
@@ -2493,9 +2818,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2493
2818
|
// Cache props only — overrides are user-owned and live in overrides.json,
|
|
2494
2819
|
// never in production.json (that file is derived and every `produce`
|
|
2495
2820
|
// run overwrites it, per the merge rule in `overrides.ts`).
|
|
2496
|
-
|
|
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));
|
|
2497
2828
|
await writeFile(
|
|
2498
|
-
|
|
2829
|
+
join(work, `beatsheet-${freshKey}.json`),
|
|
2499
2830
|
JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
|
|
2500
2831
|
);
|
|
2501
2832
|
}
|
|
@@ -2531,8 +2862,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2531
2862
|
// …and stamp it onto the artefact it explains, so `production.json` says
|
|
2532
2863
|
// who planned it without a second file to cross-reference.
|
|
2533
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))];
|
|
2534
2876
|
producerStamp = {
|
|
2535
|
-
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.
|
|
2536
2884
|
models: last.models,
|
|
2537
2885
|
cached: last.cached,
|
|
2538
2886
|
at: last.at,
|
|
@@ -2632,6 +2980,28 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2632
2980
|
}
|
|
2633
2981
|
overrideDoc = parsed.data;
|
|
2634
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
|
+
}
|
|
2635
3005
|
// `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
|
|
2636
3006
|
// no `src` yet) and any already-re-anchored splits/pins are expressed
|
|
2637
3007
|
// relative to whatever render-props the user was LAST looking at, not the
|
|
@@ -3150,7 +3520,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3150
3520
|
transcript: rawTranscript,
|
|
3151
3521
|
repairs: repairs.length > 0 ? repairs : undefined,
|
|
3152
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).
|
|
3153
3528
|
cutlist,
|
|
3529
|
+
cutlistProposed,
|
|
3154
3530
|
...(clipWindow && clipTargetSec !== undefined
|
|
3155
3531
|
? { clip: { targetSec: clipTargetSec, ...clipWindow } }
|
|
3156
3532
|
: {}),
|
|
@@ -3806,12 +4182,147 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3806
4182
|
console.log(overridesWriteLine(cutResult.changed));
|
|
3807
4183
|
}
|
|
3808
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
|
+
|
|
3809
4312
|
if (!opts.render) {
|
|
3810
4313
|
// §140: the breakdown goes above the closing lines on both exits, so the
|
|
3811
4314
|
// last thing on screen stays the success line and the edit hint.
|
|
3812
4315
|
console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
|
|
3813
|
-
|
|
3814
|
-
|
|
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
|
+
}
|
|
3815
4326
|
return {
|
|
3816
4327
|
workdir: work,
|
|
3817
4328
|
rendered: false,
|
|
@@ -3833,27 +4344,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3833
4344
|
// multi-minute render. Interactive runs approve (or edit, or skip) it
|
|
3834
4345
|
// here; the file it writes is what thumbnailStep honors after render — and
|
|
3835
4346
|
// 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);
|
|
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.
|
|
3857
4350
|
const geminiKey = process.env.GEMINI_API_KEY;
|
|
3858
4351
|
const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
|
|
3859
4352
|
// The gate reuses thumbnailDecision (plus the mime check) so the prompt
|
|
@@ -4279,14 +4772,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4279
4772
|
} else {
|
|
4280
4773
|
// Beat-sheet cache shape: keyed on everything that changes the answer —
|
|
4281
4774
|
// who is asked, with what editorial steer, about which words.
|
|
4282
|
-
|
|
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")
|
|
4283
4778
|
.update(
|
|
4284
4779
|
JSON.stringify([
|
|
4285
4780
|
// Prompt changes change the answer (the §78 posture): the v2
|
|
4286
4781
|
// rewrite must not serve a pack cached under v1's questions.
|
|
4287
4782
|
YOUTUBE_PROMPT_VERSION,
|
|
4288
|
-
|
|
4783
|
+
p,
|
|
4289
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] : []),
|
|
4290
4789
|
opts.intent ?? "",
|
|
4291
4790
|
// Steer, so part of the key — a changed audience is a different
|
|
4292
4791
|
// pack, not a cache hit.
|
|
@@ -4302,7 +4801,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4302
4801
|
)
|
|
4303
4802
|
.digest("hex")
|
|
4304
4803
|
.slice(0, 8);
|
|
4305
|
-
const packCache = join(work, `youtube-${
|
|
4804
|
+
const packCache = join(work, `youtube-${packKeyFor(providerName)}.json`);
|
|
4306
4805
|
if (existsSync(packCache)) {
|
|
4307
4806
|
pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
|
|
4308
4807
|
console.log("▸ youtube: metadata cached");
|
|
@@ -4322,7 +4821,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4322
4821
|
durationSec: map.outputDuration,
|
|
4323
4822
|
}),
|
|
4324
4823
|
);
|
|
4325
|
-
|
|
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
|
+
);
|
|
4326
4833
|
} catch (err) {
|
|
4327
4834
|
// NEVER cache a failure (§106), and never fail the produce that
|
|
4328
4835
|
// just rendered over a metadata sidecar: one loud line, the video
|
|
@@ -4388,84 +4895,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4388
4895
|
});
|
|
4389
4896
|
}
|
|
4390
4897
|
}
|
|
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);
|
|
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.
|
|
4469
4900
|
console.log(formatPhaseLine(phases.timings(), phases.totalMs()));
|
|
4470
4901
|
console.log(`✓ done → ${outPath}`);
|
|
4471
4902
|
if (isInteractive()) {
|