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