ossclip 0.1.30 → 0.1.33

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,
@@ -23,8 +23,12 @@ import {
23
23
  analyze,
24
24
  applyOverrides,
25
25
  applyRepairs,
26
+ isParkedOverrideKey,
27
+ parkedOverrideBaseKey,
28
+ remapSceneOverrides,
26
29
  assembleScenes,
27
30
  buildCaptionLines,
31
+ captionPackingFor,
28
32
  buildCutlist,
29
33
  canonicalizeDictionaryCasing,
30
34
  captionsNeedNastaliq,
@@ -42,6 +46,9 @@ import {
42
46
  COVER_PROVENANCE_BASENAME,
43
47
  coverDecision,
44
48
  coverHeadline,
49
+ coverInVideoWindow,
50
+ COVER_IN_VIDEO_CAP_SEC,
51
+ COVER_IN_VIDEO_FLOOR_SEC,
45
52
  readCoverProvenance,
46
53
  writeCoverProvenance,
47
54
  cropFilter,
@@ -94,6 +101,10 @@ import {
94
101
  thumbnailImageCacheName,
95
102
  applyCleanupChoices,
96
103
  vetoedRemovals,
104
+ dismissedRemovals,
105
+ carveKeptTakes,
106
+ resolveSplitPoints,
107
+ resolveSrcTimingPins,
97
108
  applyUserCuts,
98
109
  pruneHidesInsideCuts,
99
110
  loadConfig,
@@ -610,6 +621,15 @@ export interface ProduceOptions {
610
621
  * a free-tier limitation; this is voluntary attribution.
611
622
  */
612
623
  watermark?: boolean;
624
+ /**
625
+ * `--cover-in-video` / `--no-cover-in-video` tri-state, the watermark's
626
+ * contract verbatim: true/false when TYPED, undefined when not — undefined
627
+ * lets the config's `coverInVideo` key supply the default
628
+ * (`resolveCoverInVideo`). Default OFF: the overlay paints over the first
629
+ * fraction of the hook, which only earns its place on the platforms that
630
+ * ignore an uploaded cover.
631
+ */
632
+ coverInVideo?: boolean;
613
633
  /**
614
634
  * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
615
635
  * true/false when TYPED, undefined when not — undefined lets the config's
@@ -689,6 +709,66 @@ export function resolveWatermark(
689
709
  return flag ?? configValue === true;
690
710
  }
691
711
 
712
+ /**
713
+ * The effective `--cover-in-video` switch — resolveWatermark's semantics
714
+ * verbatim: a TYPED flag always wins (so `--no-cover-in-video` beats a
715
+ * config-on), and only then does the config supply the default. The config
716
+ * side is `=== true`, never truthiness: a hand-edited `"coverInVideo": "yes"`
717
+ * must not coerce an overlay onto the first frames of every render
718
+ * (parse-don't-coerce, CLAUDE.md). Pure, so the whole flag × config matrix is
719
+ * testable without a config file on disk.
720
+ */
721
+ export function resolveCoverInVideo(
722
+ flag: boolean | undefined,
723
+ configValue: boolean | undefined,
724
+ ): boolean {
725
+ return flag ?? configValue === true;
726
+ }
727
+
728
+ /**
729
+ * Where the cover image lives right now, most-specific first — the EDITOR
730
+ * panel's ladder (`currentCoverImage` in edit.ts), deliberately the same one:
731
+ * the destination the last cover render used (`cover.json`'s `out`), else
732
+ * this run's `<out>.cover.jpg`. Two surfaces disagreeing about which file IS
733
+ * the project's cover is how the overlay would end up showing a stale image
734
+ * the panel says was replaced.
735
+ *
736
+ * Pure — the caller owns the `existsSync` walk — so the ladder is assertable
737
+ * without a workdir. Note what it CANNOT return: the cover this run is about
738
+ * to write, which does not exist yet (produce renders the cover after the
739
+ * video). See the staging site for why that is the intended behavior.
740
+ */
741
+ export function coverInVideoCandidates(p: {
742
+ provenanceOut?: string | null;
743
+ outPath: string;
744
+ }): string[] {
745
+ const out: string[] = [];
746
+ if (p.provenanceOut) out.push(p.provenanceOut);
747
+ out.push(artifactPath(p.outPath, ".cover.jpg"));
748
+ return out;
749
+ }
750
+
751
+ /**
752
+ * Fixed subfolder the staged cover overlay lands in, never the public dir's
753
+ * root — `SIDE_IMAGE_SUBDIR`'s reasoning applied to a file produce names
754
+ * itself: the public dir can BE the user's own input folder (a --no-mezzanine
755
+ * file run), and a root-level `cover-in-video.jpg` would silently overwrite a
756
+ * file of theirs that happened to share the name. Nothing else writes into
757
+ * this subfolder, so a collision is impossible by construction.
758
+ */
759
+ export const COVER_IN_VIDEO_SUBDIR = "cover-in-video";
760
+
761
+ /**
762
+ * The staged overlay's SERVED name, from the cover image being copied. Keeps
763
+ * the source's own extension (lowercased) so a `.png` cover is not served as
764
+ * a `.jpg`, and stays POSIX-literal — never `path.join` — because this string
765
+ * is a URL read back by `staticFile()` and the editor's `/media/` mount, both
766
+ * of which split on `/` only (sideImageDestRel's Windows lesson).
767
+ */
768
+ export function coverInVideoFileName(source: string): string {
769
+ return `${COVER_IN_VIDEO_SUBDIR}/cover${extname(source).toLowerCase()}`;
770
+ }
771
+
692
772
  /**
693
773
  * The effective `--youtube` switch — resolveWatermark's semantics verbatim:
694
774
  * a TYPED flag always wins (so `--no-youtube` beats a config-on), and only
@@ -1631,14 +1711,10 @@ export function resolveCaptionsHidden(
1631
1711
  * stay byte-identical. Pure so the matrix is testable without a produce
1632
1712
  * run.
1633
1713
  */
1634
- export function captionPackingFor(landscape: boolean): {
1635
- maxWordsPerLine: number;
1636
- maxLineDuration: number;
1637
- } {
1638
- return landscape
1639
- ? { maxWordsPerLine: 6, maxLineDuration: 2.4 }
1640
- : { maxWordsPerLine: 3, maxLineDuration: 1.2 };
1641
- }
1714
+ // Moved to core (captions.ts) so the editor's live caption rebuild packs
1715
+ // with the SAME matrix (cut-review rework follow-up: captions over revived
1716
+ // material). Re-exported here so existing imports and tests keep working.
1717
+ export { captionPackingFor } from "@ossclip/core";
1642
1718
 
1643
1719
  function sha1File(path: string): Promise<string> {
1644
1720
  return new Promise((res, rej) => {
@@ -1894,6 +1970,20 @@ export function layoutSlotAspects(frame: {
1894
1970
  });
1895
1971
  }
1896
1972
 
1973
+ /**
1974
+ * One orphaned scene edit, one honest sentence. A parked key (`…#orphaned`,
1975
+ * handoff-edit-anchoring) matches no cue BY DESIGN, so it surfaces in
1976
+ * `applyOverrides`' orphan list on every run — and "dropped" would tell the
1977
+ * user an edit is gone while the doc still holds it, anchor intact, waiting
1978
+ * for a plan that has its words again. Pure so both sentences are testable
1979
+ * without a produce run.
1980
+ */
1981
+ export function orphanEditLine(id: string): string {
1982
+ return isParkedOverrideKey(id)
1983
+ ? ` ⚠ edit for ${parkedOverrideBaseKey(id)} is parked — its words are not in this plan`
1984
+ : ` ⚠ edit for ${id} dropped — the plan no longer has that scene`;
1985
+ }
1986
+
1897
1987
  async function preflight(bin: string, hint: string): Promise<void> {
1898
1988
  try {
1899
1989
  await run(bin, ["-version"], { allowNonZero: true });
@@ -3115,10 +3205,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3115
3205
  // a duration hint a few seconds short — never a wrong cut.
3116
3206
  const cutlistProposed = cutlist;
3117
3207
  const cleanupVetoed = vetoedRemovals(cutlist, overrideDoc.cleanup);
3118
- if (cleanupVetoed.length > 0) {
3208
+ // Dismissed proposals ("not a retake", cut-review rework 2026-08-26)
3209
+ // re-keep exactly like vetoes — applyCleanupChoices owns the union — but
3210
+ // get their own line: a dismissal is "the classification was wrong", not
3211
+ // "kept this once", and the two must not read as one number.
3212
+ const cleanupDismissed = dismissedRemovals(cutlist, overrideDoc.cleanup);
3213
+ if (cleanupVetoed.length > 0 || cleanupDismissed.length > 0) {
3119
3214
  cutlist = applyCleanupChoices(cutlist, overrideDoc.cleanup);
3120
3215
  map = new TimeMap(cutlist);
3121
- console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
3216
+ if (cleanupVetoed.length > 0) console.log(cleanupChoicesLine(cleanupVetoed, map.outputDuration));
3217
+ if (cleanupDismissed.length > 0) {
3218
+ const sec = cleanupDismissed.reduce((s, seg) => s + (seg.srcOut - seg.srcIn), 0);
3219
+ console.log(
3220
+ `▸ dismissed ${cleanupDismissed.length} marker(s) — ${sec.toFixed(1)}s kept as footage`,
3221
+ );
3222
+ }
3122
3223
  }
3123
3224
  // `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
3124
3225
  // no `src` yet) and any already-re-anchored splits/pins are expressed
@@ -3147,6 +3248,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3147
3248
  cutlist = cutResult.cutlist;
3148
3249
  map = cutResult.map;
3149
3250
  overrideDoc = cutResult.doc;
3251
+ // Backfill `splits[].src` ONCE for src-less legacy entries (cut-review
3252
+ // rework, 2026-08-26) — the `resolveCutSourceRanges` posture applied to
3253
+ // splits: after `applyUserCuts`, a legacy `at` speaks THIS run's clock
3254
+ // (remapped when the frames differed), so `map.toSource(at)` is exact.
3255
+ // Gated on `priorMap` like the cuts resolution: with no prior render-props
3256
+ // the frame `at` was drawn against is unknowable, and a guessed src would
3257
+ // be a wrong anchor forever. `at` stays verbatim — the historical record,
3258
+ // per SplitSchema.
3259
+ let splitSrcResolved = false;
3260
+ if (priorMap !== null) {
3261
+ const withSrc = overrideDoc.splits.map((s) => {
3262
+ if (s.src !== undefined || s.at === undefined) return s;
3263
+ splitSrcResolved = true;
3264
+ return { ...s, src: Math.round(map.toSource(s.at) * 1000) / 1000 };
3265
+ });
3266
+ if (splitSrcResolved) {
3267
+ overrideDoc = { ...overrideDoc, splits: withSrc };
3268
+ console.log(`▸ anchored ${withSrc.filter((s) => s.src !== undefined).length} split(s) to source time`);
3269
+ }
3270
+ }
3150
3271
  if (overrideDoc.cuts.length > 0) {
3151
3272
  console.log(
3152
3273
  `▸ ${overrideDoc.cuts.length} user cut(s) removed ${cutResult.removedSec.toFixed(1)}s ` +
@@ -3180,6 +3301,24 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3180
3301
  const { cues: assembled, dropped } = assembleScenes(scenes, transcript, map);
3181
3302
  for (const d of dropped) console.log(` ⚠ scene ${d.id} dropped: ${d.reason}`);
3182
3303
 
3304
+ // Re-key scene edits whose positional id no longer means the moment they
3305
+ // were made on (handoff-edit-anchoring; §137 is the caption precedent — in
3306
+ // the field, scene-4 was a TerminalMock in one plan and a FlowDiagram in
3307
+ // the next, and the user's edit landed on the impostor). Placed HERE,
3308
+ // before ANY consumer of `overrideDoc.scenes`: the first `applyOverrides`
3309
+ // pass, `splitThenDropHidden` and the pinned-timing reclamp all join by id,
3310
+ // and a stale key at any of them bakes the misapply this plan exists to
3311
+ // kill — a `hidden` on a renumbered scene would hide the impostor, not the
3312
+ // moved moment. `assembled` is enough for the match: the remap reads only
3313
+ // anchored graphic cues, and take ids (which don't exist until the fill)
3314
+ // carry no anchors and are left untouched by design. Notes are non-empty
3315
+ // exactly when the doc changed, which is what earns the write-back its
3316
+ // turn at the sanctioned write below.
3317
+ const sceneRemap = remapSceneOverrides(overrideDoc, assembled);
3318
+ overrideDoc = sceneRemap.doc;
3319
+ for (const n of sceneRemap.notes) console.log(` ▸ ${n}`);
3320
+ const sceneKeysRemapped = sceneRemap.notes.length > 0;
3321
+
3183
3322
  // ---- Framing plan → props (2026-08-16 incident) --------------------------
3184
3323
  // The plan used to be BAKED here: every window cropped, scaled and
3185
3324
  // re-encoded into a content-<hash>.mp4 that replaced the source for the
@@ -3367,7 +3506,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3367
3506
  // reshape `map` in time) — applied here, AFTER assembly and routing, so
3368
3507
  // hand edits sit on top of whatever the producer just planned. Never
3369
3508
  // written to production.json — that file is ours to overwrite.
3370
- const { cues: editedCues } = applyOverrides(routed.cues, overrideDoc);
3509
+ // Src-anchored pins (SceneTimingSchema) resolved onto THIS run's clock
3510
+ // before anything merges them — the same posture as `resolveSplitPoints`
3511
+ // below, and the reason `applyOverrides` never takes a map. A LOCAL doc,
3512
+ // deliberately not written back over `overrideDoc`: its `timing` entries
3513
+ // are now output seconds, and letting one of those reach the sanctioned
3514
+ // overrides.json write would spend the source anchor and put the pin back
3515
+ // on a clock the next re-cut moves.
3516
+ const pinnedDoc = resolveSrcTimingPins(overrideDoc, map);
3517
+ for (const r of pinnedDoc.reports) console.log(` ⚠ ${r}`);
3518
+ const { cues: editedCues } = applyOverrides(routed.cues, pinnedDoc.doc);
3371
3519
  // Scenes the user deleted in the editor drop here — their windows become
3372
3520
  // plain takes in the fill below, which is Task C's payoff for Task A.
3373
3521
  // `splitThenDropHidden`, not a bare `dropHiddenCues`: this runs before
@@ -3404,10 +3552,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3404
3552
  // are reported from THIS pass: only now does the id universe include the
3405
3553
  // takes, so a `take-2-1` edit whose take merged away reports here instead
3406
3554
  // of every take id reporting on the first pass.
3407
- const filled = fillPlainCues(reclamped, {
3555
+ // Kept/dismissed removal edges ride along as extra clipStarts (cut-review
3556
+ // rework, 2026-08-26): a veto merges two kept spans into one, which would
3557
+ // otherwise change the clip count and silently re-target every `take-N`
3558
+ // framing edit after it (§155's misapply class). With the edges preserved,
3559
+ // the fill still cuts where the boundaries were, and `carveKeptTakes`
3560
+ // below owns the revived middle.
3561
+ const keptRangesForCarve = [
3562
+ ...cleanupVetoed.map((seg) => ({ srcIn: seg.srcIn, srcOut: seg.srcOut })),
3563
+ ...cleanupDismissed.map((seg) => ({ srcIn: seg.srcIn, srcOut: seg.srcOut, dismissed: true })),
3564
+ ];
3565
+ const keptEdgeStarts = keptRangesForCarve.flatMap((r) => {
3566
+ const a = map.toOutput(r.srcIn);
3567
+ const b = map.toOutput(r.srcOut);
3568
+ return [...(a !== null ? [a] : []), ...(b !== null ? [b] : [])];
3569
+ });
3570
+ const filled0 = fillPlainCues(reclamped, {
3408
3571
  outputDurationSec: map.outputDuration,
3409
- clipStarts: map.spans.map((s) => s.outIn),
3572
+ clipStarts: [...map.spans.map((s) => s.outIn), ...keptEdgeStarts],
3410
3573
  });
3574
+ // Revived material as a first-class block — same carve the editor previews
3575
+ // with (`carveKeptTakes`'s one-implementation-two-callers doc), so
3576
+ // `take-kept-*` ids exist server-side and framing edits on them land in
3577
+ // the second override pass below instead of orphaning.
3578
+ const { cues: filled, reports: carveReports } = carveKeptTakes(filled0, keptRangesForCarve, map);
3579
+ for (const r of carveReports) console.log(` ⚠ ${r}`);
3411
3580
  // User splits (R16 §61) — after the fill so takes split like scenes, and
3412
3581
  // before the final override pass so edits on the `id@<split id>` halves land
3413
3582
  // (the suffix is the split's own minted id, §137, not its time). A
@@ -3416,11 +3585,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3416
3585
  // is a no-op for that scene (the split point sits exactly on the joint
3417
3586
  // between the two halves, matching neither), so this call stays the one
3418
3587
  // that actually cuts TAKE ids, which don't exist until the fill just ran.
3419
- const split = splitCues(filled, overrideDoc.splits);
3588
+ // Resolved onto THIS run's clock: `src` (the recut-immune anchor) wins,
3589
+ // src-less legacy entries pass their re-anchored `at` through, and a src
3590
+ // sitting in removed material is inert with a report (resolveSplitPoints'
3591
+ // doc owns the posture).
3592
+ const resolvedSplits = resolveSplitPoints(overrideDoc.splits, map);
3593
+ for (const r of resolvedSplits.reports) console.log(` ⚠ ${r}`);
3594
+ const split = splitCues(filled, resolvedSplits.points);
3420
3595
  if (overrideDoc.splits.length > 0) {
3421
3596
  console.log(`▸ ${overrideDoc.splits.length} scene split(s) from the edit layer`);
3422
3597
  }
3423
- const { cues: mergedCues, orphans: rawOrphans } = applyOverrides(split, overrideDoc);
3598
+ // `pinnedDoc.doc` again, not `overrideDoc`: this pass is on the SAME clock
3599
+ // as the first (the split above does not move time), and a src pin that
3600
+ // reached here unresolved would be ignored rather than applied.
3601
+ const { cues: mergedCues, orphans: rawOrphans } = applyOverrides(split, pinnedDoc.doc);
3424
3602
  // Halves of a TAKE the user deleted after splitting: a take id only exists
3425
3603
  // once the fill above runs, so its `id@<split id>` half couldn't have been seen by
3426
3604
  // `splitThenDropHidden` earlier (that pass only ever saw graphic scenes).
@@ -3438,7 +3616,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3438
3616
  console.log(`▸ applied your edits to ${editedCount - orphans.length - hiddenIds.length} scene(s)`);
3439
3617
  }
3440
3618
  for (const id of orphans) {
3441
- console.log(` ⚠ edit for ${id} dropped — the plan no longer has that scene`);
3619
+ console.log(orphanEditLine(id));
3442
3620
  }
3443
3621
 
3444
3622
  const graphicCues = sceneCues.filter((c) => c.kind !== "plain");
@@ -3786,7 +3964,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3786
3964
  // by name below, and a run that cannot anchor one today is not permission to
3787
3965
  // delete it — the next run, against a different cut, may place it (final
3788
3966
  // review, Critical 1).
3789
- const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines);
3967
+ const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines, map);
3790
3968
  overrideDoc = captionWork.doc;
3791
3969
  const captionLines = captionWork.lines;
3792
3970
  const captionKeysReanchored = captionWork.reanchored;
@@ -4120,6 +4298,71 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4120
4298
  );
4121
4299
  }
4122
4300
 
4301
+ // ---- Cover overlay on the opening frames (`--cover-in-video`) -----------
4302
+ // Resolved and staged HERE, next to the props it feeds, the watermark's
4303
+ // posture: one ▸ line when it is on, so a config-sourced overlay never
4304
+ // surprises the author on upload.
4305
+ //
4306
+ // THE STAGED FILE IS THE CURRENT COVER AT RENDER TIME, and that is the
4307
+ // whole state model: this run's own cover has not been rendered yet
4308
+ // (produce writes it after the video, from the finished render's own
4309
+ // geometry), so what gets copied is the cover the project has RIGHT NOW —
4310
+ // the one `ossclip cover`, the editor's regenerate button, or the previous
4311
+ // produce left behind, resolved down the panel's own ladder
4312
+ // (`coverInVideoCandidates`). A regenerated cover therefore lands in the
4313
+ // NEXT render and preview with no extra plumbing, exactly like a headline
4314
+ // edit in `cover.json`. A project with no cover on disk at all (the first
4315
+ // ever run) gets one ⚠ line and NO prop — an absent key is the
4316
+ // absent-means-off contract, so the render is byte-identical to an
4317
+ // overlay-less one rather than half-wired.
4318
+ //
4319
+ // Staged into the render's public dir AND the workdir when they differ, for
4320
+ // the Nastaliq font's reason verbatim: the render bundles
4321
+ // `dirname(renderVideo)`, `ossclip edit` serves the workdir at `/media/`,
4322
+ // and both mounts fetch the same name.
4323
+ const coverInVideoOn = resolveCoverInVideo(opts.coverInVideo, cfg.coverInVideo);
4324
+ let coverInVideo: { fileName: string; durationSec: number } | undefined;
4325
+ if (coverInVideoOn) {
4326
+ const candidates = coverInVideoCandidates({
4327
+ provenanceOut: (await readCoverProvenance(work))?.out,
4328
+ outPath,
4329
+ });
4330
+ const source = candidates.find((p) => existsSync(p));
4331
+ if (source === undefined) {
4332
+ // Deliberately does NOT promise this run's cover: `--no-cover` may mean
4333
+ // there will not be one, and a produce that says "next time" and then
4334
+ // never delivers is worse than one that names what it looked for.
4335
+ console.log(
4336
+ ` ⚠ cover in video: no cover image yet (looked for ` +
4337
+ `${candidates.join(", ")}) — no overlay this run; ` +
4338
+ `it uses the cover a previous run or \`ossclip cover\` leaves behind`,
4339
+ );
4340
+ } else {
4341
+ const fileName = coverInVideoFileName(source);
4342
+ for (const dir of new Set([renderPublicDirPath, work])) {
4343
+ // `join` on the filesystem side where `fileName` itself stays
4344
+ // POSIX-literal — these are paths, that is a served URL (the
4345
+ // Nastaliq staging's exact split).
4346
+ const dest = join(dir, fileName);
4347
+ mkdirSync(dirname(dest), { recursive: true });
4348
+ copyFileSync(source, dest);
4349
+ }
4350
+ // The window is derived from the OUTPUT clock's first word — the same
4351
+ // caption words the renderer draws, post-cut (core's coverInVideoWindow
4352
+ // owns the bounds). The first line WITH words, not `captionLines[0]`:
4353
+ // an empty leading line would read as "no speech" and take the cap.
4354
+ const durationSec = coverInVideoWindow(
4355
+ captionLines.find((l) => l.words.length > 0)?.words ?? [],
4356
+ { capSec: COVER_IN_VIDEO_CAP_SEC, floorSec: COVER_IN_VIDEO_FLOOR_SEC },
4357
+ );
4358
+ coverInVideo = { fileName, durationSec };
4359
+ console.log(
4360
+ `▸ cover in video: ${basename(source)} over the first ${durationSec.toFixed(2)}s` +
4361
+ `${opts.coverInVideo === undefined ? " (from config; --no-cover-in-video overrides)" : ""}`,
4362
+ );
4363
+ }
4364
+ }
4365
+
4123
4366
  const props = {
4124
4367
  videoFileName: basename(renderVideo),
4125
4368
  spans: [...map.spans],
@@ -4234,6 +4477,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4234
4477
  // an off run's render-props.json stays byte-identical to a pre-watermark
4235
4478
  // one, so nothing downstream can tell the feature ever shipped.
4236
4479
  ...(watermark ? { watermark: true } : {}),
4480
+ // Written only when the overlay is BOTH switched on and backed by a file
4481
+ // that exists (the staging block above): absent means no overlay, so an
4482
+ // off run's render-props.json — and its rendered pixels — stay identical
4483
+ // to a pre-feature one.
4484
+ ...(coverInVideo ? { coverInVideo } : {}),
4237
4485
  // Same absent-means-default contract, polarity flipped (captions default
4238
4486
  // ON): written only when hidden, so a normal run's render-props.json
4239
4487
  // stays byte-identical to a pre-feature one. `captionsHiddenByFlag` is
@@ -4295,7 +4543,17 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4295
4543
  // re-report "the cut removed it" on every later run. It does NOT spend the
4296
4544
  // `.bak` — retiring a redundant key is not the cut re-anchoring the backup
4297
4545
  // exists to survive.
4298
- if (cutResult.changed || captionKeysReanchored || hidesPruned) {
4546
+ // `sceneKeysRemapped` joins for the caption-migration reason: a scene edit
4547
+ // that followed its anchor to a new id (handoff-edit-anchoring) is keyed
4548
+ // right in memory but wrong on disk, and every re-keyed entry was just
4549
+ // printed by name. It does NOT spend the `.bak` — the remap moves keys, it
4550
+ // rewrites no values a user would need to recover.
4551
+ // `splitSrcResolved` joins for the sceneKeysRemapped reason: the source
4552
+ // anchor exists in memory but not on disk, and skipping the write would
4553
+ // re-resolve (and re-announce) it every run. It does NOT spend the `.bak`
4554
+ // — backfilling adds an anchor, it rewrites no value a user would need to
4555
+ // recover.
4556
+ if (cutResult.changed || captionKeysReanchored || hidesPruned || sceneKeysRemapped || splitSrcResolved) {
4299
4557
  await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
4300
4558
  console.log(overridesWriteLine(cutResult.changed));
4301
4559
  }
@@ -4377,6 +4635,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4377
4635
  // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4378
4636
  // argv — "auto" has no flag spelling and stays unpinned (see
4379
4637
  // recordedProduceArgs for why that is safe today).
4638
+ // Cover-overlay pin: the watermark's config-dependent-default rationale
4639
+ // again, on the flag that paints the first frames — an unpinned record
4640
+ // would gain or lose the overlay under a later `coverInVideo` config edit.
4380
4641
  // Youtube pin: the watermark's config-dependent-default rationale exactly —
4381
4642
  // resolved both ways, so a later config edit can't flip what Render
4382
4643
  // replays. Portrait and dictionary pin the RESOLVED values (a path and
@@ -4390,6 +4651,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4390
4651
  llmEffort,
4391
4652
  clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4392
4653
  watermark,
4654
+ // The SWITCH's resolved state (`coverInVideoOn`), not "did an overlay
4655
+ // actually happen": a run whose cover was simply missing yet still
4656
+ // records `--cover-in-video` is correct — the pin exists so the replay
4657
+ // resolves the same way this run did, and by then the cover may well be
4658
+ // on disk. Same distinction the captions pin draws between the flag and
4659
+ // the editor's override.
4660
+ coverInVideo: coverInVideoOn,
4393
4661
  captions: opts.captions ?? true,
4394
4662
  jumpCuts: jumpCutsMode,
4395
4663
  dictionary,
package/src/program.ts CHANGED
@@ -10,6 +10,7 @@ import { ExportFormatSchema, runAnalyze } from "./analyze";
10
10
  import { expandHome } from "./paths";
11
11
  import { phaseBucketProps } from "./phase-timing";
12
12
  import { dictionaryFlag, jumpCutsFlag, produce, reviewFlag } from "./produce";
13
+ import { accountsFlag, atFlag, platformsFlag } from "./publish";
13
14
  // The one interactive import that is STATIC rather than `await import()`: the
14
15
  // `resetInputSource()` run boundary in `buildProgram` has to run synchronously
15
16
  // while the program is being built, and `buildProgram` cannot await. The graph
@@ -406,6 +407,19 @@ export function buildProgram(): Command {
406
407
  .option("--no-watermark", "no wordmark, even when the config turns it on")
407
408
  // Same tri-state shape as --watermark above (positive declared first so
408
409
  // commander's default stays undefined = "not typed"): the config's
410
+ // `coverInVideo` key supplies the default (resolveCoverInVideo), and a
411
+ // typed --no-cover-in-video still beats a config-on. A separate key from
412
+ // --cover/--no-cover, which is about WRITING the cover file at all.
413
+ .option(
414
+ "--cover-in-video",
415
+ "overlay the cover image on the video's first frames, for the platforms that ignore " +
416
+ "an uploaded cover and use frame 1. Nothing is inserted — the overlay ends at the " +
417
+ "first spoken word (max 0.5s), so no timing moves " +
418
+ "(set it once with coverInVideo: true in ~/.ossclip/config.json)",
419
+ )
420
+ .option("--no-cover-in-video", "no cover overlay, even when the config turns it on")
421
+ // Same tri-state shape as --watermark above (positive declared first so
422
+ // commander's default stays undefined = "not typed"): the config's
409
423
  // `youtube` key supplies the default (resolveYoutube), and a typed
410
424
  // --no-youtube still beats a config-on.
411
425
  .option(
@@ -644,6 +658,9 @@ export function buildProgram(): Command {
644
658
  zoom: opts.zoom,
645
659
  // undefined = "not typed", so produce can let the config decide.
646
660
  watermark: opts.watermark,
661
+ // The watermark's tri-state again, resolved by resolveCoverInVideo
662
+ // at the use site against the config's `coverInVideo`.
663
+ coverInVideo: opts.coverInVideo,
647
664
  // The same tri-state contract as watermark, resolved by
648
665
  // resolveYoutube at the use site; --portrait rides along untyped =
649
666
  // undefined so the config's path can supply it.
@@ -991,6 +1008,52 @@ export function buildProgram(): Command {
991
1008
  });
992
1009
  });
993
1010
 
1011
+ program
1012
+ .command("publish")
1013
+ .description(
1014
+ "push the finished render to your social accounts through your own self-hosted " +
1015
+ "Postiz instance — now, or scheduled with --at",
1016
+ )
1017
+ // Optional like `edit`'s and `cover`'s: no argument resolves the run
1018
+ // under the CURRENT directory.
1019
+ .argument("[workdir]", "a work directory, or the folder you produced in")
1020
+ .option(
1021
+ "--at <iso>",
1022
+ "schedule instead of publishing now — an ISO-8601 time in the future " +
1023
+ "(e.g. 2026-09-01T08:00:00+02:00)",
1024
+ // Wrapped: commander's parseArg passes (value, previous), and atFlag's
1025
+ // second parameter is its injectable clock — not a place for `previous`.
1026
+ (v: string) => atFlag(v),
1027
+ )
1028
+ .option(
1029
+ "--platforms <list>",
1030
+ "only these platforms, comma-separated (linkedin,instagram,tiktok,x,facebook,youtube)",
1031
+ platformsFlag,
1032
+ )
1033
+ .option("--accounts <ids>", "explicit Postiz integration ids, comma-separated (the no-TTY path)", accountsFlag)
1034
+ .option("--all", "every connected account (after --platforms, when both are given)", false)
1035
+ .option("--dry-run", "print the targets and the exact payload; send nothing", false)
1036
+ .option("-y, --yes", "skip the confirmation prompt", false)
1037
+ .option("--force", "publish again even though this workdir already has a publish receipt", false)
1038
+ .action(async (workdir: string | undefined, opts) => {
1039
+ const { runPublish } = await import("./publish");
1040
+ const target = await resolveWorkdirArgument(workdir ?? ".", "publish");
1041
+ await runPublish(target, {
1042
+ at: opts.at,
1043
+ platforms: opts.platforms,
1044
+ accounts: opts.accounts,
1045
+ all: opts.all,
1046
+ dryRun: opts.dryRun,
1047
+ yes: opts.yes,
1048
+ force: opts.force,
1049
+ });
1050
+ telemetry.record("publish_run", {
1051
+ scheduled: opts.at !== undefined,
1052
+ dry_run: opts.dryRun === true,
1053
+ });
1054
+ await telemetry.flush();
1055
+ });
1056
+
994
1057
  program
995
1058
  .command("setup")
996
1059
  .description(