@ossclip/core 0.1.25 → 0.1.27
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/package.json +1 -1
- package/src/browser.ts +6 -0
- package/src/concat.ts +90 -3
- package/src/cover-headline.ts +61 -0
- package/src/cover.ts +184 -64
- package/src/overrides.ts +728 -0
- package/src/phonetics.ts +101 -1
- package/src/producer/repair.ts +19 -10
- package/src/recut.ts +61 -0
- package/src/transcribe.ts +71 -5
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -46,4 +46,10 @@ export {
|
|
|
46
46
|
// array, to decide whether a repair is possible at all — see
|
|
47
47
|
// `anchorCaptionLines` (§137): a non-empty array can still build an empty map.
|
|
48
48
|
export { mapFromKeptSpans, TimeMap, type KeptSpan } from "./timemap";
|
|
49
|
+
// The §35 cover word cap. The editor's CoverPanel shows the trimmed headline
|
|
50
|
+
// live as you type, and restating the trimming rules there would drift from
|
|
51
|
+
// the one the regenerate endpoint actually renders with. Imported from
|
|
52
|
+
// ./cover-headline, NOT ./cover — that module is node all the way down
|
|
53
|
+
// (node:fs, ./exec), which is exactly what this surface exists to keep out.
|
|
54
|
+
export { COVER_MAX_WORDS, coverHeadline } from "./cover-headline";
|
|
49
55
|
export type { Probe, Production, RenderSettings, Segment, Transcript, Word } from "./schema";
|
package/src/concat.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { z } from "zod/v4";
|
|
5
5
|
import { probe, type IngestTools } from "./ingest";
|
|
6
6
|
import { run } from "./exec";
|
|
@@ -42,6 +42,78 @@ export interface ConcatEntry {
|
|
|
42
42
|
size: number;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Whether a produce output path lands INSIDE the folder being produced.
|
|
47
|
+
* 2026-08-18 field cascade: a folder input's clips are re-enumerated on
|
|
48
|
+
* EVERY run, so an `--out` written into that folder became a 7th "source
|
|
49
|
+
* clip" on the next run — the folder-content hash changed, produce minted a
|
|
50
|
+
* fresh workdir with EMPTY overrides, and the render silently dropped the
|
|
51
|
+
* user's saved edits while the output's duration doubled. It cascaded three
|
|
52
|
+
* times before the doubling duration was connected to the out path.
|
|
53
|
+
*
|
|
54
|
+
* Containment via `path.relative`, the same idiom as edit.ts's `isInside`:
|
|
55
|
+
* a `startsWith` string-prefix test has no separator boundary and is fooled
|
|
56
|
+
* by a sibling folder that merely shares a prefix (`/a/Clips` vs
|
|
57
|
+
* `/a/Clips-old/out.mp4`) — `child` is inside `parent` iff the relative path
|
|
58
|
+
* from one to the other never has to climb out with `..`. Both arguments are
|
|
59
|
+
* resolved here so a relative path is judged against cwd; `~` expansion is
|
|
60
|
+
* the CALLER's job (the 2026-08-16 rule in the CLI's paths.ts — expandHome
|
|
61
|
+
* at the call site — which also keeps core homedir-free).
|
|
62
|
+
*/
|
|
63
|
+
export function outPathInsideInput(outPath: string, inputDir: string): boolean {
|
|
64
|
+
const rel = relative(resolve(inputDir), resolve(outPath));
|
|
65
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The refusal for `outPathInsideInput`, shared verbatim by produce's own
|
|
70
|
+
* gate and the edit server's `/api/render` 400 — one message, one place, so
|
|
71
|
+
* the two boundaries can never describe the same hazard differently. Names
|
|
72
|
+
* the actual risk (the field cascade above) rather than just "invalid path",
|
|
73
|
+
* and suggests the exact default a flag-less run would pick (produce's
|
|
74
|
+
* `defaultOutPath` shape: at most the LAST dot-segment replaced).
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* The default output path for an input: beside it, `.ossclip.mp4` suffix.
|
|
78
|
+
* Trailing separators are stripped FIRST (2026-08-18 field case, second
|
|
79
|
+
* report): shells tab-complete folders with the slash, and the bare regex
|
|
80
|
+
* appended after it — the "default" landed INSIDE the folder as a hidden
|
|
81
|
+
* `.ossclip.mp4` dotfile, the exact self-ingesting shape
|
|
82
|
+
* `outPathInsideInput` exists to refuse. One definition shared by produce's
|
|
83
|
+
* `defaultOutPath` and the refusal message below, so the suggestion can
|
|
84
|
+
* never disagree with what omitting `--out` actually does.
|
|
85
|
+
*/
|
|
86
|
+
export function ossclipOutputPathFor(input: string): string {
|
|
87
|
+
return input.replace(/[/\\]+$/, "").replace(/(\.[^.]+)?$/, ".ossclip.mp4");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function outInsideInputFolderMessage(folder: string): string {
|
|
91
|
+
const suggestion = ossclipOutputPathFor(folder);
|
|
92
|
+
return (
|
|
93
|
+
`refusing to write the output inside the input folder ${folder} — the next ` +
|
|
94
|
+
`run would ingest this output as a source clip and re-plan from scratch, ` +
|
|
95
|
+
`abandoning your edits (the folder's content hash changes, so produce mints ` +
|
|
96
|
+
`a fresh workdir with empty overrides). Write it beside the folder instead, ` +
|
|
97
|
+
`e.g. --out ${suggestion}`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Defense-in-depth BEHIND the out-path refusal above (same 2026-08-18 field
|
|
103
|
+
* cascade): a produce output already sitting in the folder — written by a
|
|
104
|
+
* pre-fix run, or moved there by hand — must never be ingested as a source
|
|
105
|
+
* clip, and must be filtered BEFORE `folderManifestKey` sees the entries so
|
|
106
|
+
* its presence can't re-key the workdir either. Matches any name carrying
|
|
107
|
+
* the `.ossclip` marker: `X.ossclip.mp4`, `X.ossclip_fixed.mp4`, and
|
|
108
|
+
* `X.ossclip.mp4.partial.mp4` leftovers all qualify (bare `.ossclip.mp4` is
|
|
109
|
+
* a dotfile and already skipped upstream). A custom-named output
|
|
110
|
+
* (`--out final.mp4`) is undetectable here — the out-path refusal is the
|
|
111
|
+
* real gate; this only keeps a pre-fix folder from compounding further.
|
|
112
|
+
*/
|
|
113
|
+
export function isOssclipOutputName(name: string): boolean {
|
|
114
|
+
return name.includes(".ossclip");
|
|
115
|
+
}
|
|
116
|
+
|
|
45
117
|
/**
|
|
46
118
|
* Order clips for concatenation. `name` (default, per the field request "sort
|
|
47
119
|
* them by name or date modified, name being default") is a PLAIN codepoint
|
|
@@ -321,6 +393,8 @@ export interface FolderListing {
|
|
|
321
393
|
entries: ConcatEntry[];
|
|
322
394
|
/** Files skipped for not matching a video extension (dotfiles excluded). */
|
|
323
395
|
nonVideoCount: number;
|
|
396
|
+
/** Video files skipped as produce's own outputs (`isOssclipOutputName`). */
|
|
397
|
+
ossclipOutputCount: number;
|
|
324
398
|
}
|
|
325
399
|
|
|
326
400
|
/**
|
|
@@ -339,6 +413,7 @@ export interface FolderListing {
|
|
|
339
413
|
export async function listFolderVideos(folder: string): Promise<FolderListing> {
|
|
340
414
|
const dirents = await readdir(folder, { withFileTypes: true });
|
|
341
415
|
let nonVideoCount = 0;
|
|
416
|
+
let ossclipOutputCount = 0;
|
|
342
417
|
const entries: ConcatEntry[] = [];
|
|
343
418
|
for (const d of dirents) {
|
|
344
419
|
if (d.name.startsWith(".")) continue;
|
|
@@ -357,11 +432,19 @@ export async function listFolderVideos(folder: string): Promise<FolderListing> {
|
|
|
357
432
|
nonVideoCount++;
|
|
358
433
|
continue;
|
|
359
434
|
}
|
|
435
|
+
// Skipped HERE, before the entries ever exist — never in concatFolder —
|
|
436
|
+
// so an ossclip output left in the folder can neither become a source
|
|
437
|
+
// clip nor perturb the workdir hash `folderManifestKey` derives from
|
|
438
|
+
// this listing (2026-08-18 field cascade, see isOssclipOutputName).
|
|
439
|
+
if (isOssclipOutputName(d.name)) {
|
|
440
|
+
ossclipOutputCount++;
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
360
443
|
const st = await stat(join(folder, d.name));
|
|
361
444
|
entries.push({ name: d.name, mtimeMs: st.mtimeMs, size: st.size });
|
|
362
445
|
}
|
|
363
446
|
if (entries.length === 0) throw noVideoFilesError(folder);
|
|
364
|
-
return { entries, nonVideoCount };
|
|
447
|
+
return { entries, nonVideoCount, ossclipOutputCount };
|
|
365
448
|
}
|
|
366
449
|
|
|
367
450
|
export interface FolderConcatResult {
|
|
@@ -371,6 +454,8 @@ export interface FolderConcatResult {
|
|
|
371
454
|
clips: Array<{ name: string; durationSec: number }>;
|
|
372
455
|
/** Files skipped for not matching a video extension. */
|
|
373
456
|
nonVideoCount: number;
|
|
457
|
+
/** Video files skipped as produce's own outputs (`isOssclipOutputName`). */
|
|
458
|
+
ossclipOutputCount: number;
|
|
374
459
|
/** True when the existing `source-concat.mp4` was reused, not rebuilt. */
|
|
375
460
|
cached: boolean;
|
|
376
461
|
/** The concat's own total duration (ffprobe'd from the output). */
|
|
@@ -393,7 +478,7 @@ export async function concatFolder(
|
|
|
393
478
|
sort: "name" | "mtime",
|
|
394
479
|
target: { w: number; h: number },
|
|
395
480
|
): Promise<FolderConcatResult> {
|
|
396
|
-
const { entries: current, nonVideoCount } = listing;
|
|
481
|
+
const { entries: current, nonVideoCount, ossclipOutputCount } = listing;
|
|
397
482
|
if (current.length === 0) throw noVideoFilesError(folder);
|
|
398
483
|
const order = planFolderConcat(current, sort);
|
|
399
484
|
|
|
@@ -408,6 +493,7 @@ export async function concatFolder(
|
|
|
408
493
|
path: outPath,
|
|
409
494
|
clips: order.map((name) => ({ name, durationSec: byName.get(name)!.durationSec })),
|
|
410
495
|
nonVideoCount,
|
|
496
|
+
ossclipOutputCount,
|
|
411
497
|
cached: true,
|
|
412
498
|
durationSec: outProbe.duration,
|
|
413
499
|
};
|
|
@@ -461,6 +547,7 @@ export async function concatFolder(
|
|
|
461
547
|
path: outPath,
|
|
462
548
|
clips: order.map((name, i) => ({ name, durationSec: probes[i]!.duration })),
|
|
463
549
|
nonVideoCount,
|
|
550
|
+
ossclipOutputCount,
|
|
464
551
|
cached: false,
|
|
465
552
|
durationSec: outProbe.duration,
|
|
466
553
|
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cover banner's word cap, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `./cover` (2026-08-19) for the reason `content-rect` is split
|
|
5
|
+
* from `content-rect-detect`: the EDITOR's cover panel shows the trimmed
|
|
6
|
+
* headline live as you type, so it needs this function at RUNTIME — and it
|
|
7
|
+
* imports `@ossclip/core/browser`, whose whole contract is "no node built-ins
|
|
8
|
+
* anywhere in this module graph". `./cover` is node all the way down
|
|
9
|
+
* (`node:fs`, `./exec`'s child_process), so re-exporting `coverHeadline` from
|
|
10
|
+
* there would have put ffmpeg's process runner in the Vite bundle.
|
|
11
|
+
*
|
|
12
|
+
* Restating the trimming rules in the editor was the alternative and is
|
|
13
|
+
* strictly worse: the server re-caps whatever the panel sends, so a second
|
|
14
|
+
* copy would drift into showing a preview the render disagrees with.
|
|
15
|
+
*
|
|
16
|
+
* `./cover` re-exports this file, so `@ossclip/core`'s surface is unchanged.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A cover banner is a headline, not a sentence (FINDINGS §35). The producer
|
|
21
|
+
* shipped 13 words across five lines by reusing the video's hook verbatim; at
|
|
22
|
+
* grid-tile size that is unreadable. The reference covers run 4-9 words.
|
|
23
|
+
*
|
|
24
|
+
* Stated in the schema AND enforced here, because a `.describe()` is a request
|
|
25
|
+
* and this is a constraint — the same reason `normalizeBeatSheet` exists.
|
|
26
|
+
*/
|
|
27
|
+
export const COVER_MAX_WORDS = 9;
|
|
28
|
+
|
|
29
|
+
/** Trailing words that cannot end a headline — the truncation reads as broken. */
|
|
30
|
+
const DANGLING = new Set([
|
|
31
|
+
"a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "is", "it",
|
|
32
|
+
"of", "on", "or", "the", "to", "with", "that", "this", "my", "your", "so",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cut a headline down to `maxWords`, preferring a natural break.
|
|
37
|
+
*
|
|
38
|
+
* A dash or colon usually separates a complete claim from its elaboration, so
|
|
39
|
+
* the first clause is a real headline rather than a sentence with its end
|
|
40
|
+
* lopped off. Only when that is still too long does this truncate — and then
|
|
41
|
+
* it refuses to stop on a preposition or article, which is what makes a
|
|
42
|
+
* truncation look like a bug instead of an edit.
|
|
43
|
+
*/
|
|
44
|
+
export function coverHeadline(text: string, maxWords = COVER_MAX_WORDS): string {
|
|
45
|
+
const clean = text.trim().replace(/\s+/g, " ");
|
|
46
|
+
if (!clean) return clean;
|
|
47
|
+
const words = (s: string): string[] => s.split(" ").filter(Boolean);
|
|
48
|
+
if (words(clean).length <= maxWords) return clean;
|
|
49
|
+
|
|
50
|
+
// First clause, if it stands on its own — never a two-word fragment. Even
|
|
51
|
+
// when the clause is itself too long it is the better thing to cut down,
|
|
52
|
+
// since truncating it can never wander past the dash into the elaboration.
|
|
53
|
+
const clause = clean.split(/\s*[—–:]\s*|\s+-\s+/)[0]!.trim();
|
|
54
|
+
const base = words(clause).length >= 3 ? clause : clean;
|
|
55
|
+
const out = words(base).slice(0, maxWords);
|
|
56
|
+
while (out.length > 3 && DANGLING.has(out[out.length - 1]!.toLowerCase().replace(/\W/g, ""))) {
|
|
57
|
+
out.pop();
|
|
58
|
+
}
|
|
59
|
+
// A clause that ended on its own punctuation keeps it; a cut does not.
|
|
60
|
+
return out.join(" ").replace(/[,;:—–-]+$/, "");
|
|
61
|
+
}
|
package/src/cover.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
4
|
+
import { z } from "zod/v4";
|
|
3
5
|
import { run } from "./exec";
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -15,49 +17,12 @@ import { run } from "./exec";
|
|
|
15
17
|
* This module picks WHICH frame. The banner is drawn by the renderer.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
* and this is a constraint — the same reason `normalizeBeatSheet` exists.
|
|
25
|
-
*/
|
|
26
|
-
export const COVER_MAX_WORDS = 9;
|
|
27
|
-
|
|
28
|
-
/** Trailing words that cannot end a headline — the truncation reads as broken. */
|
|
29
|
-
const DANGLING = new Set([
|
|
30
|
-
"a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "is", "it",
|
|
31
|
-
"of", "on", "or", "the", "to", "with", "that", "this", "my", "your", "so",
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Cut a headline down to `maxWords`, preferring a natural break.
|
|
36
|
-
*
|
|
37
|
-
* A dash or colon usually separates a complete claim from its elaboration, so
|
|
38
|
-
* the first clause is a real headline rather than a sentence with its end
|
|
39
|
-
* lopped off. Only when that is still too long does this truncate — and then
|
|
40
|
-
* it refuses to stop on a preposition or article, which is what makes a
|
|
41
|
-
* truncation look like a bug instead of an edit.
|
|
42
|
-
*/
|
|
43
|
-
export function coverHeadline(text: string, maxWords = COVER_MAX_WORDS): string {
|
|
44
|
-
const clean = text.trim().replace(/\s+/g, " ");
|
|
45
|
-
if (!clean) return clean;
|
|
46
|
-
const words = (s: string): string[] => s.split(" ").filter(Boolean);
|
|
47
|
-
if (words(clean).length <= maxWords) return clean;
|
|
48
|
-
|
|
49
|
-
// First clause, if it stands on its own — never a two-word fragment. Even
|
|
50
|
-
// when the clause is itself too long it is the better thing to cut down,
|
|
51
|
-
// since truncating it can never wander past the dash into the elaboration.
|
|
52
|
-
const clause = clean.split(/\s*[—–:]\s*|\s+-\s+/)[0]!.trim();
|
|
53
|
-
const base = words(clause).length >= 3 ? clause : clean;
|
|
54
|
-
const out = words(base).slice(0, maxWords);
|
|
55
|
-
while (out.length > 3 && DANGLING.has(out[out.length - 1]!.toLowerCase().replace(/\W/g, ""))) {
|
|
56
|
-
out.pop();
|
|
57
|
-
}
|
|
58
|
-
// A clause that ended on its own punctuation keeps it; a cut does not.
|
|
59
|
-
return out.join(" ").replace(/[,;:—–-]+$/, "");
|
|
60
|
-
}
|
|
20
|
+
// The §35 word cap lives in ./cover-headline — this file is node all the way
|
|
21
|
+
// down (node:fs, ./exec's child_process) and the EDITOR's cover panel needs
|
|
22
|
+
// `coverHeadline` at runtime through @ossclip/core/browser, which forbids a
|
|
23
|
+
// node built-in anywhere in its graph. Re-exported here so `@ossclip/core`'s
|
|
24
|
+
// surface is exactly what it was.
|
|
25
|
+
export { COVER_MAX_WORDS, coverHeadline } from "./cover-headline";
|
|
61
26
|
|
|
62
27
|
/**
|
|
63
28
|
* What the cover step should emit.
|
|
@@ -190,6 +155,56 @@ export interface PickCoverOptions {
|
|
|
190
155
|
subject?: "face" | "screen";
|
|
191
156
|
}
|
|
192
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Measure ONE candidate frame: extract it at `timeSec` through the crop math,
|
|
160
|
+
* score its sharpness, and locate the face in the cover's own geometry.
|
|
161
|
+
*
|
|
162
|
+
* Extracted from `pickCoverFrame`'s sampling loop because `ossclip cover --at
|
|
163
|
+
* <t>` needs exactly this for a single timestamp. A second implementation of
|
|
164
|
+
* the cover crop would drift from this one — and a drifted crop puts the
|
|
165
|
+
* banner against geometry the cover does not have, which is the whole reason
|
|
166
|
+
* `COVER_CROP_VF` exists rather than face.ts's plain `scale`.
|
|
167
|
+
*
|
|
168
|
+
* Returns null on a short read: ffmpeg seeking past the end (or onto a
|
|
169
|
+
* corrupt packet) writes fewer bytes than the detection frame, and measuring
|
|
170
|
+
* that is measuring padding.
|
|
171
|
+
*/
|
|
172
|
+
export async function measureCoverFrame(
|
|
173
|
+
tools: { ffmpegPath: string },
|
|
174
|
+
videoPath: string,
|
|
175
|
+
timeSec: number,
|
|
176
|
+
opts: {
|
|
177
|
+
cacheDir?: string;
|
|
178
|
+
cropVf?: string;
|
|
179
|
+
detectFace?: PickCoverOptions["detectFace"];
|
|
180
|
+
/** Scratch file name — the sampler keeps one per sample so a crashed run
|
|
181
|
+
* leaves no ambiguity about which frame it died on. */
|
|
182
|
+
frameName?: string;
|
|
183
|
+
} = {},
|
|
184
|
+
): Promise<{ timeSec: number; sharpness: number; hasFace: boolean; face?: CoverFace } | null> {
|
|
185
|
+
const framePath = join(opts.cacheDir ?? ".", opts.frameName ?? "cover-frame.gray");
|
|
186
|
+
await run(tools.ffmpegPath, [
|
|
187
|
+
"-v", "error",
|
|
188
|
+
"-ss", timeSec.toFixed(3),
|
|
189
|
+
"-i", videoPath,
|
|
190
|
+
"-frames:v", "1",
|
|
191
|
+
"-vf", `${opts.cropVf ? `${opts.cropVf},` : ""}${COVER_CROP_VF}`,
|
|
192
|
+
"-pix_fmt", "gray",
|
|
193
|
+
"-f", "rawvideo",
|
|
194
|
+
"-y", framePath,
|
|
195
|
+
]);
|
|
196
|
+
const pixels = new Uint8Array(await readFile(framePath));
|
|
197
|
+
await unlink(framePath).catch(() => {});
|
|
198
|
+
if (pixels.length < DET_W * DET_H) return null;
|
|
199
|
+
const face = opts.detectFace?.(pixels, DET_W, DET_H) ?? undefined;
|
|
200
|
+
return {
|
|
201
|
+
timeSec,
|
|
202
|
+
sharpness: laplacianVariance(pixels, DET_W, DET_H),
|
|
203
|
+
hasFace: face !== undefined,
|
|
204
|
+
face,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
193
208
|
/**
|
|
194
209
|
* Pick the best cover frame from the take: sharp, face present, early.
|
|
195
210
|
*
|
|
@@ -217,27 +232,13 @@ export async function pickCoverFrame(
|
|
|
217
232
|
|
|
218
233
|
for (let i = 0; i < samples; i++) {
|
|
219
234
|
const t = (window * (i + 0.5)) / samples;
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
"-frames:v", "1",
|
|
226
|
-
"-vf", `${opts.cropVf ? `${opts.cropVf},` : ""}${COVER_CROP_VF}`,
|
|
227
|
-
"-pix_fmt", "gray",
|
|
228
|
-
"-f", "rawvideo",
|
|
229
|
-
"-y", framePath,
|
|
230
|
-
]);
|
|
231
|
-
const pixels = new Uint8Array(await readFile(framePath));
|
|
232
|
-
await unlink(framePath).catch(() => {});
|
|
233
|
-
if (pixels.length < DET_W * DET_H) continue;
|
|
234
|
-
const face = opts.detectFace?.(pixels, DET_W, DET_H) ?? undefined;
|
|
235
|
-
raw.push({
|
|
236
|
-
timeSec: t,
|
|
237
|
-
sharpness: laplacianVariance(pixels, DET_W, DET_H),
|
|
238
|
-
hasFace: face !== undefined,
|
|
239
|
-
face,
|
|
235
|
+
const measured = await measureCoverFrame(tools, videoPath, t, {
|
|
236
|
+
cacheDir: opts.cacheDir,
|
|
237
|
+
cropVf: opts.cropVf,
|
|
238
|
+
detectFace: opts.detectFace,
|
|
239
|
+
frameName: `cover-frame-${i}.gray`,
|
|
240
240
|
});
|
|
241
|
+
if (measured) raw.push(measured);
|
|
241
242
|
}
|
|
242
243
|
if (raw.length === 0) return null;
|
|
243
244
|
|
|
@@ -251,3 +252,122 @@ export async function pickCoverFrame(
|
|
|
251
252
|
candidates.sort((a, b) => b.score - a.score);
|
|
252
253
|
return candidates[0]!;
|
|
253
254
|
}
|
|
255
|
+
|
|
256
|
+
// ---- Cover provenance (`<workdir>/cover.json`) ----------------------------
|
|
257
|
+
// Until this file existed, the ONLY thing that survived a cover render was the
|
|
258
|
+
// JPEG: changing a headline therefore meant a full re-render to re-derive the
|
|
259
|
+
// pick. Everything a faithful rebuild needs is recorded here so a regeneration
|
|
260
|
+
// is `renderCover` against a still that is already on disk.
|
|
261
|
+
|
|
262
|
+
/** The file the workdir keeps its cover provenance in. */
|
|
263
|
+
export const COVER_PROVENANCE_BASENAME = "cover.json";
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Annotated as `z.ZodType<CoverFace>` so the persisted shape and the in-memory
|
|
267
|
+
* one cannot drift apart silently — adding a fraction to `CoverFace` without
|
|
268
|
+
* adding it here is then a compile error, not a field that quietly stops
|
|
269
|
+
* round-tripping.
|
|
270
|
+
*/
|
|
271
|
+
const CoverFaceSchema: z.ZodType<CoverFace> = z.object({
|
|
272
|
+
centerXFrac: z.number(),
|
|
273
|
+
centerYFrac: z.number(),
|
|
274
|
+
sizeFrac: z.number(),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
export const CoverProvenanceSchema = z.object({
|
|
278
|
+
version: z.literal(1),
|
|
279
|
+
/** The banner text as SHIPPED — already through `coverHeadline`, and "" for
|
|
280
|
+
* the §34 case where the frame carried its own title. What was rendered,
|
|
281
|
+
* not what was proposed. */
|
|
282
|
+
text: z.string(),
|
|
283
|
+
/** "user" means a headline someone typed, which a later produce must not
|
|
284
|
+
* quietly overwrite with a fresh beat sheet's `coverText`. */
|
|
285
|
+
textSource: z.enum(["beatsheet", "user"]),
|
|
286
|
+
frame: z.object({
|
|
287
|
+
/** Which video the still came from: the finished render or the source. */
|
|
288
|
+
source: z.enum(["final", "source"]),
|
|
289
|
+
timeSec: z.number(),
|
|
290
|
+
/**
|
|
291
|
+
* The load-bearing field. This is the COVER-CROP geometry — deliberately
|
|
292
|
+
* NOT face.json's source-geometry measurement (see the `COVER_CROP_VF`
|
|
293
|
+
* doc comment for why the two are different numbers). Without it nothing
|
|
294
|
+
* can place the banner where the shipped cover placed it, and re-deriving
|
|
295
|
+
* it costs an ffmpeg extraction plus a cascade sweep. That single fact is
|
|
296
|
+
* what forced this whole file to exist.
|
|
297
|
+
*/
|
|
298
|
+
face: CoverFaceSchema.nullable(),
|
|
299
|
+
hasFace: z.boolean(),
|
|
300
|
+
sharpness: z.number(),
|
|
301
|
+
/** The still already on disk in the workdir (`cover-frame.png`) — a
|
|
302
|
+
* text-only regeneration reuses it verbatim and runs no ffmpeg at all. */
|
|
303
|
+
fileName: z.string(),
|
|
304
|
+
/**
|
|
305
|
+
* The ORIGINAL TAKE, and nothing else. Workdir-relative when the video
|
|
306
|
+
* lives in the workdir (a folder run's `mezzanine.mp4`), absolute
|
|
307
|
+
* otherwise: a workdir that moved must still resolve its own
|
|
308
|
+
* intermediates.
|
|
309
|
+
*
|
|
310
|
+
* Nullable, and never overwritten by a regeneration (2026-08-19): this
|
|
311
|
+
* field once recorded "whichever video the current frame was read from",
|
|
312
|
+
* so one `ossclip cover` on its default `--from final` rewrote a
|
|
313
|
+
* `mezzanine.mp4` here into the FINISHED render's path — after which
|
|
314
|
+
* `--from source` silently re-cut the cover from the finished video,
|
|
315
|
+
* burned-in captions, graphics and watermark included, while telling the
|
|
316
|
+
* user it was reading the clean source. It also destroyed the only
|
|
317
|
+
* on-disk record of where the take lives. `frame.source` already says
|
|
318
|
+
* which video the current still came from, so this one has no second
|
|
319
|
+
* job. Null means the take is genuinely unknown (a first regeneration off
|
|
320
|
+
* the final video, with no prior provenance) — `--from source` then says
|
|
321
|
+
* so instead of lying.
|
|
322
|
+
*/
|
|
323
|
+
sourceVideo: z.string().nullable(),
|
|
324
|
+
/** produce's `cropFilter(detection.uniform)`, and the SOURCE's — it is
|
|
325
|
+
* meaningless against a final-video frame, so it travels with
|
|
326
|
+
* `sourceVideo` under the same preserve-never-overwrite rule. Persisted
|
|
327
|
+
* because it is NOT reconstructible from anything else on disk: the
|
|
328
|
+
* letterbox detection that produced it is cached per source, and
|
|
329
|
+
* re-picking a frame without it frames two-thirds baked-in black bar. */
|
|
330
|
+
cropVf: z.string().nullable(),
|
|
331
|
+
}),
|
|
332
|
+
/** The OUTPUT frame the cover belongs to (R16 §76) — a landscape render
|
|
333
|
+
* gets a landscape cover, and a rebuild must not revert to 1080×1920. */
|
|
334
|
+
size: z.object({ width: z.number(), height: z.number() }),
|
|
335
|
+
/** Absolute path of the written `.cover.jpg`. */
|
|
336
|
+
out: z.string(),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
export type CoverProvenance = z.infer<typeof CoverProvenanceSchema>;
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read `<work>/cover.json`, or null when it is absent, corrupt, or fails the
|
|
343
|
+
* schema. Never throws: a pre-feature workdir has no such file at all, and a
|
|
344
|
+
* half-written one must degrade to "re-pick the frame" rather than brick the
|
|
345
|
+
* command — the same GET-path posture as `readApprovedConcept`.
|
|
346
|
+
*/
|
|
347
|
+
export async function readCoverProvenance(work: string): Promise<CoverProvenance | null> {
|
|
348
|
+
const path = join(work, COVER_PROVENANCE_BASENAME);
|
|
349
|
+
if (!existsSync(path)) return null;
|
|
350
|
+
try {
|
|
351
|
+
const parsed = CoverProvenanceSchema.safeParse(JSON.parse(await readFile(path, "utf8")));
|
|
352
|
+
return parsed.success ? parsed.data : null;
|
|
353
|
+
} catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Write `<work>/cover.json` atomically: tmp file, then rename.
|
|
360
|
+
*
|
|
361
|
+
* The editor and a `ossclip cover` run may read this at any moment, and a
|
|
362
|
+
* half-written document would be worse than a stale one — the same reasoning
|
|
363
|
+
* as the overrides save in edit.ts.
|
|
364
|
+
*/
|
|
365
|
+
export async function writeCoverProvenance(
|
|
366
|
+
work: string,
|
|
367
|
+
provenance: CoverProvenance,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
const path = join(work, COVER_PROVENANCE_BASENAME);
|
|
370
|
+
const tmp = `${path}.tmp`;
|
|
371
|
+
await writeFile(tmp, JSON.stringify(provenance, null, 2));
|
|
372
|
+
await rename(tmp, path);
|
|
373
|
+
}
|