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