ossclip 0.1.24 → 0.1.26
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-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-DVI51_2u.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/analyze.ts +17 -1
- package/src/caption-report.ts +141 -3
- package/src/doctor.ts +7 -5
- package/src/edit.ts +571 -3
- 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 +64 -8
- package/src/paths.ts +88 -0
- package/src/portrait-override.ts +86 -0
- package/src/produce.ts +1971 -161
- package/src/program.ts +142 -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 +10 -0
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
- package/editor-dist/assets/index-MLGz89mM.js +0 -163
package/src/produce.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { createReadStream } from "node:fs";
|
|
3
|
-
import { mkdir, readFile, writeFile,
|
|
4
|
-
import {
|
|
3
|
+
import { copyFile, mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import {
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
import { cpus } from "node:os";
|
|
5
14
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
15
|
import { z } from "zod/v4";
|
|
7
16
|
import {
|
|
@@ -17,12 +26,19 @@ import {
|
|
|
17
26
|
assembleScenes,
|
|
18
27
|
buildCaptionLines,
|
|
19
28
|
buildCutlist,
|
|
29
|
+
canonicalizeDictionaryCasing,
|
|
30
|
+
captionsNeedNastaliq,
|
|
31
|
+
NASTALIQ_FONT_NAME,
|
|
32
|
+
NASTALIQ_FONT_REL,
|
|
33
|
+
nastaliqFontFile,
|
|
20
34
|
buildZoomPlan,
|
|
21
35
|
checkGrounding,
|
|
22
36
|
rejectCtaKeyword,
|
|
23
37
|
concatFolder,
|
|
24
38
|
folderManifestKey,
|
|
25
39
|
listFolderVideos,
|
|
40
|
+
outInsideInputFolderMessage,
|
|
41
|
+
outPathInsideInput,
|
|
26
42
|
coverDecision,
|
|
27
43
|
coverHeadline,
|
|
28
44
|
cropFilter,
|
|
@@ -48,18 +64,51 @@ import {
|
|
|
48
64
|
formatBloopSpan,
|
|
49
65
|
findRetakeGroups,
|
|
50
66
|
formatRetakeGroup,
|
|
67
|
+
RESTART_PREFIX_CONFIDENCE,
|
|
68
|
+
type RetakeGroup,
|
|
51
69
|
formatUsageLine,
|
|
52
70
|
formatUsageReport,
|
|
71
|
+
formatYoutubeMarkdown,
|
|
72
|
+
generateYoutubePack,
|
|
73
|
+
stampedTranscript,
|
|
74
|
+
YOUTUBE_APPROVED_BASENAME,
|
|
75
|
+
YOUTUBE_PROMPT_VERSION,
|
|
76
|
+
YoutubePackSchema,
|
|
77
|
+
type YoutubePack,
|
|
78
|
+
THUMBNAIL_APPROVED_BASENAME,
|
|
79
|
+
THUMBNAIL_MODEL_DEFAULT,
|
|
80
|
+
ThumbnailConceptApprovedSchema,
|
|
81
|
+
ThumbnailConceptSchema,
|
|
82
|
+
type ThumbnailConcept,
|
|
83
|
+
type GenerateThumbnailImageOptions,
|
|
84
|
+
approvedOverlayText,
|
|
85
|
+
buildThumbnailPrompt,
|
|
86
|
+
generateThumbnailConcept,
|
|
87
|
+
generateThumbnailImage,
|
|
88
|
+
portraitMimeType,
|
|
89
|
+
thumbnailDecision,
|
|
90
|
+
thumbnailImageCacheName,
|
|
53
91
|
applyUserCuts,
|
|
92
|
+
pruneHidesInsideCuts,
|
|
54
93
|
loadConfig,
|
|
55
94
|
loudnorm,
|
|
56
95
|
MAX_NORMALIZE_UPSCALE,
|
|
96
|
+
MAX_MEAN_AREA_DISCARD,
|
|
97
|
+
FACE_ONLY_MIN_FRAC,
|
|
98
|
+
FACE_MIN_DETECTION_RATIO,
|
|
57
99
|
ZOOM_MAX_SCALE,
|
|
58
100
|
assessCueFraming,
|
|
59
|
-
bakeNormalizedSource,
|
|
60
101
|
planNormalization,
|
|
102
|
+
segmentIsFaceOnly,
|
|
103
|
+
type WindowFace,
|
|
104
|
+
type FaceBox,
|
|
105
|
+
type FramingSegment,
|
|
61
106
|
type NormalizePlan,
|
|
62
107
|
makeMezzanine,
|
|
108
|
+
mezzanineFileName,
|
|
109
|
+
mezzanineScale,
|
|
110
|
+
scaleContentTimeline,
|
|
111
|
+
scaleFramingWindows,
|
|
63
112
|
measureFace,
|
|
64
113
|
measureFaceInWindows,
|
|
65
114
|
pickCoverFrame,
|
|
@@ -72,7 +121,10 @@ import {
|
|
|
72
121
|
resolveTheme,
|
|
73
122
|
run,
|
|
74
123
|
runWhisper,
|
|
124
|
+
whisperPromptFor,
|
|
75
125
|
scanSourceText,
|
|
126
|
+
ThemeSchema,
|
|
127
|
+
type Theme,
|
|
76
128
|
appendUsageRun,
|
|
77
129
|
OverrideDocSchema,
|
|
78
130
|
CLIP_SNAP_TOLERANCE,
|
|
@@ -88,8 +140,10 @@ import {
|
|
|
88
140
|
type BeatsValidationIssue,
|
|
89
141
|
type CleanupLevel,
|
|
90
142
|
type ClipWindow,
|
|
143
|
+
type Layout,
|
|
91
144
|
type LlmProvider,
|
|
92
145
|
type Production,
|
|
146
|
+
ossclipOutputPathFor,
|
|
93
147
|
type ProviderName,
|
|
94
148
|
type Scene,
|
|
95
149
|
type SceneComponentId,
|
|
@@ -98,6 +152,12 @@ import {
|
|
|
98
152
|
} from "@ossclip/core";
|
|
99
153
|
import { recordRecentProject } from "./edit";
|
|
100
154
|
import { binOnPath, detectionLine } from "./llm-detect";
|
|
155
|
+
import {
|
|
156
|
+
modelImpliedLanguage,
|
|
157
|
+
modelUrl,
|
|
158
|
+
validModelSources,
|
|
159
|
+
whisperModelPath,
|
|
160
|
+
} from "./setup/manifest";
|
|
101
161
|
import { PhaseTimer, formatPhaseLine, type PhaseTimings } from "./phase-timing";
|
|
102
162
|
import {
|
|
103
163
|
strandedOverrideSiblings,
|
|
@@ -105,13 +165,18 @@ import {
|
|
|
105
165
|
workdirBaseName,
|
|
106
166
|
} from "./stranded-overrides";
|
|
107
167
|
import { editHint } from "./interactive/edit-hint";
|
|
168
|
+
import { artifactPath, ensureParentDir, expandHome, moveFile } from "./paths";
|
|
169
|
+
import { portraitOverridePath, resolvePortrait } from "./portrait-override";
|
|
170
|
+
import { approveThumbnailConcept, thumbnailRetryLoop } from "./interactive/thumbnail-approve";
|
|
108
171
|
import { isInteractive } from "./interactive/tty";
|
|
109
172
|
import { RenderTimelineHUD, StageAnimator, printProductionCompleteBanner } from "./ui/animation";
|
|
110
173
|
import { reconcileCaptionEdits } from "./caption-report";
|
|
111
174
|
import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
|
|
112
175
|
import { recordedProduceArgs } from "./replay-argv";
|
|
113
|
-
import { renderCover, renderProduction } from "@ossclip/renderer";
|
|
176
|
+
import { makeCancelSignal, renderCover, renderProduction } from "@ossclip/renderer";
|
|
177
|
+
import type { RenderPhase } from "@ossclip/renderer";
|
|
114
178
|
import {
|
|
179
|
+
DEFAULT_FACE,
|
|
115
180
|
coverTextRect,
|
|
116
181
|
layoutSlots,
|
|
117
182
|
regionsDuring,
|
|
@@ -155,6 +220,13 @@ export interface ProduceResult {
|
|
|
155
220
|
export const TranscriptKeySchema = z.object({
|
|
156
221
|
model: z.string(),
|
|
157
222
|
language: z.string().optional(),
|
|
223
|
+
/**
|
|
224
|
+
* The dictionary the whisper `--prompt` was biased with (F4, 2026-08-16) —
|
|
225
|
+
* a changed vocabulary changes what whisper decodes, so it re-keys the
|
|
226
|
+
* cache exactly like the model does. Absent (old key files, no-dictionary
|
|
227
|
+
* runs) means "no biasing".
|
|
228
|
+
*/
|
|
229
|
+
dictionary: z.array(z.string()).optional(),
|
|
158
230
|
});
|
|
159
231
|
export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
|
|
160
232
|
|
|
@@ -178,7 +250,14 @@ export function transcriptCacheReusable(
|
|
|
178
250
|
effective.model === requested.model &&
|
|
179
251
|
// "" and absent both mean whisper's en default — program.ts rejects an
|
|
180
252
|
// empty code, but a key file predating that guard must not wedge.
|
|
181
|
-
(effective.language ?? "") === (requested.language ?? "")
|
|
253
|
+
(effective.language ?? "") === (requested.language ?? "") &&
|
|
254
|
+
// ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
|
|
255
|
+
// text verbatim, so a reordered list genuinely is a different decoder
|
|
256
|
+
// input — treating it as equal would serve a transcript biased by a
|
|
257
|
+
// prompt this run never sent. Absent and [] compare equal (both mean
|
|
258
|
+
// "no biasing"), so pre-dictionary key files reuse under a
|
|
259
|
+
// no-dictionary request.
|
|
260
|
+
JSON.stringify(effective.dictionary ?? []) === JSON.stringify(requested.dictionary ?? []),
|
|
182
261
|
recorded: effective,
|
|
183
262
|
};
|
|
184
263
|
}
|
|
@@ -214,6 +293,13 @@ export interface ProduceOptions {
|
|
|
214
293
|
* decodes garbage (Urdu field test 2026-08-05).
|
|
215
294
|
*/
|
|
216
295
|
whisperLanguage?: string;
|
|
296
|
+
/**
|
|
297
|
+
* Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
|
|
298
|
+
* split/trimmed by the action. Wholesale beats the config's `dictionary`
|
|
299
|
+
* — typed-beats-config like the watermark, and never merged: a per-run
|
|
300
|
+
* list is a deliberate substitution, not an addition.
|
|
301
|
+
*/
|
|
302
|
+
dictionary?: string[];
|
|
217
303
|
/** Debug: force every graphic moment to this component. */
|
|
218
304
|
forceComponent?: SceneComponentId;
|
|
219
305
|
/** Write a cover image beside the video (default on). */
|
|
@@ -228,11 +314,11 @@ export interface ProduceOptions {
|
|
|
228
314
|
*/
|
|
229
315
|
blooperMarker?: string;
|
|
230
316
|
/**
|
|
231
|
-
* `--collapse-retakes` (
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
317
|
+
* `--collapse-retakes` — legacy no-op (2026-08-16). Retake collapse (R27
|
|
318
|
+
* §128) now runs automatically whenever `--blooper-marker` is given and
|
|
319
|
+
* never otherwise (`inferredRetakesEnabled` quotes the user's rule). The
|
|
320
|
+
* flag stays parseable so old command.json replays don't error; typing it
|
|
321
|
+
* without a marker earns a notice instead of a silent ignore.
|
|
236
322
|
*/
|
|
237
323
|
collapseRetakes?: boolean;
|
|
238
324
|
/**
|
|
@@ -248,6 +334,13 @@ export interface ProduceOptions {
|
|
|
248
334
|
* platform chrome to dodge and a landscape source needs no cropping at all.
|
|
249
335
|
*/
|
|
250
336
|
aspect?: "9:16" | "16:9";
|
|
337
|
+
/**
|
|
338
|
+
* `--concurrency <n>`: how many browser tabs the render opens at once,
|
|
339
|
+
* beating the config's `renderConcurrency` and the cpus-2 default
|
|
340
|
+
* (`resolveRenderConcurrency`). Already validated by commander
|
|
341
|
+
* (`concurrencyFlag`), so a number here is always a positive integer.
|
|
342
|
+
*/
|
|
343
|
+
concurrency?: number;
|
|
251
344
|
/**
|
|
252
345
|
* `--clip <seconds>` (R19 §93): produce only the strongest ~N-second window
|
|
253
346
|
* of a long take, chosen by the producer in the same editorial call as the
|
|
@@ -278,6 +371,33 @@ export interface ProduceOptions {
|
|
|
278
371
|
* a free-tier limitation; this is voluntary attribution.
|
|
279
372
|
*/
|
|
280
373
|
watermark?: boolean;
|
|
374
|
+
/**
|
|
375
|
+
* `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
|
|
376
|
+
* true/false when TYPED, undefined when not — undefined lets the config's
|
|
377
|
+
* `youtube` key supply the default (`resolveYoutube`). One flag covers the
|
|
378
|
+
* whole pack (SEO metadata + AI thumbnail) by user decision 2026-08-16.
|
|
379
|
+
*/
|
|
380
|
+
youtube?: boolean;
|
|
381
|
+
/**
|
|
382
|
+
* `--portrait <path>`: the creator's portrait photo, the likeness
|
|
383
|
+
* reference for the `--youtube` AI thumbnail. Typed-beats-config like the
|
|
384
|
+
* dictionary; validated at USE (the thumbnail step), where an absent file
|
|
385
|
+
* is a loud skip and the frame-grab cover stands.
|
|
386
|
+
*/
|
|
387
|
+
portrait?: string;
|
|
388
|
+
/**
|
|
389
|
+
* `--audience <text>`: who watches the channel, steering BOTH the youtube
|
|
390
|
+
* pack's titles/tags and the thumbnail concept. Typed-beats-config like
|
|
391
|
+
* `--portrait`; the config's `audience` supplies the default, validated
|
|
392
|
+
* with `typeof === "string"` at use.
|
|
393
|
+
*/
|
|
394
|
+
audience?: string;
|
|
395
|
+
/**
|
|
396
|
+
* `--thumbnail-brief <text>`: the durable thumbnail steer, fed to the
|
|
397
|
+
* concept call as a must-honor creator brief. Same typed-beats-config
|
|
398
|
+
* contract as `audience` (config key `thumbnailBrief`).
|
|
399
|
+
*/
|
|
400
|
+
thumbnailBrief?: string;
|
|
281
401
|
/**
|
|
282
402
|
* `--captions` / `--no-captions` tri-state: true/false when TYPED,
|
|
283
403
|
* undefined when not. Unlike `watermark` above there is no config key —
|
|
@@ -287,6 +407,15 @@ export interface ProduceOptions {
|
|
|
287
407
|
* record differently.
|
|
288
408
|
*/
|
|
289
409
|
captions?: boolean;
|
|
410
|
+
/**
|
|
411
|
+
* `--add-jump-cuts` / `--no-jump-cuts` tri-state: true/false when TYPED,
|
|
412
|
+
* undefined when not ("auto", the default — punch, face-only). Resolved by
|
|
413
|
+
* `resolveJumpCuts`; scope is the cut punch-in ONLY, narrower than `zoom`,
|
|
414
|
+
* which kills every motion driver at once. Note `true` does NOT override
|
|
415
|
+
* the face-only guard (`punchPlanFor` has the why) — it exists to beat a
|
|
416
|
+
* future config-off, nothing else.
|
|
417
|
+
*/
|
|
418
|
+
jumpCuts?: boolean;
|
|
290
419
|
/**
|
|
291
420
|
* `<input>` a DIRECTORY: order its clips before concatenating them into the
|
|
292
421
|
* source produce runs on (folder-input-brief.md). `name` (default) is a
|
|
@@ -321,6 +450,817 @@ export function resolveWatermark(
|
|
|
321
450
|
return flag ?? configValue === true;
|
|
322
451
|
}
|
|
323
452
|
|
|
453
|
+
/**
|
|
454
|
+
* The effective `--youtube` switch — resolveWatermark's semantics verbatim:
|
|
455
|
+
* a TYPED flag always wins (so `--no-youtube` beats a config-on), and only
|
|
456
|
+
* then does the config supply the default. The config side is `=== true`,
|
|
457
|
+
* never truthiness, for the same parse-don't-coerce reason: the value comes
|
|
458
|
+
* from a hand-editable JSON file loadConfig doesn't zod-parse, and a typo'd
|
|
459
|
+
* `"youtube": "no"` must not switch a metadata+thumbnail pipeline ON. Off is
|
|
460
|
+
* the only safe reading of anything malformed for an opt-in extra. Pure so
|
|
461
|
+
* the flag × config matrix is testable without a config file on disk.
|
|
462
|
+
*/
|
|
463
|
+
export function resolveYoutube(
|
|
464
|
+
flag: boolean | undefined,
|
|
465
|
+
configValue: boolean | undefined,
|
|
466
|
+
): boolean {
|
|
467
|
+
return flag ?? configValue === true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* How many browser tabs the render runs in parallel (2026-08-17 render-speed
|
|
472
|
+
* pass). Precedence: `--concurrency` beats the config's `renderConcurrency`
|
|
473
|
+
* beats the cpus-2 default (floor 2). The default is cpus-2 because the
|
|
474
|
+
* render is decode-bound — every tab waits on OffthreadVideo's ffmpeg extract
|
|
475
|
+
* workers — so saturating all cores with tabs starves the very processes the
|
|
476
|
+
* tabs block on.
|
|
477
|
+
*
|
|
478
|
+
* The flag exists because cpus-2 is a CPU guess with no memory term in it
|
|
479
|
+
* (2026-08-19 field case): a 14-core / 36GB Mac resolved to 12 tabs on a
|
|
480
|
+
* 1080×1920 source and Chrome died WHOLE, twelve in-flight frames at a time.
|
|
481
|
+
* `offthreadVideoCacheSizeInBytes` bounds the cache side of that
|
|
482
|
+
* (render-options.ts); this is the hatch for the tab side, typed per run
|
|
483
|
+
* rather than edited into a config file mid-investigation.
|
|
484
|
+
*
|
|
485
|
+
* The flag arrives already validated by commander (`concurrencyFlag` in
|
|
486
|
+
* program.ts rejects a non-positive/non-integer at the front door, §93a), so
|
|
487
|
+
* only the config value is checked here — the `dictionary` posture, since it
|
|
488
|
+
* comes from hand-editable JSON loadConfig doesn't zod-parse: a positive
|
|
489
|
+
* integer, or one warning and the default, never a coerced tab count. Pure so
|
|
490
|
+
* the flag × config × cpu matrix is testable without a config file or real
|
|
491
|
+
* cpus().
|
|
492
|
+
*/
|
|
493
|
+
export function resolveRenderConcurrency(
|
|
494
|
+
flagValue: number | undefined,
|
|
495
|
+
configValue: unknown,
|
|
496
|
+
cpuCount: number,
|
|
497
|
+
): { concurrency: number; warning?: string } {
|
|
498
|
+
const fallback = Math.max(2, cpuCount - 2);
|
|
499
|
+
// Typed-beats-config, and typed also beats a MALFORMED config: the user
|
|
500
|
+
// asking for 4 tabs on the command line gets 4, not a warning about a
|
|
501
|
+
// config key they did not touch this run.
|
|
502
|
+
if (flagValue !== undefined) return { concurrency: flagValue };
|
|
503
|
+
if (configValue === undefined) return { concurrency: fallback };
|
|
504
|
+
if (typeof configValue === "number" && Number.isInteger(configValue) && configValue > 0) {
|
|
505
|
+
return { concurrency: configValue };
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
concurrency: fallback,
|
|
509
|
+
warning: "⚠ config renderConcurrency ignored — expected a positive integer",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** What a signal that interrupted the render phase costs the caller. */
|
|
514
|
+
export interface RenderCancellation {
|
|
515
|
+
/** Partial render output to delete before exiting. */
|
|
516
|
+
removePaths: string[];
|
|
517
|
+
/** Shell convention: 128 + the signal's number (SIGINT 2, SIGTERM 15). */
|
|
518
|
+
exitCode: number;
|
|
519
|
+
message: string;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The decision half of Ctrl-C-cancels-the-render (2026-08-19 field report:
|
|
524
|
+
* "Cancelling rerendering doesn't work" — nothing in the CLI handled SIGINT,
|
|
525
|
+
* and no cancelSignal reached Remotion, so the browser and its ffmpeg
|
|
526
|
+
* children kept going after the process was told to stop). The signal wiring
|
|
527
|
+
* itself is I/O and lives at the render call site; what to delete and what to
|
|
528
|
+
* exit with is decided here so it can be tested without a real render.
|
|
529
|
+
*
|
|
530
|
+
* `rawPath` is the workdir's `render-raw.mp4`, which a finished run
|
|
531
|
+
* loudnorms and only THEN moves to the user's --out — so a cancel never has
|
|
532
|
+
* an output file to mistake for a finished render. The raw partial is deleted
|
|
533
|
+
* anyway: it is a truncated mp4 sitting in the workdir under the name the
|
|
534
|
+
* next run reads, and a stale one there is the kind of thing that gets picked
|
|
535
|
+
* up by hand and mailed to someone.
|
|
536
|
+
*
|
|
537
|
+
* Non-zero exit, because a cancelled render did not produce the video the
|
|
538
|
+
* caller asked for — but 130/143 rather than 1, so a script can tell a
|
|
539
|
+
* deliberate stop from a failure (the same distinction the editor's
|
|
540
|
+
* /api/render/cancel draws, R16 §60).
|
|
541
|
+
*/
|
|
542
|
+
export function renderCancellation(
|
|
543
|
+
signal: "SIGINT" | "SIGTERM",
|
|
544
|
+
rawPath: string,
|
|
545
|
+
): RenderCancellation {
|
|
546
|
+
return {
|
|
547
|
+
removePaths: [rawPath],
|
|
548
|
+
exitCode: signal === "SIGINT" ? 130 : 143,
|
|
549
|
+
message: "▸ cancelled — partial output discarded",
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Where the run is when a signal lands, collapsed to the three cases that
|
|
555
|
+
* behave differently. `RenderPhase`'s "bundling" and "selecting" both collapse
|
|
556
|
+
* to "pre-render": neither takes a cancelSignal, so neither can be stopped
|
|
557
|
+
* cooperatively. "post-render" is the window between `renderMedia` resolving
|
|
558
|
+
* and the handlers coming off in the `finally`.
|
|
559
|
+
*/
|
|
560
|
+
export type RenderSignalPhase = "pre-render" | "rendering" | "post-render";
|
|
561
|
+
|
|
562
|
+
/** Map the renderer's phase report onto what a signal can do about it. */
|
|
563
|
+
export function renderSignalPhaseOf(phase: RenderPhase): RenderSignalPhase {
|
|
564
|
+
return phase === "rendering" ? "rendering" : "pre-render";
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** What the SIGINT/SIGTERM handler should do about the signal it just got. */
|
|
568
|
+
export interface RenderSignalAction {
|
|
569
|
+
/** Fire the Remotion cancel signal. Free, and only `renderMedia` listens. */
|
|
570
|
+
cancel: boolean;
|
|
571
|
+
/**
|
|
572
|
+
* Tear down and `process.exit` from INSIDE the handler, because nothing
|
|
573
|
+
* downstream is going to stop on its own.
|
|
574
|
+
*/
|
|
575
|
+
exitNow: boolean;
|
|
576
|
+
/** Printed above the cancellation message when the exit needs explaining. */
|
|
577
|
+
note?: string;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* The decision half of "Ctrl-C must never be a no-op" (2026-08-19 review of
|
|
582
|
+
* the cancel feature). Registering a SIGINT listener SUPPRESSES node's default
|
|
583
|
+
* terminate, so the cancel feature as first written made Ctrl-C *worse* than
|
|
584
|
+
* before it existed in every phase the cancelSignal does not reach:
|
|
585
|
+
*
|
|
586
|
+
* - "pre-render" — `bundle()` and `selectComposition()` take no cancelSignal
|
|
587
|
+
* in @remotion/renderer 4.0.499 (verified against the installed types; see
|
|
588
|
+
* RenderPhase in @ossclip/renderer). A cold bundle is tens of seconds, and
|
|
589
|
+
* minutes when Chrome is downloaded on first run, and for all of it Ctrl-C
|
|
590
|
+
* did NOTHING while the terminal looked hung. The handler must exit itself.
|
|
591
|
+
* That can orphan the Chrome `selectComposition` opened — but a bare Ctrl-C
|
|
592
|
+
* before the cancel feature did exactly the same, so it is not a
|
|
593
|
+
* regression, and a terminal that ignores Ctrl-C is worse than a stray
|
|
594
|
+
* browser process.
|
|
595
|
+
* - "rendering" — the one phase that IS cooperative: fire the signal and let
|
|
596
|
+
* Remotion tear the browser and its ffmpeg children down.
|
|
597
|
+
* - "post-render" — HONORED, not ignored: the caller stops before mastering
|
|
598
|
+
* and discards the raw render (see the tail check at the render call site).
|
|
599
|
+
*
|
|
600
|
+
* SECOND SIGNAL ALWAYS EXITS, in every phase. If Remotion's teardown wedges,
|
|
601
|
+
* the user's only remaining move must not be `kill -9` from another terminal.
|
|
602
|
+
*/
|
|
603
|
+
export function renderSignalAction(
|
|
604
|
+
phase: RenderSignalPhase,
|
|
605
|
+
signalCount: number,
|
|
606
|
+
): RenderSignalAction {
|
|
607
|
+
if (signalCount >= 2) {
|
|
608
|
+
return {
|
|
609
|
+
cancel: true,
|
|
610
|
+
exitNow: true,
|
|
611
|
+
note: "▸ second signal — exiting without waiting for the render to shut down",
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
if (phase === "pre-render") {
|
|
615
|
+
return {
|
|
616
|
+
cancel: true,
|
|
617
|
+
exitNow: true,
|
|
618
|
+
note:
|
|
619
|
+
"▸ cancelled while preparing the render — that phase cannot be interrupted " +
|
|
620
|
+
"cleanly, so stopping the process",
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
return { cancel: true, exitNow: false };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Moved to paths.ts (2026-08-17, editor thumbnail panel): the edit server
|
|
627
|
+
// derives `<out>.thumbnail.png` from command.json's recorded out and must not
|
|
628
|
+
// import this module — produce.ts imports edit.ts (recordRecentProject), so
|
|
629
|
+
// the reverse edge would be a cycle, and this module's import graph drags the
|
|
630
|
+
// whole renderer into a server that is deliberately dependency-free.
|
|
631
|
+
// Re-exported so existing importers (tests) keep their path.
|
|
632
|
+
export { artifactPath } from "./paths";
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* `--dictionary "JSON, ossclip"` → `["JSON", "ossclip"]`. Comma-separated in
|
|
636
|
+
* ONE value because a variadic option fights the optional positional
|
|
637
|
+
* `[input]` (see program.ts); split/trim/drop-empties here so a trailing
|
|
638
|
+
* comma or doubled space never becomes an empty term in the whisper prompt.
|
|
639
|
+
* `undefined` in, `undefined` out — "not typed" must survive to let the
|
|
640
|
+
* config supply the dictionary. Pure so the split matrix is testable without
|
|
641
|
+
* commander.
|
|
642
|
+
*/
|
|
643
|
+
export function dictionaryFlag(value: string | undefined): string[] | undefined {
|
|
644
|
+
if (value === undefined) return undefined;
|
|
645
|
+
return value
|
|
646
|
+
.split(",")
|
|
647
|
+
.map((t) => t.trim())
|
|
648
|
+
.filter(Boolean);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Consumer-side validation for the config's `dictionary` key — the
|
|
653
|
+
* `watermark` posture applied to an array: the value comes from a
|
|
654
|
+
* hand-editable JSON file loadConfig doesn't zod-parse, so a non-array, a
|
|
655
|
+
* number in the list, or a term that trims to nothing means the whole key is
|
|
656
|
+
* ignored (`undefined`) and the call site prints one warning naming the
|
|
657
|
+
* problem. All-or-nothing on purpose: silently keeping the salvageable half
|
|
658
|
+
* of a typo'd list would bias whisper with a vocabulary the user never
|
|
659
|
+
* reviewed. Pure so the matrix is testable without a config file on disk.
|
|
660
|
+
*/
|
|
661
|
+
export function validDictionary(value: unknown): string[] | undefined {
|
|
662
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
663
|
+
if (!value.every((t) => typeof t === "string" && t.trim().length > 0)) return undefined;
|
|
664
|
+
return value.map((t: string) => t.trim());
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The effective whisper `-l` for a run — typed-beats-config precedence like
|
|
669
|
+
* `--dictionary`, with a third rung under both: the curated model table's
|
|
670
|
+
* implied language (`modelImpliedLanguage`), so `--whisper-model medium-urdu`
|
|
671
|
+
* alone decodes Urdu instead of silently decoding English garbage (the Urdu
|
|
672
|
+
* field test's exact first-run failure, 2026-08-05). The config side is
|
|
673
|
+
* typeof+trim, never truthiness — `language` comes from a hand-editable JSON
|
|
674
|
+
* file loadConfig doesn't zod-parse, and a malformed value earns one warning
|
|
675
|
+
* and falls through, never a coerced `-l`. `source` rides along so the call
|
|
676
|
+
* site can say where a non-flag language came from. Pure so the whole
|
|
677
|
+
* flag × config × model matrix is testable without a config file on disk.
|
|
678
|
+
*/
|
|
679
|
+
export function resolveWhisperLanguage(
|
|
680
|
+
flag: string | undefined,
|
|
681
|
+
configValue: unknown,
|
|
682
|
+
modelImplied: string | undefined,
|
|
683
|
+
): { language: string | undefined; source: "flag" | "config" | "model" | null; warning?: string } {
|
|
684
|
+
if (flag !== undefined) return { language: flag, source: "flag" };
|
|
685
|
+
const configOk = typeof configValue === "string" && configValue.trim().length > 0;
|
|
686
|
+
const warning =
|
|
687
|
+
configValue !== undefined && !configOk
|
|
688
|
+
? "⚠ config language ignored — expected a non-empty language code string"
|
|
689
|
+
: undefined;
|
|
690
|
+
if (configOk) return { language: (configValue as string).trim(), source: "config" };
|
|
691
|
+
if (modelImplied !== undefined) return { language: modelImplied, source: "model", ...(warning ? { warning } : {}) };
|
|
692
|
+
return { language: undefined, source: null, ...(warning ? { warning } : {}) };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* The BASE theme a run starts from: the config's `theme` merged over
|
|
697
|
+
* `defaultTheme` (F6, 2026-08-16). Precedence overall is overrides.json >
|
|
698
|
+
* config theme > defaultTheme — this helper builds the bottom two layers,
|
|
699
|
+
* and it must feed BOTH `resolveTheme`'s base and props.baseTheme: the
|
|
700
|
+
* editor re-applies overrides onto `baseTheme`, so a reset there must fall
|
|
701
|
+
* back to the user's global colors, not to factory defaults.
|
|
702
|
+
*
|
|
703
|
+
* All-or-nothing: `ThemeSchema.partial().safeParse` — one malformed key (a
|
|
704
|
+
* numeric `accent`, an unknown-shaped value) voids the WHOLE config theme
|
|
705
|
+
* with a warning naming the issue, because half-applying a palette the
|
|
706
|
+
* schema rejected would render colors the user never chose. The warning is
|
|
707
|
+
* RETURNED, not printed — pure, so the precedence matrix is testable without
|
|
708
|
+
* a config file or a captured console.
|
|
709
|
+
*/
|
|
710
|
+
export function configuredBaseTheme(cfgTheme: unknown): { theme: Theme; warning?: string } {
|
|
711
|
+
if (cfgTheme === undefined) return { theme: defaultTheme };
|
|
712
|
+
const parsed = ThemeSchema.partial().strict().safeParse(cfgTheme);
|
|
713
|
+
if (!parsed.success) {
|
|
714
|
+
return {
|
|
715
|
+
theme: defaultTheme,
|
|
716
|
+
warning:
|
|
717
|
+
`⚠ config theme ignored — ${parsed.error.issues
|
|
718
|
+
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
719
|
+
.join("; ")}`,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
// Re-parse the merge so zod's defaults fill anything the partial left out —
|
|
723
|
+
// the same construction defaultTheme itself uses.
|
|
724
|
+
return { theme: ThemeSchema.parse({ ...defaultTheme, ...parsed.data }) };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The concept cache's filename: keyed on who is asked, with what steer,
|
|
729
|
+
* about which words (the Y2 pack-key shape) — audience/brief/titleAngle are
|
|
730
|
+
* steer, so a changed one regenerates. ONE function shared by thumbnailStep
|
|
731
|
+
* and the pre-render approval step, so the approval's cache seed can never
|
|
732
|
+
* drift from the file the step would write. Pure so the key's inputs are
|
|
733
|
+
* pinned by a test.
|
|
734
|
+
*/
|
|
735
|
+
export function thumbnailConceptCacheName(parts: {
|
|
736
|
+
providerName: string;
|
|
737
|
+
llmModel?: string;
|
|
738
|
+
intent?: string;
|
|
739
|
+
hook?: string;
|
|
740
|
+
audience?: string;
|
|
741
|
+
brief?: string;
|
|
742
|
+
titleAngle?: string;
|
|
743
|
+
transcriptWords: readonly string[];
|
|
744
|
+
}): string {
|
|
745
|
+
const key = createHash("sha1")
|
|
746
|
+
.update(
|
|
747
|
+
JSON.stringify([
|
|
748
|
+
parts.providerName,
|
|
749
|
+
parts.llmModel ?? "",
|
|
750
|
+
parts.intent ?? "",
|
|
751
|
+
parts.hook ?? "",
|
|
752
|
+
parts.audience ?? "",
|
|
753
|
+
parts.brief ?? "",
|
|
754
|
+
parts.titleAngle ?? "",
|
|
755
|
+
parts.transcriptWords,
|
|
756
|
+
]),
|
|
757
|
+
)
|
|
758
|
+
.digest("hex")
|
|
759
|
+
.slice(0, 8);
|
|
760
|
+
return `thumbnail-concept-${key}.json`;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* The workdir's approved YouTube pack, or undefined. The Y2 block checks
|
|
765
|
+
* this FIRST (editor SEO panel, 2026-08-17 — thumbnailStep's approval-file
|
|
766
|
+
* contract applied to the pack): once the editor persisted an edited pack,
|
|
767
|
+
* a cache lookup or a fresh LLM call would silently discard the user's
|
|
768
|
+
* words. Exported so the honor/leniency matrix is testable with a temp dir.
|
|
769
|
+
*
|
|
770
|
+
* Read-side leniency, unlike thumbnailStep's hard `.parse`: a corrupt
|
|
771
|
+
* decision file here warns and falls through to the generate path — the pack
|
|
772
|
+
* is a sidecar on a render that must not die over it (§112), and the next
|
|
773
|
+
* editor save atomically replaces the file anyway.
|
|
774
|
+
*/
|
|
775
|
+
export async function readApprovedYoutubePack(
|
|
776
|
+
work: string,
|
|
777
|
+
log: (line: string) => void = console.log,
|
|
778
|
+
): Promise<YoutubePack | undefined> {
|
|
779
|
+
const path = join(work, YOUTUBE_APPROVED_BASENAME);
|
|
780
|
+
if (!existsSync(path)) return undefined;
|
|
781
|
+
try {
|
|
782
|
+
const parsed = YoutubePackSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
|
|
783
|
+
if (parsed.success) return parsed.data;
|
|
784
|
+
log(` ⚠ ${YOUTUBE_APPROVED_BASENAME} is not a valid pack — regenerating instead`);
|
|
785
|
+
} catch {
|
|
786
|
+
log(` ⚠ ${YOUTUBE_APPROVED_BASENAME} is not valid JSON — regenerating instead`);
|
|
787
|
+
}
|
|
788
|
+
return undefined;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Everything the AI thumbnail step (Y3) needs, gathered for testability. */
|
|
792
|
+
export interface ThumbnailStepArgs {
|
|
793
|
+
/** The resolved `--youtube` switch — off means the whole step is silent. */
|
|
794
|
+
youtube: boolean;
|
|
795
|
+
/** Resolved `--portrait` / config path; existence is checked HERE. */
|
|
796
|
+
portraitPath: string | undefined;
|
|
797
|
+
/** GEMINI_API_KEY — env-only, never config (env.ts secrets rule). */
|
|
798
|
+
apiKey: string | undefined;
|
|
799
|
+
/** The image model slug (config `thumbnailModel` or the default). */
|
|
800
|
+
model: string;
|
|
801
|
+
work: string;
|
|
802
|
+
outPath: string;
|
|
803
|
+
/** The run's text provider — the concept call rides it, tier editorial. */
|
|
804
|
+
provider: LlmProvider | undefined;
|
|
805
|
+
providerName: string;
|
|
806
|
+
llmModel: string | undefined;
|
|
807
|
+
intent: string | undefined;
|
|
808
|
+
hook: string | undefined;
|
|
809
|
+
/** Resolved `--audience` / config — who the channel is for. */
|
|
810
|
+
audience?: string;
|
|
811
|
+
/** Resolved `--thumbnail-brief` / config — the durable must-honor steer. */
|
|
812
|
+
brief?: string;
|
|
813
|
+
/**
|
|
814
|
+
* The youtube pack's first title, when the pack generated before this step
|
|
815
|
+
* — the thumbnail must tell the same story as the title it ships under.
|
|
816
|
+
*/
|
|
817
|
+
titleAngle?: string;
|
|
818
|
+
transcriptWords: readonly string[];
|
|
819
|
+
/**
|
|
820
|
+
* The image-generation seam, pickCoverFrame's `detectFace` shape: tests
|
|
821
|
+
* inject a stub here and therefore never import @google/genai.
|
|
822
|
+
*/
|
|
823
|
+
generate?: (opts: GenerateThumbnailImageOptions) => Promise<Uint8Array>;
|
|
824
|
+
/** Phase-timing wrapper for the concept LLM call; identity by default. */
|
|
825
|
+
time?: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
826
|
+
log?: (line: string) => void;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* What a successful thumbnail step hands back — more than the path, because
|
|
831
|
+
* the post-generation retry loop ("regenerate with a note", 2026-08-16
|
|
832
|
+
* thumbnail UX) reuses the exact concept, cache file and portrait bytes this
|
|
833
|
+
* step generated with. Re-deriving any of them in the loop would let the two
|
|
834
|
+
* drift (a different cache key regenerating a file the loop then never
|
|
835
|
+
* overwrites).
|
|
836
|
+
*/
|
|
837
|
+
export interface ThumbnailStepResult {
|
|
838
|
+
/** The written `<out>.thumbnail.png`. */
|
|
839
|
+
path: string;
|
|
840
|
+
/** The concept the image was prompted with — unchanged across retries. */
|
|
841
|
+
concept: ThumbnailConcept;
|
|
842
|
+
/** The workdir image cache the retry loop overwrites in place. */
|
|
843
|
+
imageCachePath: string;
|
|
844
|
+
/** The portrait as the inlineData shape the generate seam takes. */
|
|
845
|
+
portrait: { data: string; mimeType: string };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* The `--youtube` AI thumbnail orchestration (Y3, 2026-08-16): decide,
|
|
850
|
+
* concept, image, copy beside the output. Extracted from `produce()` so the
|
|
851
|
+
* cache/degrade matrix is testable with an injected `generate` and a temp
|
|
852
|
+
* dir — no SDK, no network.
|
|
853
|
+
*
|
|
854
|
+
* Additive to the cover pipeline by contract: EVERY exit short of success is
|
|
855
|
+
* one loud line and `undefined`, and the frame-grab cover stands. Returns
|
|
856
|
+
* the written `<out>.thumbnail.png` path (plus the retry loop's inputs) on
|
|
857
|
+
* success.
|
|
858
|
+
*/
|
|
859
|
+
export async function thumbnailStep(args: ThumbnailStepArgs): Promise<ThumbnailStepResult | undefined> {
|
|
860
|
+
const {
|
|
861
|
+
generate = generateThumbnailImage,
|
|
862
|
+
time = <T>(fn: () => Promise<T>) => fn(),
|
|
863
|
+
log = console.log,
|
|
864
|
+
} = args;
|
|
865
|
+
const portraitExists = args.portraitPath ? existsSync(args.portraitPath) : false;
|
|
866
|
+
const decision = thumbnailDecision(
|
|
867
|
+
args.youtube,
|
|
868
|
+
args.portraitPath,
|
|
869
|
+
args.apiKey !== undefined && args.apiKey !== "",
|
|
870
|
+
portraitExists,
|
|
871
|
+
);
|
|
872
|
+
if (decision !== "generate") {
|
|
873
|
+
// youtube-off is the one silent exit: the user never opted in, so there
|
|
874
|
+
// is nothing to explain. Every other skip is a run the user configured
|
|
875
|
+
// for a thumbnail and didn't get one — say why, once.
|
|
876
|
+
if (decision !== "skip-no-youtube") {
|
|
877
|
+
const reason =
|
|
878
|
+
decision === "skip-no-portrait"
|
|
879
|
+
? "no portrait — set `portrait` in ~/.ossclip/config.json or pass --portrait"
|
|
880
|
+
: decision === "skip-no-key"
|
|
881
|
+
? "GEMINI_API_KEY not set"
|
|
882
|
+
: `portrait not found: ${args.portraitPath}`;
|
|
883
|
+
log(`▸ thumbnail: skipped (${reason}) — frame-grab cover stands`);
|
|
884
|
+
}
|
|
885
|
+
return undefined;
|
|
886
|
+
}
|
|
887
|
+
// The pre-render approval file, checked FIRST (2026-08-16 thumbnail UX):
|
|
888
|
+
// the user approved — or explicitly skipped — this exact concept before
|
|
889
|
+
// the render, so asking a model again here would discard their edit. The
|
|
890
|
+
// skip variant is a LOUD skip: unlike youtube-off, the user opted in and
|
|
891
|
+
// then declined this one thumbnail, and the line says how to revisit.
|
|
892
|
+
const approvedPath = join(args.work, THUMBNAIL_APPROVED_BASENAME);
|
|
893
|
+
let approved: ThumbnailConcept | undefined;
|
|
894
|
+
if (existsSync(approvedPath)) {
|
|
895
|
+
const parsed = ThumbnailConceptApprovedSchema.parse(
|
|
896
|
+
JSON.parse(await readFile(approvedPath, "utf8")),
|
|
897
|
+
);
|
|
898
|
+
if ("skip" in parsed) {
|
|
899
|
+
log(
|
|
900
|
+
`▸ thumbnail: skipped (declined at concept approval — delete ` +
|
|
901
|
+
`${THUMBNAIL_APPROVED_BASENAME} in the workdir to revisit) — frame-grab cover stands`,
|
|
902
|
+
);
|
|
903
|
+
return undefined;
|
|
904
|
+
}
|
|
905
|
+
approved = parsed;
|
|
906
|
+
}
|
|
907
|
+
const mimeType = portraitMimeType(args.portraitPath!);
|
|
908
|
+
if (!mimeType) {
|
|
909
|
+
log(
|
|
910
|
+
`▸ thumbnail: skipped (unsupported portrait format "${args.portraitPath}" — ` +
|
|
911
|
+
"use png, jpg, jpeg or webp) — frame-grab cover stands",
|
|
912
|
+
);
|
|
913
|
+
return undefined;
|
|
914
|
+
}
|
|
915
|
+
let concept: ThumbnailConcept;
|
|
916
|
+
if (approved) {
|
|
917
|
+
// No concept call, no concept cache — the approved file IS the concept.
|
|
918
|
+
concept = approved;
|
|
919
|
+
log("▸ thumbnail: using the approved concept");
|
|
920
|
+
} else {
|
|
921
|
+
if (!args.provider) {
|
|
922
|
+
// The concept call rides the run's text provider (Y2's exactly); the
|
|
923
|
+
// IMAGE key alone cannot write the concept, so no provider means no
|
|
924
|
+
// thumbnail — loud, because the youtube gate was on.
|
|
925
|
+
log("▸ thumbnail: skipped (no LLM provider for the concept) — frame-grab cover stands");
|
|
926
|
+
return undefined;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Concept cache (thumbnailConceptCacheName has the key's rationale).
|
|
930
|
+
// Failures are never cached (§106).
|
|
931
|
+
const conceptCache = join(args.work, thumbnailConceptCacheName(args));
|
|
932
|
+
if (existsSync(conceptCache)) {
|
|
933
|
+
concept = ThumbnailConceptSchema.parse(JSON.parse(await readFile(conceptCache, "utf8")));
|
|
934
|
+
log("▸ thumbnail: concept cached");
|
|
935
|
+
} else {
|
|
936
|
+
try {
|
|
937
|
+
const fresh = await time(() =>
|
|
938
|
+
generateThumbnailConcept(args.provider!, {
|
|
939
|
+
hook: args.hook,
|
|
940
|
+
intent: args.intent,
|
|
941
|
+
audience: args.audience,
|
|
942
|
+
brief: args.brief,
|
|
943
|
+
titleAngle: args.titleAngle,
|
|
944
|
+
transcriptText: args.transcriptWords.join(" "),
|
|
945
|
+
}),
|
|
946
|
+
);
|
|
947
|
+
// The schema caps CHARACTERS; approvedOverlayText caps WORDS (§35 —
|
|
948
|
+
// overlay text at thumbnail size has a cover banner's 4-9 word
|
|
949
|
+
// ceiling). Capped BEFORE caching so the cache and the image key hold
|
|
950
|
+
// what is used.
|
|
951
|
+
concept = { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
|
|
952
|
+
await writeFile(conceptCache, JSON.stringify(concept, null, 2));
|
|
953
|
+
} catch (err) {
|
|
954
|
+
log(
|
|
955
|
+
`▸ thumbnail: concept failed (${err instanceof Error ? err.message : String(err)}) ` +
|
|
956
|
+
"— frame-grab cover stands",
|
|
957
|
+
);
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Image cache — thumbnailImageCacheName has the key's rationale, and it is
|
|
964
|
+
// shared with the editor's regenerate endpoint so the two callers can never
|
|
965
|
+
// cache past each other.
|
|
966
|
+
const portraitBytes = await readFile(args.portraitPath!);
|
|
967
|
+
const imageCache = join(
|
|
968
|
+
args.work,
|
|
969
|
+
thumbnailImageCacheName(
|
|
970
|
+
args.model,
|
|
971
|
+
concept,
|
|
972
|
+
createHash("sha1").update(portraitBytes).digest("hex"),
|
|
973
|
+
),
|
|
974
|
+
);
|
|
975
|
+
if (existsSync(imageCache)) {
|
|
976
|
+
log("▸ thumbnail: image cached");
|
|
977
|
+
} else {
|
|
978
|
+
try {
|
|
979
|
+
const bytes = await generate({
|
|
980
|
+
apiKey: args.apiKey!,
|
|
981
|
+
model: args.model,
|
|
982
|
+
prompt: buildThumbnailPrompt(concept, true),
|
|
983
|
+
portrait: { data: portraitBytes.toString("base64"), mimeType },
|
|
984
|
+
});
|
|
985
|
+
await writeFile(imageCache, bytes);
|
|
986
|
+
} catch (err) {
|
|
987
|
+
// NEVER cache a failure (§106), never fail the produce that just
|
|
988
|
+
// rendered. The message rides VERBATIM — the model slug is
|
|
989
|
+
// user-specified, and an unknown-model rejection is deterministic, so
|
|
990
|
+
// no retry and no paraphrase (§132 posture).
|
|
991
|
+
log(
|
|
992
|
+
`▸ thumbnail: generation failed (${err instanceof Error ? err.message : String(err)}) ` +
|
|
993
|
+
"— frame-grab cover stands",
|
|
994
|
+
);
|
|
995
|
+
return undefined;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const dest = artifactPath(args.outPath, ".thumbnail.png");
|
|
999
|
+
await copyFile(imageCache, dest);
|
|
1000
|
+
log(`✓ thumbnail → ${dest}`);
|
|
1001
|
+
return {
|
|
1002
|
+
path: dest,
|
|
1003
|
+
concept,
|
|
1004
|
+
imageCachePath: imageCache,
|
|
1005
|
+
portrait: { data: portraitBytes.toString("base64"), mimeType },
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Whether inferred retake collapse (findRetakeGroups, R27 §128) runs at all.
|
|
1011
|
+
* Gated on the blooper marker, NOT on `--collapse-retakes` — user decision,
|
|
1012
|
+
* verbatim (2026-08-16): "Bloopers and retakes go hand-in-hand. Do not do
|
|
1013
|
+
* retakes without bloopers... If blooper is there, we do it, else we don't."
|
|
1014
|
+
* A marker the speaker says out loud is the signal that this recording style
|
|
1015
|
+
* leaves flubs in the take; without it, inferred cutting has no such
|
|
1016
|
+
* license. `--collapse-retakes` stays parseable (old command.json replays)
|
|
1017
|
+
* but inert. Trim-empty counts as absent: findBloopSpans refuses a blank
|
|
1018
|
+
* marker for the same reason. Pure so the gate matrix is testable without a
|
|
1019
|
+
* run.
|
|
1020
|
+
*/
|
|
1021
|
+
export function inferredRetakesEnabled(blooperMarker: string | undefined): boolean {
|
|
1022
|
+
return typeof blooperMarker === "string" && blooperMarker.trim().length > 0;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* RetakeGroup cuts → `buildCutlist`'s `retakes` entries. The exact-prefix
|
|
1027
|
+
* restart rule carries RESTART_PREFIX_CONFIDENCE (0.85) instead of the 0.9
|
|
1028
|
+
* default a similarity-matched retake earns — the group's `rule` is the only
|
|
1029
|
+
* place that distinction lives, and it's dropped by the flatMap, so the
|
|
1030
|
+
* confidence has to be attached here. Pure so the mapping is testable
|
|
1031
|
+
* without a run.
|
|
1032
|
+
*/
|
|
1033
|
+
export function retakeCutsFor(
|
|
1034
|
+
groups: readonly RetakeGroup[],
|
|
1035
|
+
): { startWord: number; endWord: number; startSec: number; endSec: number; confidence?: number }[] {
|
|
1036
|
+
return groups.flatMap((g) =>
|
|
1037
|
+
g.cuts.map((c) =>
|
|
1038
|
+
g.rule === "exact-prefix" ? { ...c, confidence: RESTART_PREFIX_CONFIDENCE } : c,
|
|
1039
|
+
),
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* How much of the source a cover crop into `frame` keeps, and on which axis.
|
|
1045
|
+
* Cover scales the picture until BOTH frame axes are filled, then trims
|
|
1046
|
+
* whichever source axis overflows: a source wider than the frame loses width
|
|
1047
|
+
* (kept = frameAspect / contentAspect), a narrower one loses height (the
|
|
1048
|
+
* inverse). `null` means either nothing is trimmed (matching aspects) or a
|
|
1049
|
+
* dimension is degenerate and no claim can be made. Orientation-neutral on
|
|
1050
|
+
* purpose: the old call-site warning was gated on `!landscape`, assuming a
|
|
1051
|
+
* 16:9 output never meaningfully crops a 16:9-ish source — and the
|
|
1052
|
+
* 2026-08-16 incident was exactly that, a 1.547:1 screen recording in a 16:9
|
|
1053
|
+
* frame with 13% of the height silently gone (28% post-normalization) and no
|
|
1054
|
+
* line in the log ever mentioning it. Pure so the whole orientation matrix
|
|
1055
|
+
* is testable without probing a real video.
|
|
1056
|
+
*/
|
|
1057
|
+
export function coverKeepFraction(
|
|
1058
|
+
content: { width: number; height: number },
|
|
1059
|
+
frame: { width: number; height: number },
|
|
1060
|
+
): { axis: "width" | "height"; kept: number } | null {
|
|
1061
|
+
if (content.width <= 0 || content.height <= 0 || frame.width <= 0 || frame.height <= 0) {
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
const contentAspect = content.width / content.height;
|
|
1065
|
+
const frameAspect = frame.width / frame.height;
|
|
1066
|
+
if (contentAspect > frameAspect) return { axis: "width", kept: frameAspect / contentAspect };
|
|
1067
|
+
if (contentAspect < frameAspect) return { axis: "height", kept: contentAspect / frameAspect };
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* What the whole-take face measurement says the frame's SUBJECT is — the
|
|
1073
|
+
* same rule `segmentIsFaceOnly` (core) applies per segment, here on the
|
|
1074
|
+
* global median box that feeds `face` in render-props. "screen" tells the
|
|
1075
|
+
* stage's cover bias to stay centered instead of chasing the face: in the
|
|
1076
|
+
* 2026-08-16 incident the global 9-sample median landed on the camera PiP
|
|
1077
|
+
* (sizeFrac 0.119, bottom-right) and pinned objectPosY to 1.0, cutting the
|
|
1078
|
+
* speaker's head off at the top of every full-frame stretch — a PiP-sized
|
|
1079
|
+
* face must not steer the cover. Accepts `measureFace`'s own return shape
|
|
1080
|
+
* (null = no face found at all), and reads null the way segmentIsFaceOnly
|
|
1081
|
+
* does: no face, one below FACE_ONLY_MIN_FRAC, or one seen in under
|
|
1082
|
+
* FACE_MIN_DETECTION_RATIO of the samples means the picture is the subject.
|
|
1083
|
+
* Pure so the classification matrix is testable without a video or the
|
|
1084
|
+
* detector.
|
|
1085
|
+
*/
|
|
1086
|
+
export function faceSubject(faceBox: FaceBox | null): "face" | "screen" {
|
|
1087
|
+
if (!faceBox) return "screen";
|
|
1088
|
+
if (faceBox.sizeFrac < FACE_ONLY_MIN_FRAC) return "screen";
|
|
1089
|
+
return faceBox.framesSampled > 0 &&
|
|
1090
|
+
faceBox.framesDetected / faceBox.framesSampled >= FACE_MIN_DETECTION_RATIO
|
|
1091
|
+
? "face"
|
|
1092
|
+
: "screen";
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Reunites commander's two jump-cut keys into the one tri-state
|
|
1097
|
+
* `ProduceOptions.jumpCuts`. Unlike the watermark pair — one key, positive
|
|
1098
|
+
* declared first so the untyped default stays undefined — this pair's
|
|
1099
|
+
* positive is spelled `--add-jump-cuts` (bare "--jump-cuts" reads as adding
|
|
1100
|
+
* CUTS, not the zooms that conceal them), and commander only folds a
|
|
1101
|
+
* negative onto the key its exact positive spelling owns: `--no-jump-cuts`
|
|
1102
|
+
* alone creates `jumpCuts` defaulting TRUE, while `--add-jump-cuts` lands on
|
|
1103
|
+
* `addJumpCuts`. So "typed --no-jump-cuts" is indistinguishable from "not
|
|
1104
|
+
* typed" by value — the caller passes commander's getOptionValueSource
|
|
1105
|
+
* verdict instead. Both typed is a contradiction and must be a loud error,
|
|
1106
|
+
* never a precedence rule the user has to memorize. Pure so the whole
|
|
1107
|
+
* flag matrix is testable without commander in the loop.
|
|
1108
|
+
*/
|
|
1109
|
+
export function jumpCutsFlag(
|
|
1110
|
+
addJumpCuts: boolean | undefined,
|
|
1111
|
+
noJumpCutsTyped: boolean,
|
|
1112
|
+
): boolean | undefined {
|
|
1113
|
+
if (addJumpCuts === true && noJumpCutsTyped) {
|
|
1114
|
+
throw new Error("--add-jump-cuts contradicts --no-jump-cuts — pass at most one");
|
|
1115
|
+
}
|
|
1116
|
+
if (addJumpCuts === true) return true;
|
|
1117
|
+
return noJumpCutsTyped ? false : undefined;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The effective jump-cut punch mode from the tri-state flag. "auto" (not
|
|
1122
|
+
* typed) and "force" (--add-jump-cuts) punch identically TODAY — the split
|
|
1123
|
+
* exists so a future config key can turn the default off while a typed
|
|
1124
|
+
* --add-jump-cuts still beats it, resolveWatermark's flag-beats-config
|
|
1125
|
+
* precedence declared before the config side even exists. Pure so the
|
|
1126
|
+
* matrix is testable without a flag parse.
|
|
1127
|
+
*/
|
|
1128
|
+
export type JumpCutsMode = "off" | "auto" | "force";
|
|
1129
|
+
|
|
1130
|
+
export function resolveJumpCuts(flag: boolean | undefined): JumpCutsMode {
|
|
1131
|
+
if (flag === true) return "force";
|
|
1132
|
+
if (flag === false) return "off";
|
|
1133
|
+
return "auto";
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* The punch scale for spans the plan allows — ~1.5%, replacing the legacy 7%
|
|
1138
|
+
* (user decision 2026-08-16, "minimal, ~1%"): the 1.07 punch visibly SLID
|
|
1139
|
+
* screen content sideways at every cut on the incident's screen recording,
|
|
1140
|
+
* and even on a talking head a 7% lurch reads as the camera stumbling. Big
|
|
1141
|
+
* enough to break up the jump, small enough to pass as sensor noise.
|
|
1142
|
+
*/
|
|
1143
|
+
export const FACE_PUNCH_SCALE = 1.015;
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The framing subject at a SOURCE time — which of the plan's segments owns
|
|
1147
|
+
* `srcSec`, with the same edge clamping as scenes' `framingWindowAtOutput`:
|
|
1148
|
+
* a time before the first segment reads as the first, after the last as the
|
|
1149
|
+
* last, so a span whose in-point rounds a hair past a boundary still gets a
|
|
1150
|
+
* segment's verdict rather than a hole. An empty timeline reads as "screen"
|
|
1151
|
+
* — no punch — because with no plan there is no evidence the frame is just
|
|
1152
|
+
* a face, and the guard's failure mode (sliding a screen share) is the
|
|
1153
|
+
* worse of the two. Pure so the lookup is testable against a fixture.
|
|
1154
|
+
*/
|
|
1155
|
+
export function framingSubjectAt(
|
|
1156
|
+
timeline: readonly FramingSegment[],
|
|
1157
|
+
srcSec: number,
|
|
1158
|
+
): "face" | "screen" {
|
|
1159
|
+
if (timeline.length === 0) return "screen";
|
|
1160
|
+
if (srcSec < timeline[0]!.startSec) return timeline[0]!.subject;
|
|
1161
|
+
for (const seg of timeline) {
|
|
1162
|
+
if (srcSec >= seg.startSec && srcSec < seg.endSec) return seg.subject;
|
|
1163
|
+
}
|
|
1164
|
+
return timeline[timeline.length - 1]!.subject;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* The per-span jump-cut punch plan `render-props.punch` carries. THE
|
|
1169
|
+
* FACE-ONLY GUARD HOLDS IN EVERY MODE, "force" included: punching a screen
|
|
1170
|
+
* share slides its content — text visibly drifting is WORSE than the jump
|
|
1171
|
+
* the punch would conceal — so `--add-jump-cuts` overrides a (future)
|
|
1172
|
+
* config-off, never the guard. `spanIsFaceOnly` comes per span from the
|
|
1173
|
+
* framing timeline's subject at the span's source in-point, or from the
|
|
1174
|
+
* global `faceSubject` verdict when no plan exists. Mode "off" still emits
|
|
1175
|
+
* a full all-false mask rather than nothing: an ABSENT `punch` key is the
|
|
1176
|
+
* legacy 1.07-everywhere contract, the opposite of off. Pure so the
|
|
1177
|
+
* mode × subject matrix is testable without a produce run.
|
|
1178
|
+
*/
|
|
1179
|
+
export function punchPlanFor(
|
|
1180
|
+
spans: readonly KeptSpan[],
|
|
1181
|
+
mode: JumpCutsMode,
|
|
1182
|
+
spanIsFaceOnly: readonly boolean[],
|
|
1183
|
+
): { scale: number; allowed: boolean[] } {
|
|
1184
|
+
if (mode === "off") return { scale: 1, allowed: spans.map(() => false) };
|
|
1185
|
+
return {
|
|
1186
|
+
scale: FACE_PUNCH_SCALE,
|
|
1187
|
+
allowed: spans.map((_, i) => spanIsFaceOnly[i] === true),
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* One face-only verdict per kept span, read where that span BEGINS — the
|
|
1193
|
+
* frame at the cut is what any motion driver scales. With a framing plan the
|
|
1194
|
+
* verdict is the plan's per-segment subject at the span's source in-point;
|
|
1195
|
+
* without one every span shares the global `faceSubject` verdict. Hoisted to
|
|
1196
|
+
* ONE mask because TWO motion drivers consume it — the jump-cut punch
|
|
1197
|
+
* (`punchPlanFor.allowed`) and the idle zoom (`buildZoomPlan.allowedClips`,
|
|
1198
|
+
* user decision 2026-08-16: "Face-only. If there's anything else, then no
|
|
1199
|
+
* zoom" — the idle push visibly SLID screen-recording content) — and they
|
|
1200
|
+
* must never disagree about who the subject is: a span the punch holds still
|
|
1201
|
+
* but the idle zoom pushes would slide the very content the guard exists to
|
|
1202
|
+
* protect. Pure so the timeline × subject matrix is testable without a
|
|
1203
|
+
* produce run.
|
|
1204
|
+
*/
|
|
1205
|
+
export function spanFaceMask(
|
|
1206
|
+
spans: readonly KeptSpan[],
|
|
1207
|
+
framingTimeline: readonly FramingSegment[] | null,
|
|
1208
|
+
globalSubject: "face" | "screen",
|
|
1209
|
+
): boolean[] {
|
|
1210
|
+
return spans.map(
|
|
1211
|
+
(sp) =>
|
|
1212
|
+
(framingTimeline ? framingSubjectAt(framingTimeline, sp.srcIn) : globalSubject) === "face",
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* The measurement windows for a MEASURED per-span mask — each kept span's
|
|
1218
|
+
* SOURCE range over the full frame (`cropVf: ""`, the shape
|
|
1219
|
+
* `measureFaceInWindows` takes). This path only runs when no framing plan
|
|
1220
|
+
* exists, i.e. the content rects are uniform, so there is no per-segment
|
|
1221
|
+
* rect to crop to first. Pure so the span→window mapping is testable
|
|
1222
|
+
* without ffmpeg.
|
|
1223
|
+
*/
|
|
1224
|
+
export function spanFaceWindows(
|
|
1225
|
+
spans: readonly KeptSpan[],
|
|
1226
|
+
): Array<{ startSec: number; endSec: number; cropVf: string }> {
|
|
1227
|
+
return spans.map((sp) => ({ startSec: sp.srcIn, endSec: sp.srcOut, cropVf: "" }));
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* `spanFaceMask`'s sibling for the no-plan path, from MEASURED faces
|
|
1232
|
+
* (2026-08-16 v2 review): a screen recording with full-frame webcam
|
|
1233
|
+
* stretches has uniform content rects, so no framing plan exists and the
|
|
1234
|
+
* old fallback let the GLOBAL `faceSubject` verdict — "screen", because the
|
|
1235
|
+
* whole-take median landed on the 0.119 PiP — speak for every span. The
|
|
1236
|
+
* face-only stretches therefore got no punch concealment (raw jump cuts
|
|
1237
|
+
* visible on the face) and no idle zoom. With no plan to supply subjects,
|
|
1238
|
+
* the mask must be measured per span; the verdict rule is core's own
|
|
1239
|
+
* `segmentIsFaceOnly`, the same one the framing plan applies per segment.
|
|
1240
|
+
* `faces` is parallel to the spans that produced the windows. Pure so the
|
|
1241
|
+
* wiring is testable without spawning ffmpeg.
|
|
1242
|
+
*/
|
|
1243
|
+
export function spanFaceMaskFromFaces(faces: ReadonlyArray<WindowFace | null>): boolean[] {
|
|
1244
|
+
return faces.map((f) => segmentIsFaceOnly(f));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Cache key for the measured per-span mask: the spans' SOURCE ranges plus
|
|
1249
|
+
* the source content hash. `measureFaceInWindows` itself does not cache
|
|
1250
|
+
* (its other caller feeds a bake output that is cached downstream), and a
|
|
1251
|
+
* ~55-span take is a few hundred single-frame ffmpeg spawns — too much to
|
|
1252
|
+
* repeat on every warm re-run. Keyed on source ranges so a re-cut
|
|
1253
|
+
* re-measures, and on the source identity so a same-shape cut of a
|
|
1254
|
+
* different take cannot borrow verdicts. Pure so the key's inputs are
|
|
1255
|
+
* pinned by a test.
|
|
1256
|
+
*/
|
|
1257
|
+
export function spanFaceCacheKey(spans: readonly KeptSpan[], sourceHash: string): string {
|
|
1258
|
+
return createHash("sha1")
|
|
1259
|
+
.update(JSON.stringify([sourceHash, spans.map((sp) => [sp.srcIn, sp.srcOut])]))
|
|
1260
|
+
.digest("hex")
|
|
1261
|
+
.slice(0, 12);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
324
1264
|
/**
|
|
325
1265
|
* Whether captions are hidden this run: the flag saying OFF, or the editor's
|
|
326
1266
|
* doc-global `captionsHidden` override saying hidden. An OR, deliberately
|
|
@@ -341,6 +1281,28 @@ export function resolveCaptionsHidden(
|
|
|
341
1281
|
return flag === false || overrideHidden === true;
|
|
342
1282
|
}
|
|
343
1283
|
|
|
1284
|
+
/**
|
|
1285
|
+
* Caption packing per orientation (2026-08-16 v2 review, user screenshot:
|
|
1286
|
+
* "we can actually even have more letters at a time on the screen").
|
|
1287
|
+
* Landscape draws captions at 44px on a 1920px frame against portrait's
|
|
1288
|
+
* 64px on 1080px (`captionFontSizeFor`) — roughly 2.6× the horizontal text
|
|
1289
|
+
* budget (1920/44 ≈ 44 character-widths vs 1080/64 ≈ 17) — so the portrait
|
|
1290
|
+
* default's 3-word lines look sparse there; landscape packs 6 words over
|
|
1291
|
+
* 2.4s, double the core defaults. Portrait returns those defaults VERBATIM
|
|
1292
|
+
* — stated explicitly at the call site rather than changed in captions.ts,
|
|
1293
|
+
* because the core defaults are portrait's contract and its output must
|
|
1294
|
+
* stay byte-identical. Pure so the matrix is testable without a produce
|
|
1295
|
+
* run.
|
|
1296
|
+
*/
|
|
1297
|
+
export function captionPackingFor(landscape: boolean): {
|
|
1298
|
+
maxWordsPerLine: number;
|
|
1299
|
+
maxLineDuration: number;
|
|
1300
|
+
} {
|
|
1301
|
+
return landscape
|
|
1302
|
+
? { maxWordsPerLine: 6, maxLineDuration: 2.4 }
|
|
1303
|
+
: { maxWordsPerLine: 3, maxLineDuration: 1.2 };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
344
1306
|
function sha1File(path: string): Promise<string> {
|
|
345
1307
|
return new Promise((res, rej) => {
|
|
346
1308
|
const h = createHash("sha1");
|
|
@@ -396,7 +1358,34 @@ function deriveWorkdir(
|
|
|
396
1358
|
* file input's equivalent default already lands.
|
|
397
1359
|
*/
|
|
398
1360
|
export function defaultOutPath(originalInput: string): string {
|
|
399
|
-
|
|
1361
|
+
// Delegates to core so the refusal message's suggestion and the actual
|
|
1362
|
+
// default can never drift apart (and both strip the tab-completed trailing
|
|
1363
|
+
// slash — the 2026-08-18 hidden-dotfile-inside-the-folder field case).
|
|
1364
|
+
return ossclipOutputPathFor(originalInput);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The ⚠ line a REPLAYED produce prints when it keys to a different workdir
|
|
1369
|
+
* than the one the editor launched it for (2026-08-18 field cascade, part
|
|
1370
|
+
* 3): the edit server sets OSSCLIP_REPLAY_WORKDIR to the workdir whose
|
|
1371
|
+
* command.json it is replaying; if the run then derives another workdir —
|
|
1372
|
+
* the folder's content changed since the record — the edits saved in the
|
|
1373
|
+
* old workdir's overrides.json silently stop applying, and nothing else in
|
|
1374
|
+
* the run says so. Pure (drift decision in, line out) so the comparison is
|
|
1375
|
+
* testable without spawning a replay; null when this isn't a replay or
|
|
1376
|
+
* nothing drifted.
|
|
1377
|
+
*/
|
|
1378
|
+
export function replayWorkdirWarning(
|
|
1379
|
+
replayedWorkdir: string | undefined,
|
|
1380
|
+
derivedWorkdir: string,
|
|
1381
|
+
): string | null {
|
|
1382
|
+
if (replayedWorkdir === undefined || replayedWorkdir === "") return null;
|
|
1383
|
+
if (resolve(replayedWorkdir) === resolve(derivedWorkdir)) return null;
|
|
1384
|
+
return (
|
|
1385
|
+
`⚠ this run's workdir differs from the one the editor replayed — edits ` +
|
|
1386
|
+
`saved in ${join(replayedWorkdir, "overrides.json")} will NOT apply to ` +
|
|
1387
|
+
`this render (the input's content changed since that command was recorded)`
|
|
1388
|
+
);
|
|
400
1389
|
}
|
|
401
1390
|
|
|
402
1391
|
/**
|
|
@@ -411,9 +1400,12 @@ export function defaultOutPath(originalInput: string): string {
|
|
|
411
1400
|
* directory (a folder run's clips folder, or — the reviewer's pre-existing
|
|
412
1401
|
* "latent" case — a file run's own folder once a mezzanine gets built)
|
|
413
1402
|
* 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
|
-
*
|
|
1403
|
+
* already spent the minutes getting there. The framing bake was the one path
|
|
1404
|
+
* that ever analysed a file other than `input` (always written into `work`);
|
|
1405
|
+
* since framing became render-props (2026-08-16) the caller passes
|
|
1406
|
+
* `inputIsAnalysisInput: true`, and the parameter survives as the contract —
|
|
1407
|
+
* any future non-input analysis file must live in `work` — with the
|
|
1408
|
+
* mezzanine build as the remaining path into `work`.
|
|
417
1409
|
*/
|
|
418
1410
|
export function planRenderPublicDir(p: {
|
|
419
1411
|
input: string;
|
|
@@ -538,6 +1530,33 @@ export function sideImageDestRel(src: string): string {
|
|
|
538
1530
|
*/
|
|
539
1531
|
const PRIMARY_VIDEO_SLOT_AREA = 0.2;
|
|
540
1532
|
|
|
1533
|
+
/**
|
|
1534
|
+
* Every layout's video-slot shape for the producer's framing brief: aspect
|
|
1535
|
+
* in OUTPUT pixels, plus whether the slot is the SUBJECT (see
|
|
1536
|
+
* PRIMARY_VIDEO_SLOT_AREA above) rather than an inset. `frame` must reach
|
|
1537
|
+
* layoutSlots itself, not just the pixel multiply: layoutSlots defaults to
|
|
1538
|
+
* PORTRAIT_FRAME, and the R15 split layouts change GEOMETRY with orientation
|
|
1539
|
+
* — split-left is a {w:1, h:0.5} stack in portrait but a {w:0.5, h:1} side
|
|
1540
|
+
* panel in landscape — so omitting it fed portrait slot fractions times
|
|
1541
|
+
* landscape pixel dims to the brief, marking the wrong layouts UNAVAILABLE
|
|
1542
|
+
* on every 16:9 run (latent since R15 landscape support; surfaced by the
|
|
1543
|
+
* 2026-08-16 incident audit). Pure so both orientations are testable
|
|
1544
|
+
* without an LLM run.
|
|
1545
|
+
*/
|
|
1546
|
+
export function layoutSlotAspects(frame: {
|
|
1547
|
+
width: number;
|
|
1548
|
+
height: number;
|
|
1549
|
+
}): { layout: Layout; slotAspect: number; primary: boolean }[] {
|
|
1550
|
+
return LayoutSchema.options.map((layout) => {
|
|
1551
|
+
const v = layoutSlots(layout, DEFAULT_FACE, [], frame).video;
|
|
1552
|
+
return {
|
|
1553
|
+
layout,
|
|
1554
|
+
slotAspect: (v.rect.w * frame.width) / (v.rect.h * frame.height),
|
|
1555
|
+
primary: v.opacity > 0 && v.rect.w * v.rect.h >= PRIMARY_VIDEO_SLOT_AREA,
|
|
1556
|
+
};
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
541
1560
|
async function preflight(bin: string, hint: string): Promise<void> {
|
|
542
1561
|
try {
|
|
543
1562
|
await run(bin, ["-version"], { allowNonZero: true });
|
|
@@ -564,9 +1583,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
564
1583
|
// video" image lookup both used to read the REASSIGNED `input`, which for a
|
|
565
1584
|
// folder run is a file inside the hidden workdir, not anything the user
|
|
566
1585
|
// would recognise.
|
|
567
|
-
|
|
1586
|
+
// expandHome first (2026-08-16 incident, see paths.ts): a `~/` path that
|
|
1587
|
+
// reaches us unexpanded — the wizard's text prompts, a quoted argv — must
|
|
1588
|
+
// never be resolved against cwd.
|
|
1589
|
+
const originalInput = isAbsolute(inputArg)
|
|
1590
|
+
? inputArg
|
|
1591
|
+
: resolve(baseCwd, expandHome(inputArg));
|
|
568
1592
|
let input = originalInput;
|
|
569
1593
|
if (!existsSync(input)) throw new Error(`input not found: ${input}`);
|
|
1594
|
+
// Decided once, here — the out-path gate below and the folder pipeline
|
|
1595
|
+
// further down must read the same answer to "is this a folder run".
|
|
1596
|
+
const isFolder = statSync(input).isDirectory();
|
|
570
1597
|
|
|
571
1598
|
// §93b: the window is an editorial judgement, and there is deliberately no
|
|
572
1599
|
// heuristic fallback — an automatically-guessed 60 seconds reads as a bug,
|
|
@@ -588,6 +1615,32 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
588
1615
|
throw new Error("--clip-window is recorded by --clip runs for replay — pass --clip too.");
|
|
589
1616
|
}
|
|
590
1617
|
|
|
1618
|
+
// Resolved HERE, not at the render section (2026-08-16 field incident): a
|
|
1619
|
+
// bad out path must fail (or be healed) in the first second, not at the
|
|
1620
|
+
// rename after a 50-minute render — a wizard-typed `~/Downloads/...`
|
|
1621
|
+
// resolved against cwd and the end-of-run rename ENOENT'd because the
|
|
1622
|
+
// parent never existed. mkdir over refusal: the path is the user's explicit
|
|
1623
|
+
// intent and creating a folder is what they'd do by hand; a genuinely
|
|
1624
|
+
// un-creatable path (permissions) still fails loudly, now upfront.
|
|
1625
|
+
const outArg = opts.out !== undefined ? expandHome(opts.out) : undefined;
|
|
1626
|
+
const outPath = outArg
|
|
1627
|
+
? isAbsolute(outArg)
|
|
1628
|
+
? outArg
|
|
1629
|
+
: resolve(baseCwd, outArg)
|
|
1630
|
+
: resolve(defaultOutPath(originalInput));
|
|
1631
|
+
// 2026-08-18 field cascade: an --out pointed INSIDE the input folder became
|
|
1632
|
+
// a 7th source clip on the next run — new content hash, fresh workdir,
|
|
1633
|
+
// EMPTY overrides — so the render silently dropped the user's saved edits
|
|
1634
|
+
// and the output duration doubled, three runs in a row. Refused BEFORE
|
|
1635
|
+
// ensureParentDir so the gate can't first mkdir a stray subfolder inside
|
|
1636
|
+
// the very input it is about to refuse. (Unreachable via the default out —
|
|
1637
|
+
// defaultOutPath lands BESIDE the folder — so only a typed --out can trip
|
|
1638
|
+
// it.)
|
|
1639
|
+
if (isFolder && outPathInsideInput(outPath, originalInput)) {
|
|
1640
|
+
throw new Error(outInsideInputFolderMessage(originalInput));
|
|
1641
|
+
}
|
|
1642
|
+
ensureParentDir(outPath);
|
|
1643
|
+
|
|
591
1644
|
await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
|
|
592
1645
|
await preflight(cfg.ffprobePath, "Run `ossclip setup`, install ffmpeg (provides ffprobe), or set OSSCLIP_FFPROBE.");
|
|
593
1646
|
|
|
@@ -598,6 +1651,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
598
1651
|
|
|
599
1652
|
const tools = { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath };
|
|
600
1653
|
|
|
1654
|
+
// Resolved ONCE for the whole run — whisper biasing, repair vouching and
|
|
1655
|
+
// caption casing must all see the same list, or the passes disagree about
|
|
1656
|
+
// what a term is spelled like. A typed --dictionary wholesale beats the
|
|
1657
|
+
// config (resolveWatermark's typed-beats-config precedence, no merging).
|
|
1658
|
+
const configDictionary = validDictionary(cfg.dictionary);
|
|
1659
|
+
if (opts.dictionary === undefined && cfg.dictionary !== undefined && configDictionary === undefined) {
|
|
1660
|
+
console.log("⚠ config dictionary ignored — expected an array of non-empty strings");
|
|
1661
|
+
}
|
|
1662
|
+
const dictionary = opts.dictionary ?? configDictionary ?? [];
|
|
1663
|
+
if (dictionary.length > 0) console.log(`▸ dictionary: ${dictionary.join(", ")}`);
|
|
1664
|
+
|
|
1665
|
+
// The run's base theme (F6): config theme over defaultTheme, resolved once
|
|
1666
|
+
// and used for BOTH resolveTheme's base and props.baseTheme below — the
|
|
1667
|
+
// editor's reset must land on the user's global colors, not the factory's.
|
|
1668
|
+
const { theme: configBaseTheme, warning: themeWarning } = configuredBaseTheme(cfg.theme);
|
|
1669
|
+
if (themeWarning) console.log(themeWarning);
|
|
1670
|
+
|
|
601
1671
|
// Folder input (folder-input-brief.md, 2026-08-05 field request). The
|
|
602
1672
|
// workdir hash is derived from the folder's CONTENT — `folderManifestKey`
|
|
603
1673
|
// over the enumerated clips — not the folder path. Review fix: a path-only
|
|
@@ -609,7 +1679,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
609
1679
|
// video with captions transcribed against a different edit. Hashing the
|
|
610
1680
|
// manifest content gives a folder input the same invariant a file input
|
|
611
1681
|
// already has via `sha1File`: content changes ⇒ a fresh workdir.
|
|
612
|
-
|
|
1682
|
+
// (`isFolder` itself is decided up top, beside the out-path gate.)
|
|
613
1683
|
// Final-review fix wave, cheap minor c: --sort only means anything for a
|
|
614
1684
|
// folder input; on a file it did nothing, silently. Gated on `sortExplicit`
|
|
615
1685
|
// (whether the user TYPED it) rather than on `opts.sort` itself, since
|
|
@@ -629,9 +1699,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
629
1699
|
} else {
|
|
630
1700
|
hash = (await sha1File(input)).slice(0, 8);
|
|
631
1701
|
}
|
|
632
|
-
|
|
1702
|
+
// expandHome at the call site so deriveWorkdir stays homedir-free.
|
|
1703
|
+
const work = deriveWorkdir(
|
|
1704
|
+
input,
|
|
1705
|
+
hash,
|
|
1706
|
+
opts.workdir !== undefined ? expandHome(opts.workdir) : undefined,
|
|
1707
|
+
landscape,
|
|
1708
|
+
);
|
|
633
1709
|
await mkdir(work, { recursive: true });
|
|
634
1710
|
console.log(`▸ workdir ${work}`);
|
|
1711
|
+
// See replayWorkdirWarning — set only by the edit server's /api/render
|
|
1712
|
+
// spawn, so a terminal run never sees it.
|
|
1713
|
+
const replayWarning = replayWorkdirWarning(process.env.OSSCLIP_REPLAY_WORKDIR, work);
|
|
1714
|
+
if (replayWarning !== null) console.log(replayWarning);
|
|
635
1715
|
|
|
636
1716
|
// §131 residue: a folder re-key (clips renamed/added/removed → new content
|
|
637
1717
|
// hash) correctly lands in a fresh workdir, but any editor edits saved in
|
|
@@ -680,6 +1760,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
680
1760
|
` ${result.nonVideoCount} non-video file${result.nonVideoCount === 1 ? "" : "s"} ignored`,
|
|
681
1761
|
);
|
|
682
1762
|
}
|
|
1763
|
+
// Loud, not folded into the non-video count (2026-08-18 field cascade):
|
|
1764
|
+
// an ossclip output sitting in the clips folder means an earlier run
|
|
1765
|
+
// wrote it there, and the user should learn that before it surprises
|
|
1766
|
+
// them elsewhere. The filter itself is pure (isOssclipOutputName) and
|
|
1767
|
+
// runs inside listFolderVideos, before the workdir hash is derived.
|
|
1768
|
+
if (result.ossclipOutputCount > 0) {
|
|
1769
|
+
console.log(
|
|
1770
|
+
`▸ folder: skipped ${result.ossclipOutputCount} ossclip output ` +
|
|
1771
|
+
`file${result.ossclipOutputCount === 1 ? "" : "s"}`,
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
683
1774
|
// Order visible immediately (folder-input-brief.md) — a wrong order is a
|
|
684
1775
|
// silent bug otherwise, invisible until someone watches the whole thing.
|
|
685
1776
|
result.clips.forEach((c, i) => {
|
|
@@ -756,9 +1847,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
756
1847
|
// the flag exists for — and equally defeated the model A/B the
|
|
757
1848
|
// --whisper-model help text advertises.
|
|
758
1849
|
const transcriptKeyPath = join(work, "transcript-key.json");
|
|
1850
|
+
const requestedModel = opts.whisperModel ?? cfg.model;
|
|
1851
|
+
// Flag > config > the curated table's model-implied language (a non-English
|
|
1852
|
+
// fine-tune without `-l` decodes garbage — Urdu field test 2026-08-05).
|
|
1853
|
+
// Resolved BEFORE the key so a config/model-sourced language re-keys the
|
|
1854
|
+
// cache exactly like the typed flag does.
|
|
1855
|
+
const whisperLang = resolveWhisperLanguage(
|
|
1856
|
+
opts.whisperLanguage,
|
|
1857
|
+
cfg.language,
|
|
1858
|
+
modelImpliedLanguage(requestedModel),
|
|
1859
|
+
);
|
|
1860
|
+
if (whisperLang.warning) console.log(whisperLang.warning);
|
|
1861
|
+
if (whisperLang.source === "config" || whisperLang.source === "model") {
|
|
1862
|
+
console.log(
|
|
1863
|
+
`▸ whisper language: ${whisperLang.language} ` +
|
|
1864
|
+
`(from ${whisperLang.source === "config" ? "config" : `model ${requestedModel}`}; ` +
|
|
1865
|
+
`--whisper-language overrides)`,
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
759
1868
|
const requestedKey: TranscriptKey = {
|
|
760
|
-
model:
|
|
761
|
-
...(
|
|
1869
|
+
model: requestedModel,
|
|
1870
|
+
...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
|
|
1871
|
+
// Omitted when empty, not written as [] — pre-dictionary key files have
|
|
1872
|
+
// no `dictionary` at all, and transcriptCacheReusable reads absent and
|
|
1873
|
+
// empty as the same "no biasing", so old workdirs must not re-transcribe.
|
|
1874
|
+
...(dictionary.length > 0 ? { dictionary } : {}),
|
|
762
1875
|
};
|
|
763
1876
|
let cacheVerdict: ReturnType<typeof transcriptCacheReusable> | null = null;
|
|
764
1877
|
if (!opts.transcript && existsSync(transcriptCache)) {
|
|
@@ -768,7 +1881,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
768
1881
|
cacheVerdict = transcriptCacheReusable(recorded, requestedKey, cfg.model);
|
|
769
1882
|
}
|
|
770
1883
|
if (opts.transcript) {
|
|
771
|
-
|
|
1884
|
+
// expandHome before resolve — same 2026-08-16 rule as --out (paths.ts).
|
|
1885
|
+
transcript = TranscriptSchema.parse(
|
|
1886
|
+
JSON.parse(await readFile(resolve(expandHome(opts.transcript)), "utf8")),
|
|
1887
|
+
);
|
|
772
1888
|
console.log(`▸ transcript injected from ${opts.transcript} (${transcript.words.length} words)`);
|
|
773
1889
|
// An injected transcript came from no whisper run at all, so any key left
|
|
774
1890
|
// by an earlier one would mislabel the cache this branch overwrites below.
|
|
@@ -791,12 +1907,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
791
1907
|
"Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
|
|
792
1908
|
);
|
|
793
1909
|
const model = requestedKey.model;
|
|
794
|
-
|
|
1910
|
+
// whisperModelPath/modelUrl are THE resolution and URL sources (shared
|
|
1911
|
+
// with doctor and setup) — this error used to hold its own copy of the
|
|
1912
|
+
// ggerganov URL, which 404'd for curated/custom names and the suggested
|
|
1913
|
+
// `curl -L` then saved the 404 HTML as a fake model.
|
|
1914
|
+
const modelPath = whisperModelPath(model, cfg.modelDir);
|
|
795
1915
|
if (!existsSync(modelPath)) {
|
|
796
1916
|
throw new Error(
|
|
797
1917
|
`whisper model not found at ${modelPath}.\n` +
|
|
798
1918
|
`Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
|
|
799
|
-
` curl -L -o ${modelPath}
|
|
1919
|
+
` curl -L -o ${modelPath} ${modelUrl(model, validModelSources(cfg.modelSources))}`,
|
|
800
1920
|
);
|
|
801
1921
|
}
|
|
802
1922
|
const whisperAnim = isInteractive()
|
|
@@ -813,7 +1933,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
813
1933
|
whisperPath: cfg.whisperPath,
|
|
814
1934
|
modelPath,
|
|
815
1935
|
outBase: join(work, "whisper"),
|
|
816
|
-
language
|
|
1936
|
+
// The RESOLVED language, not the raw flag — a config/model-implied
|
|
1937
|
+
// code must reach the spawn exactly as it reached the cache key.
|
|
1938
|
+
language: requestedKey.language,
|
|
1939
|
+
// Vocabulary biasing (F4) — undefined for an empty dictionary, so
|
|
1940
|
+
// the spawned args stay byte-identical to every pre-dictionary run.
|
|
1941
|
+
prompt: whisperPromptFor(dictionary),
|
|
817
1942
|
},
|
|
818
1943
|
audioPath,
|
|
819
1944
|
),
|
|
@@ -849,7 +1974,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
849
1974
|
// repair — the repair pass reads a bare "blooper." as an oddity and has
|
|
850
1975
|
// already been observed proposing "break loop." for it. Detecting first
|
|
851
1976
|
// means the marker cannot be rewritten out from under the detector.
|
|
852
|
-
|
|
1977
|
+
// `silences` rides along so the cut can extend through the marker's own
|
|
1978
|
+
// trailing dead air — §18 stamp-stretch put the stamped end of a spoken
|
|
1979
|
+
// "blooper." 0.4s before its acoustic end (2026-08-16 incident, see
|
|
1980
|
+
// MAX_MARKER_BLEED_SEC in blooper.ts).
|
|
1981
|
+
let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker, silences) : [];
|
|
853
1982
|
if (opts.blooperMarker) {
|
|
854
1983
|
console.log(
|
|
855
1984
|
bloops.length > 0
|
|
@@ -861,24 +1990,30 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
861
1990
|
// Deterministic retake collapse (R27 §128) — the flub the speaker did NOT
|
|
862
1991
|
// mark. Same RAW-transcript-before-repair ordering as the blooper marker
|
|
863
1992
|
// above and for the same reason: repair reading a stray restart as an
|
|
864
|
-
// oddity would rewrite the exact pattern this looks for.
|
|
865
|
-
|
|
1993
|
+
// oddity would rewrite the exact pattern this looks for. Gated on the
|
|
1994
|
+
// marker, not --collapse-retakes (inferredRetakesEnabled has the user's
|
|
1995
|
+
// verbatim rule); the legacy flag typed alone earns a notice, not silence.
|
|
1996
|
+
const retakesEnabled = inferredRetakesEnabled(opts.blooperMarker);
|
|
1997
|
+
if (opts.collapseRetakes && !retakesEnabled) {
|
|
1998
|
+
console.log("▸ collapse-retakes: skipped — retake detection runs only with --blooper-marker");
|
|
1999
|
+
}
|
|
2000
|
+
let retakeGroups = retakesEnabled
|
|
866
2001
|
? findRetakeGroups(transcript, analysis, { transparentMarker: opts.blooperMarker })
|
|
867
2002
|
: [];
|
|
868
|
-
let retakes = retakeGroups
|
|
869
|
-
if (
|
|
2003
|
+
let retakes = retakeCutsFor(retakeGroups);
|
|
2004
|
+
if (retakesEnabled) {
|
|
870
2005
|
// `exact` never cuts anything — buildCutlist's own early return collapses
|
|
871
2006
|
// to one whole-duration `keep` regardless of what's in `retakes` — so
|
|
872
2007
|
// "N group(s), M take(s) cut" here was a claim the run never honored.
|
|
873
2008
|
// Same fact `valveFired` below already checks; gated the same way
|
|
874
2009
|
// (final-review fix wave, cheap minor b).
|
|
875
2010
|
if (opts.cleanup === "exact") {
|
|
876
|
-
console.log("▸
|
|
2011
|
+
console.log("▸ retakes: --cleanup exact wins — nothing cut");
|
|
877
2012
|
} else {
|
|
878
2013
|
console.log(
|
|
879
2014
|
retakeGroups.length > 0
|
|
880
|
-
? `▸
|
|
881
|
-
: "▸
|
|
2015
|
+
? `▸ retakes: ${retakeGroups.length} group(s), ${retakes.length} take(s) cut`
|
|
2016
|
+
: "▸ retakes: none found",
|
|
882
2017
|
);
|
|
883
2018
|
for (const g of retakeGroups) {
|
|
884
2019
|
for (const line of formatRetakeGroup(transcript, g).split("\n")) console.log(` ▸ ${line}`);
|
|
@@ -910,7 +2045,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
910
2045
|
const valveFired = opts.cleanup !== "exact" && cutlist.length === 1 && cutlist[0]!.kind === "keep";
|
|
911
2046
|
if (retakes.length > 0 && valveFired) {
|
|
912
2047
|
console.log(
|
|
913
|
-
" ⚠ collapse
|
|
2048
|
+
" ⚠ retake collapse found a retake, but the sanity valve reset the whole cutlist — nothing was cut",
|
|
914
2049
|
);
|
|
915
2050
|
}
|
|
916
2051
|
|
|
@@ -922,7 +2057,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
922
2057
|
// mishearing can't reach the screen twice in two different spellings.
|
|
923
2058
|
const providerName = opts.provider ?? defaultProviderName(process.env, binOnPath);
|
|
924
2059
|
let provider: LlmProvider | null = null;
|
|
925
|
-
|
|
2060
|
+
// --youtube brings its own provider (field gap, 2026-08-16): the user's
|
|
2061
|
+
// real command was `--youtube --llm antigravity` WITHOUT --produce, and the
|
|
2062
|
+
// pack skipped with "needs an LLM provider" — a flag that exists to call an
|
|
2063
|
+
// LLM must count as opting into one. This also turns transcript repair on
|
|
2064
|
+
// for such runs, which is the dictionary's caption-side fix ("Jason" →
|
|
2065
|
+
// "JSON") — biasing whisper alone does not correct what ASR already heard.
|
|
2066
|
+
const needsLlm = opts.produce === true || resolveYoutube(opts.youtube, cfg.youtube);
|
|
926
2067
|
if (needsLlm) {
|
|
927
2068
|
// Only when auto-detected: a typed --llm needs no explanation. The line
|
|
928
2069
|
// itself lives in llm-detect.ts so a drift test covers every provider —
|
|
@@ -947,6 +2088,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
947
2088
|
opts.llmModel,
|
|
948
2089
|
opts.llmFastModel ?? cfg.fastModel,
|
|
949
2090
|
opts.speaker ?? cfg.speaker,
|
|
2091
|
+
// The dictionary changes both the prompt and the vouched set (F4),
|
|
2092
|
+
// so cached repairs from a different vocabulary must not be reused.
|
|
2093
|
+
dictionary,
|
|
950
2094
|
rawTranscript.words.map((w) => w.text),
|
|
951
2095
|
]),
|
|
952
2096
|
)
|
|
@@ -954,12 +2098,37 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
954
2098
|
.slice(0, 8);
|
|
955
2099
|
const repairCache = join(work, `repairs-${rawKey}.json`);
|
|
956
2100
|
if (existsSync(repairCache)) {
|
|
957
|
-
|
|
958
|
-
|
|
2101
|
+
const cached = JSON.parse(await readFile(repairCache, "utf8")) as AppliedRepair[];
|
|
2102
|
+
// Re-DECIDE from the cached PROPOSALS; never replay the stored verdicts
|
|
2103
|
+
// (field case 2026-08-18). What this cache exists to avoid is the LLM
|
|
2104
|
+
// CALL — the gates are code, and code gets fixed. Filtering to
|
|
2105
|
+
// `r.applied` here meant a gate fix could never reach a workdir that had
|
|
2106
|
+
// already cached a refusal: the Urdu run whose 11 correct repairs stayed
|
|
2107
|
+
// refused after the Latin-only `norm` was fixed, because produce replayed
|
|
2108
|
+
// the old verdicts instead of recomputing them. The vouched set rides
|
|
2109
|
+
// along for the same reason it always did — a dictionary-vouched
|
|
2110
|
+
// correction must clear the phonetic gate on replay too.
|
|
2111
|
+
const decided = applyRepairs(
|
|
959
2112
|
rawTranscript,
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
2113
|
+
cached.map(({ startWord, endWord, heard, correction }) => ({
|
|
2114
|
+
startWord,
|
|
2115
|
+
endWord,
|
|
2116
|
+
heard,
|
|
2117
|
+
correction,
|
|
2118
|
+
})),
|
|
2119
|
+
{ dictionary },
|
|
2120
|
+
);
|
|
2121
|
+
repairs = decided.applied;
|
|
2122
|
+
transcript = decided.transcript;
|
|
2123
|
+
const now = repairs.filter((r) => r.applied).length;
|
|
2124
|
+
const before = cached.filter((r) => r.applied).length;
|
|
2125
|
+
// Re-decided verdicts are the truth from here on: persist them so the
|
|
2126
|
+
// report, the next run and this run cannot disagree about what applied.
|
|
2127
|
+
if (now !== before) await writeFile(repairCache, JSON.stringify(repairs, null, 2));
|
|
2128
|
+
console.log(
|
|
2129
|
+
`▸ repairs cached (${now} applied of ${cached.length} proposed` +
|
|
2130
|
+
`${now === before ? "" : `, re-decided from ${before}`})`,
|
|
2131
|
+
);
|
|
963
2132
|
} else {
|
|
964
2133
|
const repairAnim = isInteractive()
|
|
965
2134
|
? new StageAnimator(
|
|
@@ -971,6 +2140,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
971
2140
|
const result = await phases.time("llm", () =>
|
|
972
2141
|
repairTranscript(provider!, rawTranscript, {
|
|
973
2142
|
speaker: opts.speaker ?? cfg.speaker,
|
|
2143
|
+
// Vouched terms (F4): named in the prompt AND exempt, when a
|
|
2144
|
+
// correction is built entirely of them, from the phonetic gate.
|
|
2145
|
+
dictionary,
|
|
974
2146
|
// A repair may not merge words across a cut.
|
|
975
2147
|
isCut: (startSec, endSec) =>
|
|
976
2148
|
cutlist.some(
|
|
@@ -1009,12 +2181,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1009
2181
|
// ---- Framing measurement (PLAN Tasks A+B) --------------------------------
|
|
1010
2182
|
/**
|
|
1011
2183
|
* Mixed framing (option (a), decided with the author 2026-07-28): a source
|
|
1012
|
-
* that alternates framings
|
|
1013
|
-
*
|
|
1014
|
-
* measured face
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
*
|
|
2184
|
+
* that alternates framings gets ONE field of view — every segment windowed
|
|
2185
|
+
* to the tightest framing the take ever shows, placed on that segment's own
|
|
2186
|
+
* measured face. The PLAN is computed here, before the producer, because
|
|
2187
|
+
* the producer needs the framing brief: which word ranges are close shots,
|
|
2188
|
+
* and which layouts those rule out. The plan used to be BAKED into a
|
|
2189
|
+
* re-encoded file after the scenes existed; since 2026-08-16 it is emitted
|
|
2190
|
+
* as `framingTimeline` render-props instead (see the props assembly below).
|
|
1018
2191
|
*/
|
|
1019
2192
|
let framingPlan: NormalizePlan | null = null;
|
|
1020
2193
|
if (!detection.uniform) {
|
|
@@ -1071,9 +2244,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1071
2244
|
* `production.json`, the report, and the command.json pin below. */
|
|
1072
2245
|
let clipWindow: ClipWindow | null = null;
|
|
1073
2246
|
if (opts.scenes) {
|
|
1074
|
-
|
|
2247
|
+
// expandHome before resolve — same 2026-08-16 rule as --out (paths.ts).
|
|
2248
|
+
scenes = z.array(SceneSchema).parse(
|
|
2249
|
+
JSON.parse(await readFile(resolve(expandHome(opts.scenes)), "utf8")),
|
|
2250
|
+
);
|
|
1075
2251
|
console.log(`▸ scenes injected from ${opts.scenes} (${scenes.length})`);
|
|
1076
|
-
} else if (provider) {
|
|
2252
|
+
} else if (provider && opts.produce === true) {
|
|
2253
|
+
// `opts.produce`, not bare `provider` (2026-08-16): --youtube now brings
|
|
2254
|
+
// a provider for its metadata/repair, and the bare-provider gate silently
|
|
2255
|
+
// turned GRAPHICS on for a run that never asked for them — 12 surprise
|
|
2256
|
+
// scenes on a plain-cut video. A provider is a capability; --produce is
|
|
2257
|
+
// the consent.
|
|
1077
2258
|
// Keyed on the repaired transcript's TEXT, not its word count: a repair
|
|
1078
2259
|
// that swaps "coach and" for "code churn" leaves the count identical, and
|
|
1079
2260
|
// a count-keyed cache would silently replan from the stale wording.
|
|
@@ -1090,14 +2271,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1090
2271
|
faceFracOfCanvas: framingPlan.faceFracOfCanvas[i] ?? 0,
|
|
1091
2272
|
})),
|
|
1092
2273
|
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
|
-
}),
|
|
2274
|
+
layouts: layoutSlotAspects(frame),
|
|
1101
2275
|
zoom: ZOOM_MAX_SCALE,
|
|
1102
2276
|
}
|
|
1103
2277
|
: undefined;
|
|
@@ -1166,11 +2340,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1166
2340
|
analysis = analyze(rawTranscript, silences, sourceProbe.duration, levels);
|
|
1167
2341
|
// Re-detect on the SLICE: word indices moved, so the spans found against
|
|
1168
2342
|
// the full take no longer address the same words.
|
|
1169
|
-
|
|
1170
|
-
|
|
2343
|
+
// Full-source `silences` on a sliced transcript is correct on purpose:
|
|
2344
|
+
// sliceRawTranscript keeps SOURCE seconds on every word stamp, so the
|
|
2345
|
+
// bleed extension compares like with like.
|
|
2346
|
+
bloops = opts.blooperMarker
|
|
2347
|
+
? findBloopSpans(rawTranscript, opts.blooperMarker, silences)
|
|
2348
|
+
: [];
|
|
2349
|
+
retakeGroups = retakesEnabled
|
|
1171
2350
|
? findRetakeGroups(rawTranscript, analysis, { transparentMarker: opts.blooperMarker })
|
|
1172
2351
|
: [];
|
|
1173
|
-
retakes = retakeGroups
|
|
2352
|
+
retakes = retakeCutsFor(retakeGroups);
|
|
1174
2353
|
cutlist = boundCutlistToWindow(
|
|
1175
2354
|
buildCutlist({
|
|
1176
2355
|
transcript: rawTranscript,
|
|
@@ -1358,6 +2537,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1358
2537
|
}
|
|
1359
2538
|
}
|
|
1360
2539
|
|
|
2540
|
+
// Deterministic dictionary casing (F4) — LAST word edit before the caption
|
|
2541
|
+
// build: after the repair reassignment and reconcileCopy above, so nothing
|
|
2542
|
+
// can lower-case a term back after this. Exact-token matches only ("json."
|
|
2543
|
+
// → "JSON."; "Jason" stays — phonetic judgement is the repair pass's job,
|
|
2544
|
+
// see dictionary.ts). `rawTranscript` stays untouched on purpose:
|
|
2545
|
+
// production.json stores the RAW words that `analysis`/`cutlist` index.
|
|
2546
|
+
transcript = canonicalizeDictionaryCasing(transcript, dictionary);
|
|
2547
|
+
|
|
1361
2548
|
// Landscape keeps the frame whole (R15): the split-screen layouts are
|
|
1362
2549
|
// vertical-format answers, and applying them to 16:9 crops the picture into
|
|
1363
2550
|
// a letterbox for no gain. Remapped here — before assembly — so cues,
|
|
@@ -1469,48 +2656,69 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1469
2656
|
// beside `render-props.json`'s own write (see the comment there for why).
|
|
1470
2657
|
for (const r of cutResult.reports) console.log(` ⚠ ${r}`);
|
|
1471
2658
|
|
|
2659
|
+
// Retire hides whose words this run's FINAL cutlist removes (§59b
|
|
2660
|
+
// revisited): the "captions + video" delete writes a hide (instant
|
|
2661
|
+
// preview) plus a cut (this run), and once the cut lands the cut
|
|
2662
|
+
// supersedes the hide — see `pruneHidesInsideCuts`. Pruned HERE, before
|
|
2663
|
+
// `reconcileCaptionEdits` applies the hide layer, so the retired keys
|
|
2664
|
+
// never surface as "the cut removed it" drop lines on this or any later
|
|
2665
|
+
// run. The doc write itself goes through the one sanctioned overrides.json
|
|
2666
|
+
// write further down, gated alongside `cutResult.changed`.
|
|
2667
|
+
const hidePrune = pruneHidesInsideCuts(overrideDoc, cutlist);
|
|
2668
|
+
overrideDoc = hidePrune.doc;
|
|
2669
|
+
const hidesPruned = hidePrune.pruned.length > 0;
|
|
2670
|
+
if (hidesPruned) {
|
|
2671
|
+
console.log(
|
|
2672
|
+
`▸ captions: ${hidePrune.pruned.length} hidden-word override(s) retired — their words are cut`,
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
|
|
1472
2676
|
const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
|
|
1473
2677
|
for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
|
|
1474
2678
|
|
|
1475
|
-
// ---- Framing
|
|
1476
|
-
// The
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
2679
|
+
// ---- Framing plan → props (2026-08-16 incident) --------------------------
|
|
2680
|
+
// The plan used to be BAKED here: every window cropped, scaled and
|
|
2681
|
+
// re-encoded into a content-<hash>.mp4 that replaced the source for the
|
|
2682
|
+
// whole rest of the pipeline. That was irreversible — a bad crop's only
|
|
2683
|
+
// remedy was deleting the baked file — and invisible: the bake path also
|
|
2684
|
+
// suppressed the `sourceSize` keys in render-props, so the editor could
|
|
2685
|
+
// not even see the crop it was fighting. The plan now travels as DATA
|
|
2686
|
+
// (`framingTimeline`, emitted with the props below) and the renderer
|
|
2687
|
+
// applies each window as a transform the editor can see and counteract.
|
|
2688
|
+
// Old workdirs' baked content-*.mp4 stay on disk, inert: their own
|
|
2689
|
+
// render-props reference them by name and must keep rendering.
|
|
1484
2690
|
let fitFallback = false;
|
|
1485
2691
|
if (framingPlan) {
|
|
1486
2692
|
const plan = framingPlan;
|
|
1487
2693
|
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);
|
|
2694
|
+
console.log(
|
|
2695
|
+
`▸ framing: ${plan.segments.length} windows rendered from props (no re-encode)`,
|
|
2696
|
+
);
|
|
1503
2697
|
} else {
|
|
1504
2698
|
fitFallback = true;
|
|
2699
|
+
// A refusal names the number that tripped the gate. The upscale bound
|
|
2700
|
+
// is softness; the discard bound is picture loss — the 2026-08-16
|
|
2701
|
+
// incident's plan discarded 37% of the frame area and the old log
|
|
2702
|
+
// (upscale-only) never said so. The per-segment screen-loss bound has
|
|
2703
|
+
// no single headline number, so it reads as the residual case.
|
|
2704
|
+
const why = [
|
|
2705
|
+
...(plan.coverUpscale > MAX_NORMALIZE_UPSCALE
|
|
2706
|
+
? [`would upscale ×${plan.coverUpscale.toFixed(2)} > ${MAX_NORMALIZE_UPSCALE}`]
|
|
2707
|
+
: []),
|
|
2708
|
+
...(plan.areaDiscardWeighted > MAX_MEAN_AREA_DISCARD
|
|
2709
|
+
? [`would discard ${(plan.areaDiscardWeighted * 100).toFixed(0)}% of the picture`]
|
|
2710
|
+
: []),
|
|
2711
|
+
];
|
|
1505
2712
|
console.log(
|
|
1506
|
-
` ⚠ strip too small to unify (
|
|
1507
|
-
|
|
2713
|
+
` ⚠ strip too small to unify (${
|
|
2714
|
+
why.length > 0 ? why.join("; ") : "a screen segment would lose its content"
|
|
2715
|
+
}) — letterboxed stretches render FITTED at natural size; ` +
|
|
1508
2716
|
`framing will visibly change at ${contentTimeline.length - 1} boundaries`,
|
|
1509
2717
|
);
|
|
1510
2718
|
}
|
|
1511
2719
|
}
|
|
1512
2720
|
const contentRect: ContentRect = detection.uniform ?? {
|
|
1513
|
-
x: 0, y: 0, w:
|
|
2721
|
+
x: 0, y: 0, w: sourceProbe.width, h: sourceProbe.height, full: true,
|
|
1514
2722
|
};
|
|
1515
2723
|
/** The picture's dimensions — what every geometric consumer reasons about. */
|
|
1516
2724
|
const content = { width: contentRect.w, height: contentRect.h };
|
|
@@ -1519,7 +2727,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1519
2727
|
// not two independent copies of the same condition that could silently
|
|
1520
2728
|
// drift apart (Finding 3, final-review fix wave: that drift is exactly
|
|
1521
2729
|
// what let an accepted image 404 inside Remotion's staticFile()).
|
|
1522
|
-
|
|
2730
|
+
// The old `analysisInput === input` term is gone WITH the bake: the bake
|
|
2731
|
+
// was the only thing that ever pointed analysis at a different file, so
|
|
2732
|
+
// with framing as props the analysis input IS the source, always.
|
|
2733
|
+
const mezzanineWillBuild = opts.mezzanine || !contentRect.full;
|
|
1523
2734
|
|
|
1524
2735
|
// Face measurement (FINDINGS §13): one static crop offset per source,
|
|
1525
2736
|
// measured rather than guessed; cached in the workdir like the transcript.
|
|
@@ -1531,10 +2742,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1531
2742
|
"render",
|
|
1532
2743
|
).start()
|
|
1533
2744
|
: null;
|
|
1534
|
-
const faceBox = await measureFace(tools,
|
|
2745
|
+
const faceBox = await measureFace(tools, input, sourceProbe.duration, {
|
|
1535
2746
|
cacheDir: work,
|
|
1536
|
-
cropVf
|
|
1537
|
-
cacheTag,
|
|
2747
|
+
cropVf,
|
|
1538
2748
|
samples: faceSamples,
|
|
1539
2749
|
});
|
|
1540
2750
|
if (faceSampleAnim) faceSampleAnim.stop();
|
|
@@ -1550,21 +2760,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1550
2760
|
"the crop may be wrong, check the output",
|
|
1551
2761
|
);
|
|
1552
2762
|
|
|
1553
|
-
//
|
|
1554
|
-
// much is arithmetic, not opinion
|
|
1555
|
-
//
|
|
1556
|
-
//
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
2763
|
+
// Whatever the source's aspect doesn't share with the frame, a cover crop
|
|
2764
|
+
// trims — and how much is arithmetic, not opinion. Said out loud because
|
|
2765
|
+
// the result LOOKS deliberate — a tight talking head — and nothing else in
|
|
2766
|
+
// the run would mention that the desk, the screen and the second person
|
|
2767
|
+
// are simply gone. Orientation-neutral on purpose: the old `!landscape`
|
|
2768
|
+
// gate assumed a 16:9 output never crops a 16:9-ish source, and the
|
|
2769
|
+
// 2026-08-16 incident (a 1.547:1 screen recording in a 16:9 frame — 13% of
|
|
2770
|
+
// the height silently gone, 28% post-normalization) shipped without a word.
|
|
2771
|
+
const coverKeep = coverKeepFraction(content, frame);
|
|
2772
|
+
if (opts.sourceFit !== "contain" && coverKeep && coverKeep.kept < 0.95) {
|
|
2773
|
+
console.log(
|
|
2774
|
+
`▸ source is ${(content.width / content.height).toFixed(2)}:1 — a full-frame crop keeps ` +
|
|
2775
|
+
`${(coverKeep.kept * 100).toFixed(0)}% of its ${coverKeep.axis}. ` +
|
|
2776
|
+
"Use --source-fit contain to show the whole frame instead.",
|
|
2777
|
+
);
|
|
1568
2778
|
}
|
|
1569
2779
|
|
|
1570
2780
|
// ---- Route around the source's own burned-in text (FINDINGS §26) --------
|
|
@@ -1582,11 +2792,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1582
2792
|
// face. Routing around a hazard only pays when there is a hazard, and only
|
|
1583
2793
|
// the user knows whether their source is already edited.
|
|
1584
2794
|
const sourceText = opts.sourceIsEdited
|
|
1585
|
-
? await scanSourceText(tools,
|
|
2795
|
+
? await scanSourceText(tools, input, sourceProbe.duration, {
|
|
1586
2796
|
cacheDir: work,
|
|
1587
2797
|
assumeEdited: true,
|
|
1588
|
-
cropVf
|
|
1589
|
-
cacheTag,
|
|
2798
|
+
cropVf,
|
|
1590
2799
|
})
|
|
1591
2800
|
: { regions: [], assumed: false, framesSampled: 0 };
|
|
1592
2801
|
if (sourceText.regions.length > 0) {
|
|
@@ -1666,7 +2875,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1666
2875
|
if (hiddenIds.length > 0) {
|
|
1667
2876
|
console.log(`▸ ${hiddenIds.length} scene(s) hidden by the edit layer: ${hiddenIds.join(", ")}`);
|
|
1668
2877
|
}
|
|
1669
|
-
|
|
2878
|
+
// Config theme as the BASE (F6): overrides.json > config theme >
|
|
2879
|
+
// defaultTheme. The same `configBaseTheme` feeds props.baseTheme below.
|
|
2880
|
+
const theme = resolveTheme(configBaseTheme, overrideDoc);
|
|
1670
2881
|
|
|
1671
2882
|
// A pin freezes a scene's ABSOLUTE time against whatever its neighbours'
|
|
1672
2883
|
// timing was when it was set. This same plan may since have re-anchored
|
|
@@ -1825,7 +3036,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1825
3036
|
const sideDirs = isFolder ? [work, originalInput] : [work, dirname(input)];
|
|
1826
3037
|
const renderPublicDirPath = planRenderPublicDir({
|
|
1827
3038
|
input,
|
|
1828
|
-
|
|
3039
|
+
// Literal since the framing bake became render-props (2026-08-16): the
|
|
3040
|
+
// bake was the only path that ever analysed a file other than the input.
|
|
3041
|
+
// The parameter (and its platform matrix test) stays, because it encodes
|
|
3042
|
+
// the contract "a non-input analysis file must live in `work`" — the
|
|
3043
|
+
// thing any future re-introduction of such a file has to get right.
|
|
3044
|
+
inputIsAnalysisInput: true,
|
|
1829
3045
|
mezzanineWillBuild,
|
|
1830
3046
|
work,
|
|
1831
3047
|
});
|
|
@@ -1952,11 +3168,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
1952
3168
|
// §128: same reasoning as §122's block above, for the flub the speaker did
|
|
1953
3169
|
// NOT say a marker over — kept / cut (with similarity) / ignored as a
|
|
1954
3170
|
// hallucination (with its silence fraction), in the words `report.txt`
|
|
1955
|
-
// already trusts.
|
|
1956
|
-
//
|
|
3171
|
+
// already trusts. Runs automatically with --blooper-marker (2026-08-16
|
|
3172
|
+
// gate decision, inferredRetakesEnabled); a clean run recorded here is
|
|
3173
|
+
// still the promotion evidence the §128 appendix asks for.
|
|
1957
3174
|
if (retakeGroups.length > 0) {
|
|
1958
3175
|
report +=
|
|
1959
|
-
"\nretakes collapsed (--
|
|
3176
|
+
"\nretakes collapsed (runs with --blooper-marker — FINDINGS §128):\n" +
|
|
1960
3177
|
retakeGroups.map((g) => formatRetakeGroup(rawTranscript, g)).join("\n") +
|
|
1961
3178
|
"\n";
|
|
1962
3179
|
}
|
|
@@ -2030,6 +3247,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2030
3247
|
// the fill's derived boundaries re-split caption lines would change
|
|
2031
3248
|
// caption output for zero visual reason (PLAN Task A4.4).
|
|
2032
3249
|
breakpoints: graphicCues.flatMap((c) => [c.startSec, c.endSec]),
|
|
3250
|
+
// Orientation-dependent packing (captionPackingFor has the budget math):
|
|
3251
|
+
// portrait gets the core defaults verbatim, landscape doubles them.
|
|
3252
|
+
...captionPackingFor(landscape),
|
|
2033
3253
|
});
|
|
2034
3254
|
// §137 (Task 6 review, Critical 1): the caption half of a run — migrate the
|
|
2035
3255
|
// doc's keys, apply what applies, and account for the rest — is one pure
|
|
@@ -2062,21 +3282,111 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2062
3282
|
const captionKeysReanchored = captionWork.reanchored;
|
|
2063
3283
|
for (const line of captionWork.log) console.log(line);
|
|
2064
3284
|
|
|
3285
|
+
// Const re-binding of the accepted plan so its narrowing survives into the
|
|
3286
|
+
// `framingTimeline` map closure below — `framingPlan` is a `let`, and TS
|
|
3287
|
+
// drops a `let`'s narrowing inside callbacks.
|
|
3288
|
+
const acceptedFramingPlan = framingPlan?.ok ? framingPlan : null;
|
|
3289
|
+
// Hoisted out of the props spread because TWO consumers need it: the
|
|
3290
|
+
// render-props emission below and the per-span subject mask both motion
|
|
3291
|
+
// drivers gate on. Skipped under `--source-fit contain` — contain shows the
|
|
3292
|
+
// WHOLE frame, and a framing plan's cover windows would fight it — so the
|
|
3293
|
+
// subject gate reads the same timeline the renderer will actually see.
|
|
3294
|
+
const framingTimeline: FramingSegment[] | null =
|
|
3295
|
+
acceptedFramingPlan && opts.sourceFit !== "contain"
|
|
3296
|
+
? acceptedFramingPlan.segments.map(
|
|
3297
|
+
(s, i): FramingSegment => ({
|
|
3298
|
+
startSec: s.startSec,
|
|
3299
|
+
endSec: s.endSec,
|
|
3300
|
+
window: s.window,
|
|
3301
|
+
subject: acceptedFramingPlan.subject[i] ?? "screen",
|
|
3302
|
+
bias: acceptedFramingPlan.bias[i] ?? { x: 0.5, y: 0.5 },
|
|
3303
|
+
}),
|
|
3304
|
+
)
|
|
3305
|
+
: null;
|
|
3306
|
+
const jumpCutsMode = resolveJumpCuts(opts.jumpCuts);
|
|
3307
|
+
const globalSubject = faceSubject(faceBox);
|
|
3308
|
+
// ONE verdict array feeds BOTH motion drivers (spanFaceMask has the why) —
|
|
3309
|
+
// computed before either plan so neither can be built from a stale or
|
|
3310
|
+
// re-derived copy that disagrees with the other.
|
|
3311
|
+
//
|
|
3312
|
+
// 2026-08-16 v2 review: with NO framing plan (uniform content rects) the
|
|
3313
|
+
// old flat `spanFaceMask(…, null, globalSubject)` let the whole-take PiP
|
|
3314
|
+
// verdict speak for the full-frame face stretches inside a screen
|
|
3315
|
+
// recording, so those spans lost punch concealment and idle zoom — the
|
|
3316
|
+
// mask must be MEASURED per span instead (spanFaceMaskFromFaces has the
|
|
3317
|
+
// full why). Cached: `measureFaceInWindows` itself does not cache, and a
|
|
3318
|
+
// ~55-span take is a few hundred single-frame ffmpeg spawns.
|
|
3319
|
+
let spanIsFaceOnly: boolean[];
|
|
3320
|
+
if (framingTimeline) {
|
|
3321
|
+
spanIsFaceOnly = spanFaceMask(map.spans, framingTimeline, globalSubject);
|
|
3322
|
+
} else {
|
|
3323
|
+
const spanFaceCache = join(work, `face-spans-${spanFaceCacheKey(map.spans, hash)}.json`);
|
|
3324
|
+
let measured: boolean[] | null = null;
|
|
3325
|
+
if (existsSync(spanFaceCache)) {
|
|
3326
|
+
measured = z.array(z.boolean()).length(map.spans.length)
|
|
3327
|
+
.parse(JSON.parse(await readFile(spanFaceCache, "utf8")));
|
|
3328
|
+
} else {
|
|
3329
|
+
const maskAnim = isInteractive()
|
|
3330
|
+
? new StageAnimator(
|
|
3331
|
+
"SUBJECT TRACKING",
|
|
3332
|
+
`Measuring who the subject is across ${map.spans.length} kept spans...`,
|
|
3333
|
+
"render",
|
|
3334
|
+
).start()
|
|
3335
|
+
: null;
|
|
3336
|
+
try {
|
|
3337
|
+
const spanFaces = await measureFaceInWindows(
|
|
3338
|
+
tools,
|
|
3339
|
+
input,
|
|
3340
|
+
spanFaceWindows(map.spans),
|
|
3341
|
+
{ workDir: work },
|
|
3342
|
+
);
|
|
3343
|
+
measured = spanFaceMaskFromFaces(spanFaces);
|
|
3344
|
+
await writeFile(spanFaceCache, JSON.stringify(measured));
|
|
3345
|
+
} catch (err) {
|
|
3346
|
+
// NEVER cache a FAILURE (§106) — and the punch/zoom are polish, not
|
|
3347
|
+
// the product, so a dead measurement falls back to the whole-take
|
|
3348
|
+
// verdict (the pre-2026-08-16 behavior) rather than killing the run.
|
|
3349
|
+
console.log(
|
|
3350
|
+
` ⚠ per-span face measurement failed (${err instanceof Error ? err.message : String(err)})` +
|
|
3351
|
+
" — every span shares the whole-take verdict this run",
|
|
3352
|
+
);
|
|
3353
|
+
} finally {
|
|
3354
|
+
if (maskAnim) maskAnim.stop();
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
spanIsFaceOnly = measured ?? spanFaceMask(map.spans, null, globalSubject);
|
|
3358
|
+
if (measured) {
|
|
3359
|
+
const faceSpans = measured.filter(Boolean).length;
|
|
3360
|
+
console.log(
|
|
3361
|
+
`▸ subject per span (measured): ${faceSpans} face-only, ` +
|
|
3362
|
+
`${measured.length - faceSpans} screen`,
|
|
3363
|
+
);
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
// The jump-cut punch plan (Task 6): mode from the flag pair, gated per
|
|
3367
|
+
// span by who the subject is where that span BEGINS — the frame at the
|
|
3368
|
+
// cut is what the punch scales. Without a framing plan every span shares
|
|
3369
|
+
// the whole-take verdict, the same `face.subject` the stage bias reads.
|
|
3370
|
+
const punch = punchPlanFor(map.spans, jumpCutsMode, spanIsFaceOnly);
|
|
3371
|
+
|
|
2065
3372
|
// Micro zoom punches (FINDINGS §15) reversing at real phrase breaks (§18).
|
|
2066
3373
|
// Breaths are source-time; TimeMap has no span mapper, so both ends go
|
|
2067
3374
|
// through toOutputClamped — a pause that was cut collapses to one instant,
|
|
2068
3375
|
// which is still a boundary (a jump cut is a phrase break too).
|
|
2069
3376
|
// One move per cut-free clip: ramp in, then hold. The clip starts ARE the
|
|
2070
3377
|
// cuts — every point the source jumps — so a take that removed nothing is
|
|
2071
|
-
// one clip and gets exactly one slow push.
|
|
3378
|
+
// one clip and gets exactly one slow push. Face-only since 2026-08-16
|
|
3379
|
+
// (same mask as the punch): a screen-subject clip gets NO push at all.
|
|
2072
3380
|
const zoomOff = opts.zoom === false;
|
|
2073
3381
|
const zoom = buildZoomPlan(map.outputDuration, {
|
|
2074
3382
|
clipStarts: map.spans.map((s) => s.outIn),
|
|
3383
|
+
allowedClips: spanIsFaceOnly,
|
|
2075
3384
|
});
|
|
2076
3385
|
console.log(
|
|
2077
3386
|
zoomOff
|
|
2078
3387
|
? "▸ zoom: off (--no-zoom) — static camera; jump cuts land unconcealed"
|
|
2079
|
-
: `▸ zoom: ${zoom.
|
|
3388
|
+
: `▸ zoom: ${zoom.zoomedClips} clip(s) zoomed, ${zoom.staticClips} static ` +
|
|
3389
|
+
`(screen subject), ${zoom.rampSec}s push then hold ` +
|
|
2080
3390
|
`(${zoom.segments.length} segments)`,
|
|
2081
3391
|
);
|
|
2082
3392
|
|
|
@@ -2101,7 +3411,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2101
3411
|
// same head-fits rule would report a defect for working as designed.
|
|
2102
3412
|
const issues = assessCueFraming(
|
|
2103
3413
|
graphicCues.flatMap((c) => {
|
|
2104
|
-
|
|
3414
|
+
// `frame` must reach layoutSlots, not just the pixel multiply below —
|
|
3415
|
+
// it defaults to PORTRAIT_FRAME, and the R15 split layouts change
|
|
3416
|
+
// geometry with orientation (split-left: {w:1, h:0.5} stacked in
|
|
3417
|
+
// portrait, {w:0.5, h:1} side panel in landscape), so omitting it
|
|
3418
|
+
// judged 16:9 cues against portrait slot shapes. Latent since R15
|
|
3419
|
+
// landscape support; see layoutSlotAspects for the twin brief-side bug.
|
|
3420
|
+
const v = layoutSlots(c.layout, DEFAULT_FACE, [], frame).video;
|
|
2105
3421
|
if (v.opacity <= 0 || v.rect.w * v.rect.h < PRIMARY_VIDEO_SLOT_AREA) return [];
|
|
2106
3422
|
return [{
|
|
2107
3423
|
id: c.id,
|
|
@@ -2133,20 +3449,35 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2133
3449
|
}
|
|
2134
3450
|
}
|
|
2135
3451
|
|
|
2136
|
-
let renderVideo =
|
|
3452
|
+
let renderVideo = input;
|
|
2137
3453
|
// A letterboxed source MUST go through the re-encode even under
|
|
2138
3454
|
// --no-mezzanine: the bars are pixels in the file, and cropping them here is
|
|
2139
3455
|
// what lets every layout and zoom downstream treat the picture as the frame.
|
|
2140
3456
|
// 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
3457
|
// `mezzanineWillBuild` (computed once, above, with `contentRect`) — not a
|
|
2145
3458
|
// second copy of this condition — so this can't drift from what
|
|
2146
3459
|
// `planRenderPublicDir` already decided the accepted-image check against
|
|
2147
3460
|
// (Finding 3, final-review fix wave).
|
|
3461
|
+
// Display-sized mezzanine (2026-08-17 render-speed pass): computed on the
|
|
3462
|
+
// POST-CROP picture (the crop runs first in the same ffmpeg pass, so
|
|
3463
|
+
// `contentRect` IS what the scale filter sees) against the OUTPUT
|
|
3464
|
+
// frame+fps. Deliberately null when no mezzanine will build (--no-mezzanine
|
|
3465
|
+
// on a bar-free source): there is no re-encode to scale, the render plays
|
|
3466
|
+
// the source itself, and the window emissions below must then stay in true
|
|
3467
|
+
// source pixels — which the identity `mezzFactor` below guarantees.
|
|
3468
|
+
const mezzScale = mezzanineWillBuild
|
|
3469
|
+
? mezzanineScale(
|
|
3470
|
+
{ width: contentRect.w, height: contentRect.h, fps: sourceProbe.fps },
|
|
3471
|
+
production.render,
|
|
3472
|
+
opts.sourceFit ?? "cover",
|
|
3473
|
+
)
|
|
3474
|
+
: null;
|
|
2148
3475
|
if (mezzanineWillBuild) {
|
|
2149
|
-
|
|
3476
|
+
// The scale decision rides the FILENAME (`mezzanineFileName` has the
|
|
3477
|
+
// why): mezzanine caching is existence-keyed, so a pre-pass full-res
|
|
3478
|
+
// mezzanine.mp4 must not satisfy a run that emits mezzanine-sized
|
|
3479
|
+
// windows — the scaled file rebuilds once under its own name.
|
|
3480
|
+
const mezz = join(work, mezzanineFileName(!contentRect.full, mezzScale));
|
|
2150
3481
|
if (!existsSync(mezz)) {
|
|
2151
3482
|
const mezzAnim = isInteractive()
|
|
2152
3483
|
? new StageAnimator(
|
|
@@ -2164,11 +3495,41 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2164
3495
|
: "▸ building mezzanine (dense keyframes, letterbox bars trimmed)…",
|
|
2165
3496
|
);
|
|
2166
3497
|
}
|
|
2167
|
-
await makeMezzanine(tools, input, mezz, {
|
|
3498
|
+
await makeMezzanine(tools, input, mezz, {
|
|
3499
|
+
cropVf: cropVf || undefined,
|
|
3500
|
+
scale: mezzScale ?? undefined,
|
|
3501
|
+
});
|
|
2168
3502
|
if (mezzAnim) mezzAnim.stop();
|
|
2169
3503
|
}
|
|
3504
|
+
if (mezzScale) {
|
|
3505
|
+
console.log(
|
|
3506
|
+
`▸ mezzanine: ${contentRect.w}x${contentRect.h}@${Math.round(sourceProbe.fps)} → ` +
|
|
3507
|
+
`${mezzScale.width}x${mezzScale.height}@${Math.round(mezzScale.fps)} ` +
|
|
3508
|
+
`(render-sized — decode is the render bottleneck)`,
|
|
3509
|
+
);
|
|
3510
|
+
}
|
|
2170
3511
|
renderVideo = mezz;
|
|
2171
3512
|
}
|
|
3513
|
+
// Window space must equal PLAYED-FILE space: the renderer's crop math
|
|
3514
|
+
// (`contentCoverBox` et al.) positions windows against the file it plays,
|
|
3515
|
+
// so a scaled mezzanine needs every pixel-space emission below scaled by
|
|
3516
|
+
// the same factor. Derived from the actual scaled dims — per axis, because
|
|
3517
|
+
// yuv420 even-rounding makes the two ratios differ by a hair — and
|
|
3518
|
+
// identity whenever the render plays an unscaled file (no mezzanine, or a
|
|
3519
|
+
// source already at display size). `playedFullFrame` is the matching
|
|
3520
|
+
// `sourceSize`: the framing/fit paths only ever fire with a FULL-frame
|
|
3521
|
+
// mezzanine (mixed framing ⇒ no uniform crop), so its base is the source's
|
|
3522
|
+
// own dims. Face fractions, `sourceAspect` and `sourceTextRegions` are
|
|
3523
|
+
// ratios/fractions — scale-invariant, untouched. The Premiere project
|
|
3524
|
+
// export stays in TRUE source space by construction: it cuts the ORIGINAL
|
|
3525
|
+
// file (`production.source`, path + probe) and consumes only seconds and
|
|
3526
|
+
// scales from render-props (spans, zoomPlan, punch), never these windows.
|
|
3527
|
+
const mezzFactor = mezzScale
|
|
3528
|
+
? { x: mezzScale.width / contentRect.w, y: mezzScale.height / contentRect.h }
|
|
3529
|
+
: { x: 1, y: 1 };
|
|
3530
|
+
const playedFullFrame = mezzScale
|
|
3531
|
+
? { width: mezzScale.width, height: mezzScale.height }
|
|
3532
|
+
: null;
|
|
2172
3533
|
|
|
2173
3534
|
// Comment-CTA keyword (FINDINGS §16), scoped to the ask (FINDINGS §22).
|
|
2174
3535
|
// Read off the timed CUE, not the untimed scene: the cue carries the same
|
|
@@ -2191,6 +3552,33 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2191
3552
|
);
|
|
2192
3553
|
}
|
|
2193
3554
|
|
|
3555
|
+
// Bundled Nastaliq for RTL captions (2026-08-17): the render must not
|
|
3556
|
+
// depend on the machine having an Arabic-script font — a Linux box has
|
|
3557
|
+
// none, and macOS/Windows each substitute a different one, so identical
|
|
3558
|
+
// render-props drew three different Urdu caption sets. Gated on the SAME
|
|
3559
|
+
// predicate CaptionTrack keys its @font-face on (`captionsNeedNastaliq`),
|
|
3560
|
+
// so pure-Latin runs copy nothing and render byte-identically. Staged into
|
|
3561
|
+
// the render's public dir AND the workdir when they differ (a
|
|
3562
|
+
// --no-mezzanine file run serves the render from the source's own folder,
|
|
3563
|
+
// but `ossclip edit` serves from the workdir — program.ts's
|
|
3564
|
+
// `dirname(propsPath)` — and both mounts fetch the same served URL).
|
|
3565
|
+
// `join` is correct here where NASTALIQ_FONT_REL itself must stay
|
|
3566
|
+
// POSIX-literal: these are filesystem paths, the REL is the served URL
|
|
3567
|
+
// (sideImageDestRel's Windows lesson).
|
|
3568
|
+
if (!captionsHidden && captionsNeedNastaliq(captionLines)) {
|
|
3569
|
+
const fontSrc = nastaliqFontFile();
|
|
3570
|
+
for (const dir of new Set([renderPublicDirPath, work])) {
|
|
3571
|
+
const dest = join(dir, NASTALIQ_FONT_REL);
|
|
3572
|
+
if (!existsSync(dest)) {
|
|
3573
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
3574
|
+
copyFileSync(fontSrc, dest);
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
console.log(
|
|
3578
|
+
`▸ captions: RTL lines detected — bundled ${NASTALIQ_FONT_NAME} staged as ${NASTALIQ_FONT_REL}`,
|
|
3579
|
+
);
|
|
3580
|
+
}
|
|
3581
|
+
|
|
2194
3582
|
const ctaCue = [...graphicCues]
|
|
2195
3583
|
.reverse()
|
|
2196
3584
|
.find((c) => typeof c.props?.keyword === "string" && (c.props.keyword as string).length > 0);
|
|
@@ -2236,7 +3624,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2236
3624
|
// editing session would have nothing to fall back to and render as if
|
|
2237
3625
|
// it never happened, even though `overrides.json` on disk is correct.
|
|
2238
3626
|
baseSceneCues: routed.cues,
|
|
2239
|
-
|
|
3627
|
+
// The CONFIG base, not defaultTheme (F6): the editor re-applies its
|
|
3628
|
+
// overrides onto this, so a theme reset there must land on the user's
|
|
3629
|
+
// global colors — falling to factory defaults would silently discard
|
|
3630
|
+
// ~/.ossclip/config.json's theme the first time anyone touched a color.
|
|
3631
|
+
baseTheme: configBaseTheme,
|
|
2240
3632
|
baseCaptionLines,
|
|
2241
3633
|
settings: production.render,
|
|
2242
3634
|
outputDurationSec: map.outputDuration,
|
|
@@ -2250,8 +3642,19 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2250
3642
|
centerXFrac: faceBox.centerXFrac,
|
|
2251
3643
|
sizeFrac: faceBox.sizeFrac,
|
|
2252
3644
|
// The CONTENT's shape, not the container's — with bars trimmed the
|
|
2253
|
-
// rendered video IS the content rect (PLAN Task 7).
|
|
3645
|
+
// rendered video IS the content rect (PLAN Task 7). Since the
|
|
3646
|
+
// framing bake became props (2026-08-16), this is always the RAW
|
|
3647
|
+
// source's picture: `content` derives from `sourceProbe`, never
|
|
3648
|
+
// from a re-encoded canvas, so a framing plan no longer distorts
|
|
3649
|
+
// the aspect the stage crops against.
|
|
2254
3650
|
sourceAspect: content.height > 0 ? content.width / content.height : undefined,
|
|
3651
|
+
// Whether the face IS the subject, by the same rule the framing
|
|
3652
|
+
// plan applies per segment. 2026-08-16 incident: the global
|
|
3653
|
+
// 9-sample median landed on the camera PiP and pinned objectPosY
|
|
3654
|
+
// to 1.0, decapitating the speaker at the top of the frame — a
|
|
3655
|
+
// PiP-sized face must not steer the cover. Absent (old props)
|
|
3656
|
+
// means "face", so pre-existing render-props render unchanged.
|
|
3657
|
+
subject: faceSubject(faceBox),
|
|
2255
3658
|
}
|
|
2256
3659
|
: null,
|
|
2257
3660
|
// Emptied, not flattened-to-1: a plan of flat segments still reads as "a
|
|
@@ -2265,25 +3668,57 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2265
3668
|
ctaKeyword,
|
|
2266
3669
|
ctaWindow,
|
|
2267
3670
|
sourceTextRegions: textRegions,
|
|
2268
|
-
// Sent ONLY on the fit fallback (option (b)): a
|
|
2269
|
-
//
|
|
2270
|
-
// the mezzanine — cropping either again at
|
|
2271
|
-
// picture twice.
|
|
3671
|
+
// Sent ONLY on the fit fallback (option (b)): a plan-framed mixed source
|
|
3672
|
+
// carries its windows in `framingTimeline` below, and a uniform source
|
|
3673
|
+
// had its bars cropped into the mezzanine — cropping either again at
|
|
3674
|
+
// render time would eat the picture twice. Rects and sourceSize are in
|
|
3675
|
+
// PLAYED-FILE pixels (`mezzFactor`/`playedFullFrame` above): the renderer
|
|
3676
|
+
// windows the file it plays, which a display-sized mezzanine has resampled.
|
|
2272
3677
|
...(fitFallback
|
|
2273
3678
|
? {
|
|
2274
|
-
contentTimeline,
|
|
2275
|
-
sourceSize:
|
|
3679
|
+
contentTimeline: scaleContentTimeline(contentTimeline, mezzFactor),
|
|
3680
|
+
sourceSize:
|
|
3681
|
+
playedFullFrame ?? { width: sourceProbe.width, height: sourceProbe.height },
|
|
2276
3682
|
contentCropMode: "fit" as const,
|
|
2277
3683
|
}
|
|
2278
3684
|
: {}),
|
|
3685
|
+
// The accepted framing plan, as DATA (2026-08-16 incident: the bake this
|
|
3686
|
+
// replaces was irreversible — deleting the re-encoded content-<hash>.mp4
|
|
3687
|
+
// was the only remedy — and it suppressed these very keys, so the editor
|
|
3688
|
+
// could not even see the crop). Mutually exclusive with the fit-fallback
|
|
3689
|
+
// spread by construction (`fitFallback` ⇔ `!plan.ok`), so `sourceSize`
|
|
3690
|
+
// is emitted by exactly one of them. Skipped under `--source-fit
|
|
3691
|
+
// contain`: contain shows the WHOLE frame, and a framing plan's cover
|
|
3692
|
+
// windows would fight it — the explicit flag wins over the inferred plan.
|
|
3693
|
+
...(framingTimeline
|
|
3694
|
+
? {
|
|
3695
|
+
// The plan's windows are TRUE source pixels (planNormalization
|
|
3696
|
+
// analyses the source); scaled here, at emission, into the pixel
|
|
3697
|
+
// space of the file the render plays — a display-sized mezzanine
|
|
3698
|
+
// resamples that space by `mezzFactor` (identity when unscaled).
|
|
3699
|
+
framingTimeline: scaleFramingWindows(framingTimeline, mezzFactor),
|
|
3700
|
+
// The PLAYED file's size — the scaled windows are in its pixels.
|
|
3701
|
+
sourceSize:
|
|
3702
|
+
playedFullFrame ?? { width: sourceProbe.width, height: sourceProbe.height },
|
|
3703
|
+
}
|
|
3704
|
+
: {}),
|
|
3705
|
+
// ALWAYS written, never absent-when-default like the flags around it:
|
|
3706
|
+
// an ABSENT `punch` is the LEGACY contract — EdlVideo's 1.07 punch on
|
|
3707
|
+
// every alternating span — kept so every pre-feature render-props.json
|
|
3708
|
+
// renders byte-identical to what it always did. Presence, even an
|
|
3709
|
+
// all-false "off" mask, is what opts a render into the face-only 1.015
|
|
3710
|
+
// behavior (punchPlanFor has the guard's why).
|
|
3711
|
+
punch,
|
|
2279
3712
|
// `--source-fit contain`: show the whole frame instead of cropping it.
|
|
2280
3713
|
// The size sent is the PICTURE's, not the container's — with bars trimmed
|
|
2281
3714
|
// into the mezzanine the rendered video IS the content rect, and fitting
|
|
2282
3715
|
// against the container's shape would inset a frame that no longer exists.
|
|
2283
3716
|
// Listed after the fit fallback so it wins on a source that is both mixed
|
|
2284
|
-
// and asked to be shown whole.
|
|
3717
|
+
// and asked to be shown whole. `playedFullFrame` when the mezzanine is
|
|
3718
|
+
// display-sized: it is that same picture, post-resample — the file the
|
|
3719
|
+
// renderer fits.
|
|
2285
3720
|
...(opts.sourceFit === "contain"
|
|
2286
|
-
? { sourceFit: "contain" as const, sourceSize: content }
|
|
3721
|
+
? { sourceFit: "contain" as const, sourceSize: playedFullFrame ?? content }
|
|
2287
3722
|
: {}),
|
|
2288
3723
|
// Written only when ON, matching the field's absent-means-off contract:
|
|
2289
3724
|
// an off run's render-props.json stays byte-identical to a pre-watermark
|
|
@@ -2344,7 +3779,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2344
3779
|
// recovered from (`legacySplitId`). Repairing the captions would destroy the
|
|
2345
3780
|
// evidence for the split. `writeOverrideDoc` carries the full argument for
|
|
2346
3781
|
// why a caption-only write has nothing worth backing up.
|
|
2347
|
-
|
|
3782
|
+
// `hidesPruned` joins the gate for the same reason `captionKeysReanchored`
|
|
3783
|
+
// did: a hide retired by its own cut (`pruneHidesInsideCuts`) changes the
|
|
3784
|
+
// doc without changing the cut entries, and skipping the write would
|
|
3785
|
+
// re-report "the cut removed it" on every later run. It does NOT spend the
|
|
3786
|
+
// `.bak` — retiring a redundant key is not the cut re-anchoring the backup
|
|
3787
|
+
// exists to survive.
|
|
3788
|
+
if (cutResult.changed || captionKeysReanchored || hidesPruned) {
|
|
2348
3789
|
await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
|
|
2349
3790
|
console.log(overridesWriteLine(cutResult.changed));
|
|
2350
3791
|
}
|
|
@@ -2365,13 +3806,124 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2365
3806
|
};
|
|
2366
3807
|
}
|
|
2367
3808
|
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
? opts.out
|
|
2371
|
-
: resolve(baseCwd, opts.out)
|
|
2372
|
-
: resolve(defaultOutPath(originalInput));
|
|
3809
|
+
// outPath was resolved (and its parent healed) at produce start — see the
|
|
3810
|
+
// 2026-08-16 fail-fast block up top.
|
|
2373
3811
|
const rawPath = join(work, "render-raw.mp4");
|
|
2374
3812
|
const interactive = isInteractive();
|
|
3813
|
+
|
|
3814
|
+
// ---- Thumbnail concept approval (thumbnail UX, 2026-08-16) --------------
|
|
3815
|
+
// BEFORE the render kickoff, after scene planning: the concept is the one
|
|
3816
|
+
// creative judgement the user previously only discovered after a
|
|
3817
|
+
// multi-minute render. Interactive runs approve (or edit, or skip) it
|
|
3818
|
+
// here; the file it writes is what thumbnailStep honors after render — and
|
|
3819
|
+
// what a non-TTY replay (the editor's Render) reuses, which is the whole
|
|
3820
|
+
// persistence story.
|
|
3821
|
+
//
|
|
3822
|
+
// Resolved HERE rather than at the pack section below so the gate and the
|
|
3823
|
+
// post-render consumers read one answer. Typed-beats-config, `typeof` not
|
|
3824
|
+
// truthiness (the `portrait` posture): config.json is hand-edited and
|
|
3825
|
+
// unparsed, and a `"audience": true` typo must resolve to "no audience".
|
|
3826
|
+
const youtube = resolveYoutube(opts.youtube, cfg.youtube);
|
|
3827
|
+
// resolvePortrait (portrait-override.ts) carries the expandHome treatment
|
|
3828
|
+
// of the flag and config paths, and puts the workdir's portrait-override
|
|
3829
|
+
// ABOVE both (editor face swap, 2026-08-17): a per-project expression
|
|
3830
|
+
// chosen in the editor must survive CLI re-renders — the flag/config
|
|
3831
|
+
// portrait is the fallback headshot, and a replay silently reverting the
|
|
3832
|
+
// swapped face would undo the one thing the swap exists for.
|
|
3833
|
+
const portrait = resolvePortrait({
|
|
3834
|
+
overridePath: portraitOverridePath(work),
|
|
3835
|
+
flagPortrait: opts.portrait,
|
|
3836
|
+
cfgPortrait: cfg.portrait,
|
|
3837
|
+
})?.path;
|
|
3838
|
+
const audience = opts.audience ?? (typeof cfg.audience === "string" ? cfg.audience : undefined);
|
|
3839
|
+
const thumbnailBrief =
|
|
3840
|
+
opts.thumbnailBrief ?? (typeof cfg.thumbnailBrief === "string" ? cfg.thumbnailBrief : undefined);
|
|
3841
|
+
const geminiKey = process.env.GEMINI_API_KEY;
|
|
3842
|
+
const approvedConceptPath = join(work, THUMBNAIL_APPROVED_BASENAME);
|
|
3843
|
+
// The gate reuses thumbnailDecision (plus the mime check) so the prompt
|
|
3844
|
+
// never asks about a thumbnail the post-render step would skip anyway.
|
|
3845
|
+
const thumbnailWouldGenerate =
|
|
3846
|
+
provider != null &&
|
|
3847
|
+
portrait !== undefined &&
|
|
3848
|
+
thumbnailDecision(
|
|
3849
|
+
youtube,
|
|
3850
|
+
portrait,
|
|
3851
|
+
geminiKey !== undefined && geminiKey !== "",
|
|
3852
|
+
existsSync(portrait),
|
|
3853
|
+
) === "generate" &&
|
|
3854
|
+
portraitMimeType(portrait) !== undefined;
|
|
3855
|
+
if (interactive && thumbnailWouldGenerate) {
|
|
3856
|
+
if (existsSync(approvedConceptPath)) {
|
|
3857
|
+
// A decision already on file IS the answer — re-asking a question the
|
|
3858
|
+
// user settled would make every warm re-run nag. The line names the
|
|
3859
|
+
// escape hatch instead.
|
|
3860
|
+
console.log(
|
|
3861
|
+
`▸ thumbnail: concept already decided (${THUMBNAIL_APPROVED_BASENAME} — delete it to revisit)`,
|
|
3862
|
+
);
|
|
3863
|
+
} else {
|
|
3864
|
+
// Seed from the concept cache when this exact steer was asked before
|
|
3865
|
+
// (a prior non-TTY run) — no titleAngle: the pack generates after
|
|
3866
|
+
// render, so the pre-render call cannot carry it and passes the hook
|
|
3867
|
+
// instead (see generateConcept below).
|
|
3868
|
+
const conceptCache = join(
|
|
3869
|
+
work,
|
|
3870
|
+
thumbnailConceptCacheName({
|
|
3871
|
+
providerName,
|
|
3872
|
+
llmModel: opts.llmModel,
|
|
3873
|
+
intent: opts.intent,
|
|
3874
|
+
hook: beatSheet?.hook,
|
|
3875
|
+
audience,
|
|
3876
|
+
brief: thumbnailBrief,
|
|
3877
|
+
transcriptWords: transcript.words.map((w) => w.text),
|
|
3878
|
+
}),
|
|
3879
|
+
);
|
|
3880
|
+
const initial = existsSync(conceptCache)
|
|
3881
|
+
? ThumbnailConceptSchema.parse(JSON.parse(await readFile(conceptCache, "utf8")))
|
|
3882
|
+
: undefined;
|
|
3883
|
+
try {
|
|
3884
|
+
const approved = await approveThumbnailConcept({
|
|
3885
|
+
initial,
|
|
3886
|
+
generateConcept: async (note) => {
|
|
3887
|
+
const fresh = await phases.time("llm", () =>
|
|
3888
|
+
generateThumbnailConcept(provider!, {
|
|
3889
|
+
hook: beatSheet?.hook,
|
|
3890
|
+
intent: opts.intent,
|
|
3891
|
+
audience,
|
|
3892
|
+
brief: thumbnailBrief,
|
|
3893
|
+
note,
|
|
3894
|
+
transcriptText: transcript.words.map((w) => w.text).join(" "),
|
|
3895
|
+
}),
|
|
3896
|
+
);
|
|
3897
|
+
// The §35 word cap, thumbnailStep's exact treatment — approved
|
|
3898
|
+
// text must be the text the image is prompted with.
|
|
3899
|
+
return { ...fresh, overlayText: approvedOverlayText(fresh.overlayText) };
|
|
3900
|
+
},
|
|
3901
|
+
});
|
|
3902
|
+
await writeFile(approvedConceptPath, JSON.stringify(approved, null, 2));
|
|
3903
|
+
} catch (err) {
|
|
3904
|
+
// A concept-call failure must not block the render the user is
|
|
3905
|
+
// waiting on (§112 posture) — no approved file is written, and the
|
|
3906
|
+
// post-render step retries the concept on its own.
|
|
3907
|
+
console.log(
|
|
3908
|
+
`▸ thumbnail: concept approval unavailable (${err instanceof Error ? err.message : String(err)}) ` +
|
|
3909
|
+
"— the post-render step will try again",
|
|
3910
|
+
);
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
|
|
3915
|
+
// --concurrency, else config renderConcurrency, else cpus-2 with a floor of
|
|
3916
|
+
// 2 (resolveRenderConcurrency has the precedence and the why: leave cores
|
|
3917
|
+
// for the ffmpeg decode workers every tab waits on). Resolved BEFORE the log
|
|
3918
|
+
// line below so the count can go INTO it — the 2026-08-19 whole-browser OOM
|
|
3919
|
+
// took a machine spec and arithmetic to diagnose, because no line of the
|
|
3920
|
+
// render's own output ever said how many tabs it opened.
|
|
3921
|
+
const renderConcurrency = resolveRenderConcurrency(
|
|
3922
|
+
opts.concurrency,
|
|
3923
|
+
cfg.renderConcurrency,
|
|
3924
|
+
cpus().length,
|
|
3925
|
+
);
|
|
3926
|
+
if (renderConcurrency.warning) console.log(renderConcurrency.warning);
|
|
2375
3927
|
let renderHud: RenderTimelineHUD | null = null;
|
|
2376
3928
|
if (interactive) {
|
|
2377
3929
|
renderHud = new RenderTimelineHUD({
|
|
@@ -2381,27 +3933,109 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2381
3933
|
aspect: landscape ? "16:9" : "9:16",
|
|
2382
3934
|
}).start();
|
|
2383
3935
|
} else {
|
|
2384
|
-
console.log(
|
|
3936
|
+
console.log(`▸ rendering… (${renderConcurrency.concurrency} parallel tabs)`);
|
|
2385
3937
|
}
|
|
2386
3938
|
let lastPct = -10;
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
3939
|
+
// Ctrl-C must actually stop the render (2026-08-19 field report). Remotion
|
|
3940
|
+
// owns a browser and ffmpeg children that outlive a bare process death, so
|
|
3941
|
+
// stopping means handing renderMedia a cancelSignal and firing it — node's
|
|
3942
|
+
// default SIGINT handling would leave those orphaned.
|
|
3943
|
+
//
|
|
3944
|
+
// Registered around the RENDER PHASE ONLY, and removed in the finally: a
|
|
3945
|
+
// handler that outlived this phase would swallow Ctrl-C during the LLM and
|
|
3946
|
+
// whisper phases, which exit promptly today and must keep doing so.
|
|
3947
|
+
//
|
|
3948
|
+
// SIGTERM is wired for the same reason as SIGINT, plus one of its own: the
|
|
3949
|
+
// editor's /api/render/cancel (edit.ts) kills this process as a child, and
|
|
3950
|
+
// before this handler that kill left the browser behind. That path is
|
|
3951
|
+
// otherwise untouched — it already reports its own cancel. It also inherits
|
|
3952
|
+
// the dead-window fix below: the editor's Cancel button now kills the child
|
|
3953
|
+
// DURING bundling too, where the SIGTERM used to be swallowed and
|
|
3954
|
+
// /api/render kept answering 409 until the bundle finished on its own.
|
|
3955
|
+
const renderCancel = makeCancelSignal();
|
|
3956
|
+
// An array, not a `let`: the handler assigns from inside a closure, and TS's
|
|
3957
|
+
// flow analysis would still read a `let` as null in the catch below (it
|
|
3958
|
+
// narrowed the branch to `never`). First signal wins — hammering Ctrl-C must
|
|
3959
|
+
// not rewrite the verdict while the teardown is already running.
|
|
3960
|
+
const cancellations: RenderCancellation[] = [];
|
|
3961
|
+
// Where renderProduction is, so the handler knows whether the cancel signal
|
|
3962
|
+
// has anyone listening (renderSignalAction has the whole reasoning).
|
|
3963
|
+
// "pre-render" from the start: the handlers go on before the call.
|
|
3964
|
+
let signalPhase: RenderSignalPhase = "pre-render";
|
|
3965
|
+
let signalCount = 0;
|
|
3966
|
+
// Shared by all three places a cancel is finalised — the handler, the
|
|
3967
|
+
// rejected-render catch, and the post-render tail check.
|
|
3968
|
+
const finishCancel = (c: RenderCancellation, note?: string): never => {
|
|
3969
|
+
if (renderHud) renderHud.stop();
|
|
3970
|
+
if (note) console.log(note);
|
|
3971
|
+
// rmSync, not fs/promises rm: the handler path calls this and exits on the
|
|
3972
|
+
// next statement, and an awaited unlink would never get its turn.
|
|
3973
|
+
for (const path of c.removePaths) rmSync(path, { force: true });
|
|
3974
|
+
console.log(c.message);
|
|
3975
|
+
// Exits here rather than throwing: program.ts's catch would record this as
|
|
3976
|
+
// produce_failed and print "✗ <message>", dressing a deliberate stop as a
|
|
3977
|
+
// bug (R16 §60's distinction).
|
|
3978
|
+
process.exit(c.exitCode);
|
|
3979
|
+
};
|
|
3980
|
+
const onCancelSignal = (signal: "SIGINT" | "SIGTERM") => {
|
|
3981
|
+
signalCount += 1;
|
|
3982
|
+
if (cancellations.length === 0) cancellations.push(renderCancellation(signal, rawPath));
|
|
3983
|
+
const action = renderSignalAction(signalPhase, signalCount);
|
|
3984
|
+
if (action.cancel) renderCancel.cancel();
|
|
3985
|
+
if (action.exitNow) finishCancel(cancellations[0]!, action.note);
|
|
3986
|
+
};
|
|
3987
|
+
const onSigint = () => onCancelSignal("SIGINT");
|
|
3988
|
+
const onSigterm = () => onCancelSignal("SIGTERM");
|
|
3989
|
+
process.on("SIGINT", onSigint);
|
|
3990
|
+
process.on("SIGTERM", onSigterm);
|
|
3991
|
+
try {
|
|
3992
|
+
await phases.time("render", () =>
|
|
3993
|
+
renderProduction(props, {
|
|
3994
|
+
publicDir: dirname(renderVideo),
|
|
3995
|
+
outPath: rawPath,
|
|
3996
|
+
browserExecutable: cfg.browserExecutable,
|
|
3997
|
+
concurrency: renderConcurrency.concurrency,
|
|
3998
|
+
cancelSignal: renderCancel.cancelSignal,
|
|
3999
|
+
onPhase: (phase: RenderPhase) => {
|
|
4000
|
+
signalPhase = renderSignalPhaseOf(phase);
|
|
4001
|
+
},
|
|
4002
|
+
onProgress: (p) => {
|
|
4003
|
+
if (renderHud) {
|
|
4004
|
+
renderHud.setProgress(p);
|
|
4005
|
+
} else {
|
|
4006
|
+
const pct = Math.floor(p * 100);
|
|
4007
|
+
if (pct >= lastPct + 10) {
|
|
4008
|
+
lastPct = pct;
|
|
4009
|
+
process.stdout.write(` ${pct}%\n`);
|
|
4010
|
+
}
|
|
2400
4011
|
}
|
|
2401
|
-
}
|
|
2402
|
-
},
|
|
2403
|
-
|
|
2404
|
-
|
|
4012
|
+
},
|
|
4013
|
+
}),
|
|
4014
|
+
);
|
|
4015
|
+
// renderMedia resolved, so nothing is left to cancel cooperatively. Not a
|
|
4016
|
+
// phase the handler can exit from either — see the tail check below.
|
|
4017
|
+
signalPhase = "post-render";
|
|
4018
|
+
} catch (err) {
|
|
4019
|
+
// A cancelled renderMedia rejects like any other failure; only the
|
|
4020
|
+
// handler above can tell the two apart.
|
|
4021
|
+
const cancellation = cancellations[0];
|
|
4022
|
+
if (!cancellation) throw err;
|
|
4023
|
+
finishCancel(cancellation);
|
|
4024
|
+
} finally {
|
|
4025
|
+
process.off("SIGINT", onSigint);
|
|
4026
|
+
process.off("SIGTERM", onSigterm);
|
|
4027
|
+
}
|
|
4028
|
+
// The TAIL CASE (2026-08-19 review): a signal landing after renderMedia
|
|
4029
|
+
// resolved but before the `finally` above took the handlers off used to be
|
|
4030
|
+
// swallowed outright — nothing threw, the run went on to loudnorm and
|
|
4031
|
+
// mastering, and the user got a complete video having pressed Ctrl-C. We
|
|
4032
|
+
// HONOR it: the user asked to stop, and stopping here costs only the
|
|
4033
|
+
// mastering pass, whereas ignoring it hands them the file they just said
|
|
4034
|
+
// they did not want. Nothing has been written to --out yet (moveFile is
|
|
4035
|
+
// below), so honoring here is still "no output", the same promise every
|
|
4036
|
+
// other cancel makes.
|
|
4037
|
+
const tailCancellation = cancellations[0];
|
|
4038
|
+
if (tailCancellation) finishCancel(tailCancellation);
|
|
2405
4039
|
if (renderHud) renderHud.stop();
|
|
2406
4040
|
|
|
2407
4041
|
const masterAnim = interactive
|
|
@@ -2415,7 +4049,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2415
4049
|
const normPath = join(work, "render-norm.mp4");
|
|
2416
4050
|
await phases.time("ffmpeg", () => loudnorm(tools, rawPath, normPath));
|
|
2417
4051
|
if (masterAnim) masterAnim.stop();
|
|
2418
|
-
|
|
4052
|
+
// moveFile, not fs rename: an --out on another volume (external drive)
|
|
4053
|
+
// throws EXDEV at the very end of the run — the sibling trap to the
|
|
4054
|
+
// ENOENT ensureParentDir prevents upfront (paths.ts).
|
|
4055
|
+
await moveFile(normPath, outPath);
|
|
2419
4056
|
|
|
2420
4057
|
// ---- Cover image (FINDINGS §31) -----------------------------------------
|
|
2421
4058
|
// A separate file, not a burned-in intro: both platforms accept a custom
|
|
@@ -2432,9 +4069,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2432
4069
|
const cover = coverDecision(opts.cover !== false, coverText);
|
|
2433
4070
|
if (cover !== "none") {
|
|
2434
4071
|
const detector = await createFaceDetector();
|
|
2435
|
-
const pick = await pickCoverFrame(tools,
|
|
4072
|
+
const pick = await pickCoverFrame(tools, input, sourceProbe.duration, {
|
|
2436
4073
|
cacheDir: work,
|
|
2437
|
-
cropVf
|
|
4074
|
+
cropVf,
|
|
4075
|
+
// On a screen-subject take the face weight is zeroed (2026-08-16: a
|
|
4076
|
+
// Facebook reel face visible IN the screen recording won the cover
|
|
4077
|
+
// — scoreCandidate has the incident). Same whole-take verdict the
|
|
4078
|
+
// stage bias and the span mask fallback read.
|
|
4079
|
+
subject: faceSubject(faceBox),
|
|
2438
4080
|
detectFace: (pixels, w, h) => {
|
|
2439
4081
|
const d = detector(pixels, w, h);
|
|
2440
4082
|
// pico returns [row, col, size, score] in detection-frame pixels,
|
|
@@ -2450,13 +4092,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2450
4092
|
await run(cfg.ffmpegPath, [
|
|
2451
4093
|
"-v", "error",
|
|
2452
4094
|
"-ss", pick.timeSec.toFixed(3),
|
|
2453
|
-
"-i",
|
|
4095
|
+
"-i", input,
|
|
2454
4096
|
"-frames:v", "1",
|
|
2455
|
-
"-vf", `${
|
|
4097
|
+
"-vf", `${cropVf ? `${cropVf},` : ""}scale=${frame.width}:${frame.height}:force_original_aspect_ratio=increase,crop=${frame.width}:${frame.height}`,
|
|
2456
4098
|
"-y", join(work, frameName),
|
|
2457
4099
|
]);
|
|
4100
|
+
// expandHome on the user half only — the artifactPath default derives
|
|
4101
|
+
// from the already-expanded outPath (2026-08-16, paths.ts).
|
|
2458
4102
|
const coverPath = resolve(
|
|
2459
|
-
opts.coverPath
|
|
4103
|
+
opts.coverPath !== undefined
|
|
4104
|
+
? expandHome(opts.coverPath)
|
|
4105
|
+
: artifactPath(outPath, ".cover.jpg"),
|
|
2460
4106
|
);
|
|
2461
4107
|
// The §34 dedupe check and the band-placement log exist only to
|
|
2462
4108
|
// route a banner around the frame's contents — a textless cover
|
|
@@ -2497,6 +4143,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2497
4143
|
{
|
|
2498
4144
|
frameFileName: frameName,
|
|
2499
4145
|
text: bannerText,
|
|
4146
|
+
// The RESOLVED theme — so the cover's banner already carries the
|
|
4147
|
+
// config theme (F6) via resolveTheme's base, no separate wiring.
|
|
2500
4148
|
theme,
|
|
2501
4149
|
face: pick.face,
|
|
2502
4150
|
// The cover is the OUTPUT's thumbnail — a landscape render gets a
|
|
@@ -2510,6 +4158,148 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2510
4158
|
}
|
|
2511
4159
|
}
|
|
2512
4160
|
}
|
|
4161
|
+
|
|
4162
|
+
// ---- YouTube pack (Y2, 2026-08-16) --------------------------------------
|
|
4163
|
+
// AFTER the cover block and additive to it: the pack never changes the
|
|
4164
|
+
// video or the cover, it only writes siblings — so a failure here degrades
|
|
4165
|
+
// to "no metadata file" on a render that already succeeded, never a dead
|
|
4166
|
+
// run (§112 posture). `youtube`/`portrait`/`audience`/`thumbnailBrief`
|
|
4167
|
+
// were resolved before the render kickoff — the concept-approval gate and
|
|
4168
|
+
// this section must read one answer.
|
|
4169
|
+
let youtubeMdPath: string | undefined;
|
|
4170
|
+
let thumbnailPath: string | undefined;
|
|
4171
|
+
// The pack's FIRST title, when it generated — the thumbnail concept's
|
|
4172
|
+
// titleAngle, so thumbnail and title tell one story. (The pre-render
|
|
4173
|
+
// approval could not carry it: the pack writes here, after render.)
|
|
4174
|
+
let packTitle: string | undefined;
|
|
4175
|
+
if (youtube) {
|
|
4176
|
+
// The approved file wins outright (readApprovedYoutubePack): no cache
|
|
4177
|
+
// lookup, no LLM call — and no provider needed, so an edited pack still
|
|
4178
|
+
// writes its markdown on a --youtube run that carries no --produce.
|
|
4179
|
+
let pack: YoutubePack | undefined = await readApprovedYoutubePack(work);
|
|
4180
|
+
if (pack) {
|
|
4181
|
+
console.log(
|
|
4182
|
+
"▸ youtube: metadata from your edited pack " +
|
|
4183
|
+
`(delete ${YOUTUBE_APPROVED_BASENAME} to regenerate)`,
|
|
4184
|
+
);
|
|
4185
|
+
} else if (!provider) {
|
|
4186
|
+
// The metadata call rides the run's LLM provider; a run without one
|
|
4187
|
+
// (no --produce) has nothing to call. Said out loud rather than
|
|
4188
|
+
// silently — the user typed/configured --youtube. The thumbnail (Y3)
|
|
4189
|
+
// is a separate API keyed by GEMINI_API_KEY and still attempts.
|
|
4190
|
+
console.log("▸ youtube: metadata needs an LLM provider — skipped (thumbnail unaffected)");
|
|
4191
|
+
} else {
|
|
4192
|
+
// Beat-sheet cache shape: keyed on everything that changes the answer —
|
|
4193
|
+
// who is asked, with what editorial steer, about which words.
|
|
4194
|
+
const packKey = createHash("sha1")
|
|
4195
|
+
.update(
|
|
4196
|
+
JSON.stringify([
|
|
4197
|
+
// Prompt changes change the answer (the §78 posture): the v2
|
|
4198
|
+
// rewrite must not serve a pack cached under v1's questions.
|
|
4199
|
+
YOUTUBE_PROMPT_VERSION,
|
|
4200
|
+
providerName,
|
|
4201
|
+
opts.llmModel ?? "",
|
|
4202
|
+
opts.intent ?? "",
|
|
4203
|
+
// Steer, so part of the key — a changed audience is a different
|
|
4204
|
+
// pack, not a cache hit.
|
|
4205
|
+
audience ?? "",
|
|
4206
|
+
transcript.words.map((w) => w.text),
|
|
4207
|
+
// The stamped transcript's [m:ss] marks come from the CUT MAP,
|
|
4208
|
+
// not the words — a re-cut with identical words moves every
|
|
4209
|
+
// chapter stamp, and a word-only key would serve the stale
|
|
4210
|
+
// chapters (v2 review gap, 2026-08-17). Spans, rounded to ms,
|
|
4211
|
+
// pin the timeline the stamps were computed on.
|
|
4212
|
+
map.spans.map((s) => [Math.round(s.srcIn * 1000), Math.round(s.outIn * 1000)]),
|
|
4213
|
+
]),
|
|
4214
|
+
)
|
|
4215
|
+
.digest("hex")
|
|
4216
|
+
.slice(0, 8);
|
|
4217
|
+
const packCache = join(work, `youtube-${packKey}.json`);
|
|
4218
|
+
if (existsSync(packCache)) {
|
|
4219
|
+
pack = YoutubePackSchema.parse(JSON.parse(await readFile(packCache, "utf8")));
|
|
4220
|
+
console.log("▸ youtube: metadata cached");
|
|
4221
|
+
} else {
|
|
4222
|
+
try {
|
|
4223
|
+
pack = await phases.time("llm", () =>
|
|
4224
|
+
generateYoutubePack(provider!, {
|
|
4225
|
+
// Sentence lines stamped with OUTPUT-clock times (prompt v2):
|
|
4226
|
+
// the words carry SOURCE seconds and `map` translates them, so
|
|
4227
|
+
// the chapters the model returns are measured, not guessed —
|
|
4228
|
+
// the one thing a paste-a-transcript prompt tool cannot do.
|
|
4229
|
+
transcriptText: stampedTranscript(transcript.words, map),
|
|
4230
|
+
intent: opts.intent,
|
|
4231
|
+
hook: beatSheet?.hook,
|
|
4232
|
+
coverText: beatSheet?.coverText,
|
|
4233
|
+
audience,
|
|
4234
|
+
durationSec: map.outputDuration,
|
|
4235
|
+
}),
|
|
4236
|
+
);
|
|
4237
|
+
await writeFile(packCache, JSON.stringify(pack, null, 2));
|
|
4238
|
+
} catch (err) {
|
|
4239
|
+
// NEVER cache a failure (§106), and never fail the produce that
|
|
4240
|
+
// just rendered over a metadata sidecar: one loud line, the video
|
|
4241
|
+
// and cover stand, the next run retries the call.
|
|
4242
|
+
console.log(
|
|
4243
|
+
` ⚠ youtube metadata unavailable: ${err instanceof Error ? err.message : String(err)}\n` +
|
|
4244
|
+
" (not cached — the next run retries the pass)",
|
|
4245
|
+
);
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
if (pack) {
|
|
4250
|
+
youtubeMdPath = artifactPath(outPath, ".youtube.md");
|
|
4251
|
+
await writeFile(youtubeMdPath, formatYoutubeMarkdown(pack));
|
|
4252
|
+
console.log(`✓ youtube pack → ${youtubeMdPath}`);
|
|
4253
|
+
packTitle = pack.titles[0];
|
|
4254
|
+
}
|
|
4255
|
+
// ---- AI thumbnail (Y3, 2026-08-16) ------------------------------------
|
|
4256
|
+
// Shares the gate and `portrait` above but not the provider's KEY — its
|
|
4257
|
+
// credential is GEMINI_API_KEY, env-only (secrets never in config.json,
|
|
4258
|
+
// env.ts:7-9 rule; env-file loading already ran at CLI entry), so a
|
|
4259
|
+
// metadata skip must not skip it. The concept call does still need the
|
|
4260
|
+
// run's text provider; thumbnailStep says so out loud when it's absent.
|
|
4261
|
+
// Consumer-side validation, the `portrait` posture above: config.json
|
|
4262
|
+
// is hand-edited and unparsed, so a non-string `thumbnailModel` falls
|
|
4263
|
+
// back to the default rather than reaching the API as garbage.
|
|
4264
|
+
const thumbnailModel =
|
|
4265
|
+
typeof cfg.thumbnailModel === "string" ? cfg.thumbnailModel : THUMBNAIL_MODEL_DEFAULT;
|
|
4266
|
+
const thumbnail = await thumbnailStep({
|
|
4267
|
+
youtube,
|
|
4268
|
+
portraitPath: portrait,
|
|
4269
|
+
apiKey: geminiKey,
|
|
4270
|
+
model: thumbnailModel,
|
|
4271
|
+
work,
|
|
4272
|
+
outPath,
|
|
4273
|
+
// The run's provider is `LlmProvider | null`; the step's "absent" is
|
|
4274
|
+
// undefined, matching thumbnailDecision's optional-argument shape.
|
|
4275
|
+
provider: provider ?? undefined,
|
|
4276
|
+
providerName,
|
|
4277
|
+
llmModel: opts.llmModel,
|
|
4278
|
+
intent: opts.intent,
|
|
4279
|
+
hook: beatSheet?.hook,
|
|
4280
|
+
audience,
|
|
4281
|
+
brief: thumbnailBrief,
|
|
4282
|
+
titleAngle: packTitle,
|
|
4283
|
+
transcriptWords: transcript.words.map((w) => w.text),
|
|
4284
|
+
time: (fn) => phases.time("llm", fn),
|
|
4285
|
+
});
|
|
4286
|
+
thumbnailPath = thumbnail?.path;
|
|
4287
|
+
if (thumbnail && interactive && geminiKey) {
|
|
4288
|
+
// Post-generation retry (thumbnail UX, 2026-08-16): the image call is
|
|
4289
|
+
// seconds where the render was minutes, so an unwanted result is cheap
|
|
4290
|
+
// to redo NOW — the concept stays fixed, only the image re-rolls with
|
|
4291
|
+
// the user's note.
|
|
4292
|
+
await thumbnailRetryLoop({
|
|
4293
|
+
imagePath: thumbnail.path,
|
|
4294
|
+
imageCachePath: thumbnail.imageCachePath,
|
|
4295
|
+
concept: thumbnail.concept,
|
|
4296
|
+
apiKey: geminiKey,
|
|
4297
|
+
model: thumbnailModel,
|
|
4298
|
+
portrait: thumbnail.portrait,
|
|
4299
|
+
generate: generateThumbnailImage,
|
|
4300
|
+
});
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
2513
4303
|
// Record THIS invocation so the editor's Render button can replay it (R11
|
|
2514
4304
|
// Task 4). Nothing else can reconstruct it — production.json has the
|
|
2515
4305
|
// source path, cleanup and intent, but not --produce, --out or the LLM
|
|
@@ -2550,11 +4340,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2550
4340
|
// because the EDITOR hid them would freeze an edit the user may later
|
|
2551
4341
|
// undo in that same editor. See recordedProduceArgs for why the pin is
|
|
2552
4342
|
// unconditional even though captions' default is config-independent today.
|
|
4343
|
+
// Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
|
|
4344
|
+
// argv — "auto" has no flag spelling and stays unpinned (see
|
|
4345
|
+
// recordedProduceArgs for why that is safe today).
|
|
4346
|
+
// Youtube pin: the watermark's config-dependent-default rationale exactly —
|
|
4347
|
+
// resolved both ways, so a later config edit can't flip what Render
|
|
4348
|
+
// replays. Portrait and dictionary pin the RESOLVED values (a path and
|
|
4349
|
+
// terms, never a secret) for the same reason; recordedProduceArgs owns
|
|
4350
|
+
// the non-empty/includes guards.
|
|
2553
4351
|
const recordedArgs = recordedProduceArgs({
|
|
2554
4352
|
llm: provider ? providerName : undefined,
|
|
2555
4353
|
clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
|
|
2556
4354
|
watermark,
|
|
2557
4355
|
captions: opts.captions ?? true,
|
|
4356
|
+
jumpCuts: jumpCutsMode,
|
|
4357
|
+
dictionary,
|
|
4358
|
+
youtube,
|
|
4359
|
+
portrait,
|
|
4360
|
+
audience,
|
|
4361
|
+
thumbnailBrief,
|
|
2558
4362
|
});
|
|
2559
4363
|
await writeFile(
|
|
2560
4364
|
join(work, "command.json"),
|
|
@@ -2579,7 +4383,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2579
4383
|
if (isInteractive()) {
|
|
2580
4384
|
printProductionCompleteBanner({
|
|
2581
4385
|
outPath,
|
|
2582
|
-
|
|
4386
|
+
// `opts.coverPath ?? artifactPath(...)`, matching the cover write above.
|
|
4387
|
+
// The old check here was `typeof opts.cover === "string"` — stale since
|
|
4388
|
+
// the cover/coverPath split, so an explicit --cover <path> banner'd the
|
|
4389
|
+
// default path instead of the file actually written.
|
|
4390
|
+
coverPath: opts.cover !== false ? opts.coverPath ?? artifactPath(outPath, ".cover.jpg") : undefined,
|
|
4391
|
+
youtubePath: youtubeMdPath,
|
|
4392
|
+
thumbnailPath,
|
|
2583
4393
|
sourceDurationSec: sourceProbe.duration,
|
|
2584
4394
|
outputDurationSec: map.outputDuration,
|
|
2585
4395
|
sceneCount: scenes.length,
|