ossclip 0.1.31 → 0.1.34

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/src/produce.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  statSync,
12
12
  } from "node:fs";
13
13
  import { cpus } from "node:os";
14
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
14
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
15
15
  import { z } from "zod/v4";
16
16
  import {
17
17
  LayoutSchema,
@@ -28,6 +28,7 @@ import {
28
28
  remapSceneOverrides,
29
29
  assembleScenes,
30
30
  buildCaptionLines,
31
+ captionPackingFor,
31
32
  buildCutlist,
32
33
  canonicalizeDictionaryCasing,
33
34
  captionsNeedNastaliq,
@@ -45,6 +46,9 @@ import {
45
46
  COVER_PROVENANCE_BASENAME,
46
47
  coverDecision,
47
48
  coverHeadline,
49
+ coverInVideoWindow,
50
+ COVER_IN_VIDEO_CAP_SEC,
51
+ COVER_IN_VIDEO_FLOOR_SEC,
48
52
  readCoverProvenance,
49
53
  writeCoverProvenance,
50
54
  cropFilter,
@@ -97,6 +101,10 @@ import {
97
101
  thumbnailImageCacheName,
98
102
  applyCleanupChoices,
99
103
  vetoedRemovals,
104
+ dismissedRemovals,
105
+ carveKeptTakes,
106
+ resolveSplitPoints,
107
+ resolveSrcTimingPins,
100
108
  applyUserCuts,
101
109
  pruneHidesInsideCuts,
102
110
  loadConfig,
@@ -158,7 +166,12 @@ import {
158
166
  AGY_PRINT_TIMEOUT,
159
167
  type Production,
160
168
  ossclipOutputPathFor,
169
+ resolveOutputFrame,
170
+ RESOLUTION_CHOICES,
171
+ smallestSource,
172
+ ResolutionChoiceSchema,
161
173
  type ProviderName,
174
+ type ResolutionChoice,
162
175
  type Scene,
163
176
  type SceneComponentId,
164
177
  type Segment,
@@ -248,6 +261,14 @@ export const TranscriptKeySchema = z.object({
248
261
  * runs) means "no biasing".
249
262
  */
250
263
  dictionary: z.array(z.string()).optional(),
264
+ /**
265
+ * Whether whisper ran its TRANSLATE task (`-tr`, 2026-08-29). It changes
266
+ * the decoded TEXT — Urdu speech comes back as English words — so it
267
+ * re-keys the cache exactly like the language does, or a warm workdir
268
+ * serves the Urdu-script transcript to a translate run. Absent (old key
269
+ * files) means "no translation", the `dictionary` contract.
270
+ */
271
+ translate: z.boolean().optional(),
251
272
  });
252
273
  export type TranscriptKey = z.infer<typeof TranscriptKeySchema>;
253
274
 
@@ -272,6 +293,9 @@ export function transcriptCacheReusable(
272
293
  // "" and absent both mean whisper's en default — program.ts rejects an
273
294
  // empty code, but a key file predating that guard must not wedge.
274
295
  (effective.language ?? "") === (requested.language ?? "") &&
296
+ // Absent and false are the same "no translation", so pre-flag key
297
+ // files reuse under a non-translate request.
298
+ (effective.translate ?? false) === (requested.translate ?? false) &&
275
299
  // ORDER-SENSITIVE by choice: the dictionary becomes whisper's --prompt
276
300
  // text verbatim, so a reordered list genuinely is a different decoder
277
301
  // input — treating it as equal would serve a transcript biased by a
@@ -529,6 +553,13 @@ export interface ProduceOptions {
529
553
  * decodes garbage (Urdu field test 2026-08-05).
530
554
  */
531
555
  whisperLanguage?: string;
556
+ /**
557
+ * `--whisper-translate`: whisper's TRANSLATE task (`-tr`) — non-English
558
+ * speech decoded straight to ENGLISH text. Distinct from
559
+ * `whisperLanguage`, which says what is SPOKEN; the two are passed
560
+ * together (whisper decodes better knowing the source language).
561
+ */
562
+ whisperTranslate?: boolean;
532
563
  /**
533
564
  * Vocabulary terms for this run (`--dictionary`, F4 2026-08-16), already
534
565
  * split/trimmed by the action. Wholesale beats the config's `dictionary`
@@ -576,6 +607,16 @@ export interface ProduceOptions {
576
607
  * platform chrome to dodge and a landscape source needs no cropping at all.
577
608
  */
578
609
  aspect?: "9:16" | "16:9";
610
+ /**
611
+ * `--resolution <height>`: how big the output actually renders. `auto`
612
+ * keeps what the source has (capped at 2160); an explicit height scales the
613
+ * 1080-wide composition to it. Already validated by commander (or by
614
+ * `ResolutionChoiceSchema` when it comes from the config), so this is a
615
+ * choice, never a raw string. `resolveOutputFrame` owns the math and the
616
+ * why; the value reaches THREE stages that would otherwise each pin 1080p:
617
+ * the folder-concat target, the mezzanine, and the render's own scale.
618
+ */
619
+ resolution?: ResolutionChoice;
579
620
  /**
580
621
  * `--concurrency <n>`: how many browser tabs the render opens at once,
581
622
  * beating the config's `renderConcurrency` and the cpus-2 default
@@ -613,6 +654,15 @@ export interface ProduceOptions {
613
654
  * a free-tier limitation; this is voluntary attribution.
614
655
  */
615
656
  watermark?: boolean;
657
+ /**
658
+ * `--cover-in-video` / `--no-cover-in-video` tri-state, the watermark's
659
+ * contract verbatim: true/false when TYPED, undefined when not — undefined
660
+ * lets the config's `coverInVideo` key supply the default
661
+ * (`resolveCoverInVideo`). Default OFF: the overlay paints over the first
662
+ * fraction of the hook, which only earns its place on the platforms that
663
+ * ignore an uploaded cover.
664
+ */
665
+ coverInVideo?: boolean;
616
666
  /**
617
667
  * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
618
668
  * true/false when TYPED, undefined when not — undefined lets the config's
@@ -692,6 +742,92 @@ export function resolveWatermark(
692
742
  return flag ?? configValue === true;
693
743
  }
694
744
 
745
+ /**
746
+ * The effective `--resolution` — `resolveWatermark`'s precedence verbatim: a
747
+ * TYPED flag always wins, and only then does the config supply the default.
748
+ *
749
+ * The config side is ZOD-PARSED rather than trusted: `loadConfig` hands back
750
+ * whatever the hand-editable JSON held, and an unparsed `"4k"` would reach
751
+ * `Number("4k")` inside `resolveOutputFrame` as NaN and size the whole render
752
+ * off it. A malformed value earns one warning naming the key and falls back
753
+ * to 1080 — the value every existing run already produces, so a typo costs a
754
+ * message rather than a surprise 4K render (CLAUDE.md: parse, never coerce).
755
+ * Pure, so the flag × config matrix is testable without a config file.
756
+ */
757
+ export function resolveResolution(
758
+ flag: ResolutionChoice | undefined,
759
+ configValue: unknown,
760
+ ): ResolutionChoice {
761
+ if (flag !== undefined) return flag;
762
+ if (configValue === undefined) return "1080";
763
+ const parsed = ResolutionChoiceSchema.safeParse(configValue);
764
+ if (parsed.success) return parsed.data;
765
+ console.log(
766
+ `⚠ config resolution ignored — expected one of ${RESOLUTION_CHOICES.join(", ")}, using 1080`,
767
+ );
768
+ return "1080";
769
+ }
770
+
771
+ /**
772
+ * The effective `--cover-in-video` switch — resolveWatermark's semantics
773
+ * verbatim: a TYPED flag always wins (so `--no-cover-in-video` beats a
774
+ * config-on), and only then does the config supply the default. The config
775
+ * side is `=== true`, never truthiness: a hand-edited `"coverInVideo": "yes"`
776
+ * must not coerce an overlay onto the first frames of every render
777
+ * (parse-don't-coerce, CLAUDE.md). Pure, so the whole flag × config matrix is
778
+ * testable without a config file on disk.
779
+ */
780
+ export function resolveCoverInVideo(
781
+ flag: boolean | undefined,
782
+ configValue: boolean | undefined,
783
+ ): boolean {
784
+ return flag ?? configValue === true;
785
+ }
786
+
787
+ /**
788
+ * Where the cover image lives right now, most-specific first — the EDITOR
789
+ * panel's ladder (`currentCoverImage` in edit.ts), deliberately the same one:
790
+ * the destination the last cover render used (`cover.json`'s `out`), else
791
+ * this run's `<out>.cover.jpg`. Two surfaces disagreeing about which file IS
792
+ * the project's cover is how the overlay would end up showing a stale image
793
+ * the panel says was replaced.
794
+ *
795
+ * Pure — the caller owns the `existsSync` walk — so the ladder is assertable
796
+ * without a workdir. Note what it CANNOT return: the cover this run is about
797
+ * to write, which does not exist yet (produce renders the cover after the
798
+ * video). See the staging site for why that is the intended behavior.
799
+ */
800
+ export function coverInVideoCandidates(p: {
801
+ provenanceOut?: string | null;
802
+ outPath: string;
803
+ }): string[] {
804
+ const out: string[] = [];
805
+ if (p.provenanceOut) out.push(p.provenanceOut);
806
+ out.push(artifactPath(p.outPath, ".cover.jpg"));
807
+ return out;
808
+ }
809
+
810
+ /**
811
+ * Fixed subfolder the staged cover overlay lands in, never the public dir's
812
+ * root — `SIDE_IMAGE_SUBDIR`'s reasoning applied to a file produce names
813
+ * itself: the public dir can BE the user's own input folder (a --no-mezzanine
814
+ * file run), and a root-level `cover-in-video.jpg` would silently overwrite a
815
+ * file of theirs that happened to share the name. Nothing else writes into
816
+ * this subfolder, so a collision is impossible by construction.
817
+ */
818
+ export const COVER_IN_VIDEO_SUBDIR = "cover-in-video";
819
+
820
+ /**
821
+ * The staged overlay's SERVED name, from the cover image being copied. Keeps
822
+ * the source's own extension (lowercased) so a `.png` cover is not served as
823
+ * a `.jpg`, and stays POSIX-literal — never `path.join` — because this string
824
+ * is a URL read back by `staticFile()` and the editor's `/media/` mount, both
825
+ * of which split on `/` only (sideImageDestRel's Windows lesson).
826
+ */
827
+ export function coverInVideoFileName(source: string): string {
828
+ return `${COVER_IN_VIDEO_SUBDIR}/cover${extname(source).toLowerCase()}`;
829
+ }
830
+
695
831
  /**
696
832
  * The effective `--youtube` switch — resolveWatermark's semantics verbatim:
697
833
  * a TYPED flag always wins (so `--no-youtube` beats a config-on), and only
@@ -1634,14 +1770,10 @@ export function resolveCaptionsHidden(
1634
1770
  * stay byte-identical. Pure so the matrix is testable without a produce
1635
1771
  * run.
1636
1772
  */
1637
- export function captionPackingFor(landscape: boolean): {
1638
- maxWordsPerLine: number;
1639
- maxLineDuration: number;
1640
- } {
1641
- return landscape
1642
- ? { maxWordsPerLine: 6, maxLineDuration: 2.4 }
1643
- : { maxWordsPerLine: 3, maxLineDuration: 1.2 };
1644
- }
1773
+ // Moved to core (captions.ts) so the editor's live caption rebuild packs
1774
+ // with the SAME matrix (cut-review rework follow-up: captions over revived
1775
+ // material). Re-exported here so existing imports and tests keep working.
1776
+ export { captionPackingFor } from "@ossclip/core";
1645
1777
 
1646
1778
  function sha1File(path: string): Promise<string> {
1647
1779
  return new Promise((res, rej) => {
@@ -2005,6 +2137,46 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2005
2137
 
2006
2138
  const tools = { ffmpegPath: cfg.ffmpegPath, ffprobePath: cfg.ffprobePath };
2007
2139
 
2140
+ // `--resolution` (2026-08-27), resolved before ANY stage that sizes pixels:
2141
+ // the folder concat, the mezzanine and the render each used to pin 1080p
2142
+ // independently, so a 4K take lost three quarters of its pixels before
2143
+ // anything looked at it. `auto` needs the SOURCE's size, which for a folder
2144
+ // means probing the clips here — the concat target is chosen before the
2145
+ // concatenated file (and its probe) exists.
2146
+ const resolution = resolveResolution(opts.resolution, cfg.resolution);
2147
+ const autoSource = async (): Promise<{ width: number; height: number } | null> => {
2148
+ if (resolution !== "auto") return null;
2149
+ if (isFolder && folderListing) {
2150
+ // Metadata-only probes, and only under `auto`: the default path adds no
2151
+ // ffprobe calls at all (the 4m32s probe-storm lesson, concat.ts:272).
2152
+ const sizes: Array<{ width: number; height: number }> = [];
2153
+ for (const entry of folderListing.entries) {
2154
+ try {
2155
+ const p = await probe(tools, join(input, entry.name));
2156
+ sizes.push({ width: p.width, height: p.height });
2157
+ } catch {
2158
+ // A clip that will not probe is the concat guard's problem, not
2159
+ // this sizing pass's — skip it rather than fail the run here.
2160
+ }
2161
+ }
2162
+ return smallestSource(sizes);
2163
+ }
2164
+ try {
2165
+ const p = await probe(tools, input);
2166
+ return { width: p.width, height: p.height };
2167
+ } catch {
2168
+ return null;
2169
+ }
2170
+ };
2171
+ const output = resolveOutputFrame({
2172
+ frame,
2173
+ source: (await autoSource()) ?? { width: 0, height: 0 },
2174
+ resolution,
2175
+ });
2176
+ if (output.scale !== 1) {
2177
+ console.log(`▸ resolution: ${output.width}x${output.height} (${resolution})`);
2178
+ }
2179
+
2008
2180
  // Resolved ONCE for the whole run — whisper biasing, repair vouching and
2009
2181
  // caption casing must all see the same list, or the passes disagree about
2010
2182
  // what a term is spelled like. A typed --dictionary wholesale beats the
@@ -2109,9 +2281,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2109
2281
  if (isFolder && folderListing) {
2110
2282
  const sort = opts.sort ?? "name";
2111
2283
  const result = await phases.time("ffmpeg", () =>
2284
+ // The concat target carries the RESOLVED size, not the base frame:
2285
+ // letterboxing every take into 1080p here would throw the pixels away
2286
+ // before the mezzanine or the render ever saw them (`--resolution`).
2112
2287
  concatFolder(tools, input, folderListing!, work, sort, {
2113
- w: frame.width,
2114
- h: frame.height,
2288
+ w: output.width,
2289
+ h: output.height,
2115
2290
  }),
2116
2291
  );
2117
2292
  console.log(
@@ -2231,6 +2406,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2231
2406
  const requestedKey: TranscriptKey = {
2232
2407
  model: requestedModel,
2233
2408
  ...(whisperLang.language !== undefined ? { language: whisperLang.language } : {}),
2409
+ // Omitted when off, so a non-translate run's key stays byte-identical to
2410
+ // every pre-flag key file (the dictionary posture).
2411
+ ...(opts.whisperTranslate === true ? { translate: true } : {}),
2234
2412
  // Omitted when empty, not written as [] — pre-dictionary key files have
2235
2413
  // no `dictionary` at all, and transcriptCacheReusable reads absent and
2236
2414
  // empty as the same "no biasing", so old workdirs must not re-transcribe.
@@ -2301,6 +2479,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2301
2479
  language: requestedKey.language,
2302
2480
  // Vocabulary biasing (F4) — undefined for an empty dictionary, so
2303
2481
  // the spawned args stay byte-identical to every pre-dictionary run.
2482
+ // From the KEY, like the language: whatever re-keys the cache is
2483
+ // what actually ran, so the two can never disagree.
2484
+ ...(requestedKey.translate === true ? { translate: true } : {}),
2304
2485
  prompt: whisperPromptFor(dictionary),
2305
2486
  },
2306
2487
  audioPath,
@@ -3132,10 +3313,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3132
3313
  // a duration hint a few seconds short — never a wrong cut.
3133
3314
  const cutlistProposed = cutlist;
3134
3315
  const cleanupVetoed = vetoedRemovals(cutlist, overrideDoc.cleanup);
3135
- if (cleanupVetoed.length > 0) {
3316
+ // Dismissed proposals ("not a retake", cut-review rework 2026-08-26)
3317
+ // re-keep exactly like vetoes — applyCleanupChoices owns the union — but
3318
+ // get their own line: a dismissal is "the classification was wrong", not
3319
+ // "kept this once", and the two must not read as one number.
3320
+ const cleanupDismissed = dismissedRemovals(cutlist, overrideDoc.cleanup);
3321
+ if (cleanupVetoed.length > 0 || cleanupDismissed.length > 0) {
3136
3322
  cutlist = applyCleanupChoices(cutlist, overrideDoc.cleanup);
3137
3323
  map = new TimeMap(cutlist);
3138
- console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
3324
+ if (cleanupVetoed.length > 0) console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
3325
+ if (cleanupDismissed.length > 0) {
3326
+ const sec = cleanupDismissed.reduce((s, seg) => s + (seg.srcOut - seg.srcIn), 0);
3327
+ console.log(
3328
+ `▸ dismissed ${cleanupDismissed.length} marker(s) — ${sec.toFixed(1)}s kept as footage`,
3329
+ );
3330
+ }
3139
3331
  }
3140
3332
  // `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
3141
3333
  // no `src` yet) and any already-re-anchored splits/pins are expressed
@@ -3164,6 +3356,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3164
3356
  cutlist = cutResult.cutlist;
3165
3357
  map = cutResult.map;
3166
3358
  overrideDoc = cutResult.doc;
3359
+ // Backfill `splits[].src` ONCE for src-less legacy entries (cut-review
3360
+ // rework, 2026-08-26) — the `resolveCutSourceRanges` posture applied to
3361
+ // splits: after `applyUserCuts`, a legacy `at` speaks THIS run's clock
3362
+ // (remapped when the frames differed), so `map.toSource(at)` is exact.
3363
+ // Gated on `priorMap` like the cuts resolution: with no prior render-props
3364
+ // the frame `at` was drawn against is unknowable, and a guessed src would
3365
+ // be a wrong anchor forever. `at` stays verbatim — the historical record,
3366
+ // per SplitSchema.
3367
+ let splitSrcResolved = false;
3368
+ if (priorMap !== null) {
3369
+ const withSrc = overrideDoc.splits.map((s) => {
3370
+ if (s.src !== undefined || s.at === undefined) return s;
3371
+ splitSrcResolved = true;
3372
+ return { ...s, src: Math.round(map.toSource(s.at) * 1000) / 1000 };
3373
+ });
3374
+ if (splitSrcResolved) {
3375
+ overrideDoc = { ...overrideDoc, splits: withSrc };
3376
+ console.log(`▸ anchored ${withSrc.filter((s) => s.src !== undefined).length} split(s) to source time`);
3377
+ }
3378
+ }
3167
3379
  if (overrideDoc.cuts.length > 0) {
3168
3380
  console.log(
3169
3381
  `▸ ${overrideDoc.cuts.length} user cut(s) removed ${cutResult.removedSec.toFixed(1)}s ` +
@@ -3402,7 +3614,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3402
3614
  // reshape `map` in time) — applied here, AFTER assembly and routing, so
3403
3615
  // hand edits sit on top of whatever the producer just planned. Never
3404
3616
  // written to production.json — that file is ours to overwrite.
3405
- const { cues: editedCues } = applyOverrides(routed.cues, overrideDoc);
3617
+ // Src-anchored pins (SceneTimingSchema) resolved onto THIS run's clock
3618
+ // before anything merges them — the same posture as `resolveSplitPoints`
3619
+ // below, and the reason `applyOverrides` never takes a map. A LOCAL doc,
3620
+ // deliberately not written back over `overrideDoc`: its `timing` entries
3621
+ // are now output seconds, and letting one of those reach the sanctioned
3622
+ // overrides.json write would spend the source anchor and put the pin back
3623
+ // on a clock the next re-cut moves.
3624
+ const pinnedDoc = resolveSrcTimingPins(overrideDoc, map);
3625
+ for (const r of pinnedDoc.reports) console.log(` ⚠ ${r}`);
3626
+ const { cues: editedCues } = applyOverrides(routed.cues, pinnedDoc.doc);
3406
3627
  // Scenes the user deleted in the editor drop here — their windows become
3407
3628
  // plain takes in the fill below, which is Task C's payoff for Task A.
3408
3629
  // `splitThenDropHidden`, not a bare `dropHiddenCues`: this runs before
@@ -3439,10 +3660,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3439
3660
  // are reported from THIS pass: only now does the id universe include the
3440
3661
  // takes, so a `take-2-1` edit whose take merged away reports here instead
3441
3662
  // of every take id reporting on the first pass.
3442
- const filled = fillPlainCues(reclamped, {
3663
+ // Kept/dismissed removal edges ride along as extra clipStarts (cut-review
3664
+ // rework, 2026-08-26): a veto merges two kept spans into one, which would
3665
+ // otherwise change the clip count and silently re-target every `take-N`
3666
+ // framing edit after it (§155's misapply class). With the edges preserved,
3667
+ // the fill still cuts where the boundaries were, and `carveKeptTakes`
3668
+ // below owns the revived middle.
3669
+ const keptRangesForCarve = [
3670
+ ...cleanupVetoed.map((seg) => ({ srcIn: seg.srcIn, srcOut: seg.srcOut })),
3671
+ ...cleanupDismissed.map((seg) => ({ srcIn: seg.srcIn, srcOut: seg.srcOut, dismissed: true })),
3672
+ ];
3673
+ const keptEdgeStarts = keptRangesForCarve.flatMap((r) => {
3674
+ const a = map.toOutput(r.srcIn);
3675
+ const b = map.toOutput(r.srcOut);
3676
+ return [...(a !== null ? [a] : []), ...(b !== null ? [b] : [])];
3677
+ });
3678
+ const filled0 = fillPlainCues(reclamped, {
3443
3679
  outputDurationSec: map.outputDuration,
3444
- clipStarts: map.spans.map((s) => s.outIn),
3680
+ clipStarts: [...map.spans.map((s) => s.outIn), ...keptEdgeStarts],
3445
3681
  });
3682
+ // Revived material as a first-class block — same carve the editor previews
3683
+ // with (`carveKeptTakes`'s one-implementation-two-callers doc), so
3684
+ // `take-kept-*` ids exist server-side and framing edits on them land in
3685
+ // the second override pass below instead of orphaning.
3686
+ const { cues: filled, reports: carveReports } = carveKeptTakes(filled0, keptRangesForCarve, map);
3687
+ for (const r of carveReports) console.log(` ⚠ ${r}`);
3446
3688
  // User splits (R16 §61) — after the fill so takes split like scenes, and
3447
3689
  // before the final override pass so edits on the `id@<split id>` halves land
3448
3690
  // (the suffix is the split's own minted id, §137, not its time). A
@@ -3451,11 +3693,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3451
3693
  // is a no-op for that scene (the split point sits exactly on the joint
3452
3694
  // between the two halves, matching neither), so this call stays the one
3453
3695
  // that actually cuts TAKE ids, which don't exist until the fill just ran.
3454
- const split = splitCues(filled, overrideDoc.splits);
3696
+ // Resolved onto THIS run's clock: `src` (the recut-immune anchor) wins,
3697
+ // src-less legacy entries pass their re-anchored `at` through, and a src
3698
+ // sitting in removed material is inert with a report (resolveSplitPoints'
3699
+ // doc owns the posture).
3700
+ const resolvedSplits = resolveSplitPoints(overrideDoc.splits, map);
3701
+ for (const r of resolvedSplits.reports) console.log(` ⚠ ${r}`);
3702
+ const split = splitCues(filled, resolvedSplits.points);
3455
3703
  if (overrideDoc.splits.length > 0) {
3456
3704
  console.log(`▸ ${overrideDoc.splits.length} scene split(s) from the edit layer`);
3457
3705
  }
3458
- const { cues: mergedCues, orphans: rawOrphans } = applyOverrides(split, overrideDoc);
3706
+ // `pinnedDoc.doc` again, not `overrideDoc`: this pass is on the SAME clock
3707
+ // as the first (the split above does not move time), and a src pin that
3708
+ // reached here unresolved would be ignored rather than applied.
3709
+ const { cues: mergedCues, orphans: rawOrphans } = applyOverrides(split, pinnedDoc.doc);
3459
3710
  // Halves of a TAKE the user deleted after splitting: a take id only exists
3460
3711
  // once the fill above runs, so its `id@<split id>` half couldn't have been seen by
3461
3712
  // `splitThenDropHidden` earlier (that pass only ever saw graphic scenes).
@@ -3686,7 +3937,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3686
3937
  scenes: scenes.length > 0 ? scenes : undefined,
3687
3938
  producer: producerStamp,
3688
3939
  theme,
3689
- render: { ...frame, fps: 30 },
3940
+ // The EFFECTIVE output size, not the base frame (`--resolution`): this is
3941
+ // what the file on disk actually is, and it is read downstream by the
3942
+ // mezzanine's scale decision, the NLE exports and the editor — all of
3943
+ // which would otherwise describe a 1080p file that isn't there.
3944
+ render: { width: output.width, height: output.height, fps: 30 },
3690
3945
  };
3691
3946
  await writeFile(join(work, "production.json"), JSON.stringify(production, null, 2));
3692
3947
 
@@ -3821,7 +4076,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3821
4076
  // by name below, and a run that cannot anchor one today is not permission to
3822
4077
  // delete it — the next run, against a different cut, may place it (final
3823
4078
  // review, Critical 1).
3824
- const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines);
4079
+ const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines, map);
3825
4080
  overrideDoc = captionWork.doc;
3826
4081
  const captionLines = captionWork.lines;
3827
4082
  const captionKeysReanchored = captionWork.reanchored;
@@ -4155,6 +4410,71 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4155
4410
  );
4156
4411
  }
4157
4412
 
4413
+ // ---- Cover overlay on the opening frames (`--cover-in-video`) -----------
4414
+ // Resolved and staged HERE, next to the props it feeds, the watermark's
4415
+ // posture: one ▸ line when it is on, so a config-sourced overlay never
4416
+ // surprises the author on upload.
4417
+ //
4418
+ // THE STAGED FILE IS THE CURRENT COVER AT RENDER TIME, and that is the
4419
+ // whole state model: this run's own cover has not been rendered yet
4420
+ // (produce writes it after the video, from the finished render's own
4421
+ // geometry), so what gets copied is the cover the project has RIGHT NOW —
4422
+ // the one `ossclip cover`, the editor's regenerate button, or the previous
4423
+ // produce left behind, resolved down the panel's own ladder
4424
+ // (`coverInVideoCandidates`). A regenerated cover therefore lands in the
4425
+ // NEXT render and preview with no extra plumbing, exactly like a headline
4426
+ // edit in `cover.json`. A project with no cover on disk at all (the first
4427
+ // ever run) gets one ⚠ line and NO prop — an absent key is the
4428
+ // absent-means-off contract, so the render is byte-identical to an
4429
+ // overlay-less one rather than half-wired.
4430
+ //
4431
+ // Staged into the render's public dir AND the workdir when they differ, for
4432
+ // the Nastaliq font's reason verbatim: the render bundles
4433
+ // `dirname(renderVideo)`, `ossclip edit` serves the workdir at `/media/`,
4434
+ // and both mounts fetch the same name.
4435
+ const coverInVideoOn = resolveCoverInVideo(opts.coverInVideo, cfg.coverInVideo);
4436
+ let coverInVideo: { fileName: string; durationSec: number } | undefined;
4437
+ if (coverInVideoOn) {
4438
+ const candidates = coverInVideoCandidates({
4439
+ provenanceOut: (await readCoverProvenance(work))?.out,
4440
+ outPath,
4441
+ });
4442
+ const source = candidates.find((p) => existsSync(p));
4443
+ if (source === undefined) {
4444
+ // Deliberately does NOT promise this run's cover: `--no-cover` may mean
4445
+ // there will not be one, and a produce that says "next time" and then
4446
+ // never delivers is worse than one that names what it looked for.
4447
+ console.log(
4448
+ ` ⚠ cover in video: no cover image yet (looked for ` +
4449
+ `${candidates.join(", ")}) — no overlay this run; ` +
4450
+ `it uses the cover a previous run or \`ossclip cover\` leaves behind`,
4451
+ );
4452
+ } else {
4453
+ const fileName = coverInVideoFileName(source);
4454
+ for (const dir of new Set([renderPublicDirPath, work])) {
4455
+ // `join` on the filesystem side where `fileName` itself stays
4456
+ // POSIX-literal — these are paths, that is a served URL (the
4457
+ // Nastaliq staging's exact split).
4458
+ const dest = join(dir, fileName);
4459
+ mkdirSync(dirname(dest), { recursive: true });
4460
+ copyFileSync(source, dest);
4461
+ }
4462
+ // The window is derived from the OUTPUT clock's first word — the same
4463
+ // caption words the renderer draws, post-cut (core's coverInVideoWindow
4464
+ // owns the bounds). The first line WITH words, not `captionLines[0]`:
4465
+ // an empty leading line would read as "no speech" and take the cap.
4466
+ const durationSec = coverInVideoWindow(
4467
+ captionLines.find((l) => l.words.length > 0)?.words ?? [],
4468
+ { capSec: COVER_IN_VIDEO_CAP_SEC, floorSec: COVER_IN_VIDEO_FLOOR_SEC },
4469
+ );
4470
+ coverInVideo = { fileName, durationSec };
4471
+ console.log(
4472
+ `▸ cover in video: ${basename(source)} over the first ${durationSec.toFixed(2)}s` +
4473
+ `${opts.coverInVideo === undefined ? " (from config; --no-cover-in-video overrides)" : ""}`,
4474
+ );
4475
+ }
4476
+ }
4477
+
4158
4478
  const props = {
4159
4479
  videoFileName: basename(renderVideo),
4160
4480
  spans: [...map.spans],
@@ -4175,7 +4495,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4175
4495
  // ~/.ossclip/config.json's theme the first time anyone touched a color.
4176
4496
  baseTheme: configBaseTheme,
4177
4497
  baseCaptionLines,
4178
- settings: production.render,
4498
+ // The COMPOSITION's size, which is the BASE frame — never
4499
+ // `production.render` (2026-08-27). `production.render` describes the
4500
+ // FILE, and under `--resolution` those differ by the render's `scale`:
4501
+ // Remotion enlarges this composition by that factor, so sizing the
4502
+ // composition from the scaled dims applies it TWICE (2160×3840 became
4503
+ // 4320×7680 frames, which h264_videotoolbox refuses outright, at stitch
4504
+ // time, after every frame had been paid for). The Player reads these too,
4505
+ // and previewing at the base size is what keeps the editor cheap.
4506
+ settings: { ...frame, fps: production.render.fps },
4179
4507
  outputDurationSec: map.outputDuration,
4180
4508
  // The aspect travels with the measurement because the crop math needs it:
4181
4509
  // `object-fit: cover` spills vertically for a portrait source and
@@ -4269,6 +4597,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4269
4597
  // an off run's render-props.json stays byte-identical to a pre-watermark
4270
4598
  // one, so nothing downstream can tell the feature ever shipped.
4271
4599
  ...(watermark ? { watermark: true } : {}),
4600
+ // Written only when the overlay is BOTH switched on and backed by a file
4601
+ // that exists (the staging block above): absent means no overlay, so an
4602
+ // off run's render-props.json — and its rendered pixels — stay identical
4603
+ // to a pre-feature one.
4604
+ ...(coverInVideo ? { coverInVideo } : {}),
4272
4605
  // Same absent-means-default contract, polarity flipped (captions default
4273
4606
  // ON): written only when hidden, so a normal run's render-props.json
4274
4607
  // stays byte-identical to a pre-feature one. `captionsHiddenByFlag` is
@@ -4335,7 +4668,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4335
4668
  // right in memory but wrong on disk, and every re-keyed entry was just
4336
4669
  // printed by name. It does NOT spend the `.bak` — the remap moves keys, it
4337
4670
  // rewrites no values a user would need to recover.
4338
- if (cutResult.changed || captionKeysReanchored || hidesPruned || sceneKeysRemapped) {
4671
+ // `splitSrcResolved` joins for the sceneKeysRemapped reason: the source
4672
+ // anchor exists in memory but not on disk, and skipping the write would
4673
+ // re-resolve (and re-announce) it every run. It does NOT spend the `.bak`
4674
+ // — backfilling adds an anchor, it rewrites no value a user would need to
4675
+ // recover.
4676
+ if (cutResult.changed || captionKeysReanchored || hidesPruned || sceneKeysRemapped || splitSrcResolved) {
4339
4677
  await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
4340
4678
  console.log(overridesWriteLine(cutResult.changed));
4341
4679
  }
@@ -4417,6 +4755,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4417
4755
  // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4418
4756
  // argv — "auto" has no flag spelling and stays unpinned (see
4419
4757
  // recordedProduceArgs for why that is safe today).
4758
+ // Cover-overlay pin: the watermark's config-dependent-default rationale
4759
+ // again, on the flag that paints the first frames — an unpinned record
4760
+ // would gain or lose the overlay under a later `coverInVideo` config edit.
4420
4761
  // Youtube pin: the watermark's config-dependent-default rationale exactly —
4421
4762
  // resolved both ways, so a later config edit can't flip what Render
4422
4763
  // replays. Portrait and dictionary pin the RESOLVED values (a path and
@@ -4430,6 +4771,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4430
4771
  llmEffort,
4431
4772
  clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4432
4773
  watermark,
4774
+ // The SWITCH's resolved state (`coverInVideoOn`), not "did an overlay
4775
+ // actually happen": a run whose cover was simply missing yet still
4776
+ // records `--cover-in-video` is correct — the pin exists so the replay
4777
+ // resolves the same way this run did, and by then the cover may well be
4778
+ // on disk. Same distinction the captions pin draws between the flag and
4779
+ // the editor's override.
4780
+ coverInVideo: coverInVideoOn,
4433
4781
  captions: opts.captions ?? true,
4434
4782
  jumpCuts: jumpCutsMode,
4435
4783
  dictionary,
@@ -4662,6 +5010,10 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4662
5010
  outPath: rawPath,
4663
5011
  browserExecutable: cfg.browserExecutable,
4664
5012
  concurrency: renderConcurrency.concurrency,
5013
+ // The composition stays 1080-wide and Remotion renders it larger
5014
+ // (`--resolution`) — rebuilding the stage at 2160 would keep
5015
+ // `captionFontSizeFor`'s absolute 64px and draw quarter-size captions.
5016
+ scale: output.scale,
4665
5017
  cancelSignal: renderCancel.cancelSignal,
4666
5018
  onPhase: (phase: RenderPhase) => {
4667
5019
  signalPhase = renderSignalPhaseOf(phase);