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/README.md +2 -0
- package/editor-dist/assets/index-DA7vDWn_.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/caption-report.ts +26 -1
- package/src/edit.ts +391 -1
- package/src/produce.ts +246 -18
- package/src/program.ts +63 -0
- package/src/publish.ts +352 -0
- package/src/replay-argv.ts +16 -0
- package/src/ui/animation.ts +5 -1
- package/editor-dist/assets/index-C537KpNT.js +0 -166
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
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|