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/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 +490 -1
- package/src/produce.ts +287 -19
- 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-DfTreNoE.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,
|
|
@@ -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
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|