ossclip 0.1.31 → 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,
@@ -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,
@@ -613,6 +621,15 @@ export interface ProduceOptions {
613
621
  * a free-tier limitation; this is voluntary attribution.
614
622
  */
615
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;
616
633
  /**
617
634
  * `--youtube` / `--no-youtube` tri-state, the watermark's exact contract:
618
635
  * true/false when TYPED, undefined when not — undefined lets the config's
@@ -692,6 +709,66 @@ export function resolveWatermark(
692
709
  return flag ?? configValue === true;
693
710
  }
694
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
+
695
772
  /**
696
773
  * The effective `--youtube` switch — resolveWatermark's semantics verbatim:
697
774
  * a TYPED flag always wins (so `--no-youtube` beats a config-on), and only
@@ -1634,14 +1711,10 @@ export function resolveCaptionsHidden(
1634
1711
  * stay byte-identical. Pure so the matrix is testable without a produce
1635
1712
  * run.
1636
1713
  */
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
- }
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";
1645
1718
 
1646
1719
  function sha1File(path: string): Promise<string> {
1647
1720
  return new Promise((res, rej) => {
@@ -3132,10 +3205,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3132
3205
  // a duration hint a few seconds short — never a wrong cut.
3133
3206
  const cutlistProposed = cutlist;
3134
3207
  const cleanupVetoed = vetoedRemovals(cutlist, overrideDoc.cleanup);
3135
- 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) {
3136
3214
  cutlist = applyCleanupChoices(cutlist, overrideDoc.cleanup);
3137
3215
  map = new TimeMap(cutlist);
3138
- 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
+ }
3139
3223
  }
3140
3224
  // `applyUserCuts`'s `priorMap`: a cut's `startSec`/`endSec` (when it has
3141
3225
  // no `src` yet) and any already-re-anchored splits/pins are expressed
@@ -3164,6 +3248,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3164
3248
  cutlist = cutResult.cutlist;
3165
3249
  map = cutResult.map;
3166
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
+ }
3167
3271
  if (overrideDoc.cuts.length > 0) {
3168
3272
  console.log(
3169
3273
  `▸ ${overrideDoc.cuts.length} user cut(s) removed ${cutResult.removedSec.toFixed(1)}s ` +
@@ -3402,7 +3506,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3402
3506
  // reshape `map` in time) — applied here, AFTER assembly and routing, so
3403
3507
  // hand edits sit on top of whatever the producer just planned. Never
3404
3508
  // written to production.json — that file is ours to overwrite.
3405
- 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);
3406
3519
  // Scenes the user deleted in the editor drop here — their windows become
3407
3520
  // plain takes in the fill below, which is Task C's payoff for Task A.
3408
3521
  // `splitThenDropHidden`, not a bare `dropHiddenCues`: this runs before
@@ -3439,10 +3552,31 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3439
3552
  // are reported from THIS pass: only now does the id universe include the
3440
3553
  // takes, so a `take-2-1` edit whose take merged away reports here instead
3441
3554
  // of every take id reporting on the first pass.
3442
- 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, {
3443
3571
  outputDurationSec: map.outputDuration,
3444
- clipStarts: map.spans.map((s) => s.outIn),
3572
+ clipStarts: [...map.spans.map((s) => s.outIn), ...keptEdgeStarts],
3445
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}`);
3446
3580
  // User splits (R16 §61) — after the fill so takes split like scenes, and
3447
3581
  // before the final override pass so edits on the `id@<split id>` halves land
3448
3582
  // (the suffix is the split's own minted id, §137, not its time). A
@@ -3451,11 +3585,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3451
3585
  // is a no-op for that scene (the split point sits exactly on the joint
3452
3586
  // between the two halves, matching neither), so this call stays the one
3453
3587
  // that actually cuts TAKE ids, which don't exist until the fill just ran.
3454
- 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);
3455
3595
  if (overrideDoc.splits.length > 0) {
3456
3596
  console.log(`▸ ${overrideDoc.splits.length} scene split(s) from the edit layer`);
3457
3597
  }
3458
- 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);
3459
3602
  // Halves of a TAKE the user deleted after splitting: a take id only exists
3460
3603
  // once the fill above runs, so its `id@<split id>` half couldn't have been seen by
3461
3604
  // `splitThenDropHidden` earlier (that pass only ever saw graphic scenes).
@@ -3821,7 +3964,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
3821
3964
  // by name below, and a run that cannot anchor one today is not permission to
3822
3965
  // delete it — the next run, against a different cut, may place it (final
3823
3966
  // review, Critical 1).
3824
- const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines);
3967
+ const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines, map);
3825
3968
  overrideDoc = captionWork.doc;
3826
3969
  const captionLines = captionWork.lines;
3827
3970
  const captionKeysReanchored = captionWork.reanchored;
@@ -4155,6 +4298,71 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4155
4298
  );
4156
4299
  }
4157
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
+
4158
4366
  const props = {
4159
4367
  videoFileName: basename(renderVideo),
4160
4368
  spans: [...map.spans],
@@ -4269,6 +4477,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4269
4477
  // an off run's render-props.json stays byte-identical to a pre-watermark
4270
4478
  // one, so nothing downstream can tell the feature ever shipped.
4271
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 } : {}),
4272
4485
  // Same absent-means-default contract, polarity flipped (captions default
4273
4486
  // ON): written only when hidden, so a normal run's render-props.json
4274
4487
  // stays byte-identical to a pre-feature one. `captionsHiddenByFlag` is
@@ -4335,7 +4548,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4335
4548
  // right in memory but wrong on disk, and every re-keyed entry was just
4336
4549
  // printed by name. It does NOT spend the `.bak` — the remap moves keys, it
4337
4550
  // rewrites no values a user would need to recover.
4338
- if (cutResult.changed || captionKeysReanchored || hidesPruned || sceneKeysRemapped) {
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) {
4339
4557
  await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
4340
4558
  console.log(overridesWriteLine(cutResult.changed));
4341
4559
  }
@@ -4417,6 +4635,9 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4417
4635
  // Jump-cuts pin: the RESOLVED mode, but only its typed states reach the
4418
4636
  // argv — "auto" has no flag spelling and stays unpinned (see
4419
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.
4420
4641
  // Youtube pin: the watermark's config-dependent-default rationale exactly —
4421
4642
  // resolved both ways, so a later config edit can't flip what Render
4422
4643
  // replays. Portrait and dictionary pin the RESOLVED values (a path and
@@ -4430,6 +4651,13 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
4430
4651
  llmEffort,
4431
4652
  clipWindow: clipWindow ? `${clipWindow.startWord}:${clipWindow.endWord}` : undefined,
4432
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,
4433
4661
  captions: opts.captions ?? true,
4434
4662
  jumpCuts: jumpCutsMode,
4435
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(