@ossclip/core 0.1.26 → 0.1.28
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 +69 -2
- package/src/config.ts +15 -0
- package/src/cover-headline.ts +68 -0
- package/src/cover.ts +184 -64
- package/src/cutlist.ts +109 -0
- package/src/index.ts +1 -0
- package/src/overrides.ts +37 -0
- package/src/producer/antigravity.ts +237 -25
- package/src/producer/beats.ts +45 -4
- package/src/producer/claude-cli.ts +82 -2
- package/src/producer/failure.ts +40 -0
- package/src/producer/fallback.ts +91 -0
- package/src/producer/index.ts +65 -5
- package/src/producer/usage.ts +31 -1
- package/src/recut.ts +6 -1
- package/src/retime-preview.ts +331 -0
- package/src/schema.ts +19 -0
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -45,5 +45,72 @@ export {
|
|
|
45
45
|
// types only). The editor's load-path repair needs the MAP, not the raw span
|
|
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
|
-
|
|
49
|
-
|
|
48
|
+
// `mapsClose` rides along since cut review step 4: the editor's playhead
|
|
49
|
+
// hand-off across a live re-cut needs "did the clock actually change" to be
|
|
50
|
+
// the SAME float-tolerant comparison `livePreviewMap`'s identity gate uses,
|
|
51
|
+
// or a 1-ulp drift could seek the player for nothing.
|
|
52
|
+
export { mapFromKeptSpans, mapsClose, TimeMap, type KeptSpan } from "./timemap";
|
|
53
|
+
// The §35 cover word cap. The editor's CoverPanel shows the trimmed headline
|
|
54
|
+
// live as you type, and restating the trimming rules there would drift from
|
|
55
|
+
// the one the regenerate endpoint actually renders with. Imported from
|
|
56
|
+
// ./cover-headline, NOT ./cover — that module is node all the way down
|
|
57
|
+
// (node:fs, ./exec), which is exactly what this surface exists to keep out.
|
|
58
|
+
export { COVER_MAX_WORDS, coverHeadline } from "./cover-headline";
|
|
59
|
+
// The cleanup veto layer (cut review step 3), VALUE exports and browser-safe:
|
|
60
|
+
// cutlist.ts imports nothing but types from ./schema — verified before this
|
|
61
|
+
// export, zero node built-ins in its graph. The editor marks vetoed seams
|
|
62
|
+
// with the SAME `applyCleanupChoices` produce renders with (the
|
|
63
|
+
// buildCoverRender one-implementation-two-callers pattern); a browser copy is
|
|
64
|
+
// how the preview and the render would drift. `buildCutlist` itself rides
|
|
65
|
+
// along in the module graph but stays unexported here on purpose — the
|
|
66
|
+
// editor must never rebuild the proposal, only apply choices to the one
|
|
67
|
+
// produce recorded.
|
|
68
|
+
export {
|
|
69
|
+
applyCleanupChoices,
|
|
70
|
+
cleanupVetoable,
|
|
71
|
+
vetoedRemovals,
|
|
72
|
+
type CleanupChoices,
|
|
73
|
+
} from "./cutlist";
|
|
74
|
+
// The live post-veto preview (cut review step 4), VALUE exports and
|
|
75
|
+
// browser-safe: retime-preview.ts composes cutlist + recut + timemap — all
|
|
76
|
+
// already in this surface's runtime graph (recut.ts imports only overrides
|
|
77
|
+
// and timemap, zero node built-ins, verified before this export). The editor
|
|
78
|
+
// re-cuts its preview clock with the SAME `applyCleanupChoices` +
|
|
79
|
+
// `subtractRangesFromCutlist` sequence produce runs and re-times every prop
|
|
80
|
+
// through the SAME `remapPoint` produce re-anchors with — one implementation,
|
|
81
|
+
// two callers, so the preview cannot drift from the render.
|
|
82
|
+
// `previewClockMappers` rides along (step 4 follow-up): the surfaces that
|
|
83
|
+
// speak in single instants — transcript seeks, ghost bands, the cover
|
|
84
|
+
// panel's playhead — need the same walk as a point function, identity when
|
|
85
|
+
// no re-cut is live, threaded by App so no consumer learns the machinery.
|
|
86
|
+
// `cutRangeToOldClock` is the WRITE direction's range half (the follow-up's
|
|
87
|
+
// follow-up): a cut gesture's live window converted to the old-clock frame
|
|
88
|
+
// `doc.cuts` speaks, shared by the Inspector's button and App's delete
|
|
89
|
+
// modal so the shrink/refuse verdict cannot drift between the two.
|
|
90
|
+
export {
|
|
91
|
+
cutRangeToOldClock,
|
|
92
|
+
livePreviewMap,
|
|
93
|
+
previewClockMappers,
|
|
94
|
+
retimeForPreview,
|
|
95
|
+
type LivePreviewClocks,
|
|
96
|
+
type OldClockCutRange,
|
|
97
|
+
type PreviewClockMappers,
|
|
98
|
+
type RetimeablePreviewProps,
|
|
99
|
+
type RetimedPreviewFields,
|
|
100
|
+
} from "./retime-preview";
|
|
101
|
+
export type {
|
|
102
|
+
Probe,
|
|
103
|
+
Production,
|
|
104
|
+
// `RemovalReason` rides along with `Segment` (cut review step 2): the
|
|
105
|
+
// editor's reason→colour map is a `Record<RemovalReason, string>` precisely
|
|
106
|
+
// so a NEW reason in the vocabulary fails typecheck in the editor instead
|
|
107
|
+
// of silently drawing an uncoloured seam. (Since step 3 the schema module
|
|
108
|
+
// is in the runtime graph anyway — ./overrides imports RemovalReasonSchema
|
|
109
|
+
// for the `cleanup` key — but schema.ts is zod + scene-schema only, both
|
|
110
|
+
// already on this surface, so it stays browser-safe.)
|
|
111
|
+
RemovalReason,
|
|
112
|
+
RenderSettings,
|
|
113
|
+
Segment,
|
|
114
|
+
Transcript,
|
|
115
|
+
Word,
|
|
116
|
+
} from "./schema";
|
package/src/config.ts
CHANGED
|
@@ -19,6 +19,16 @@ export interface OssclipConfig {
|
|
|
19
19
|
* sheet always uses the main model. "same" disables tiering (FINDINGS §37).
|
|
20
20
|
*/
|
|
21
21
|
fastModel?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Reasoning effort for the antigravity provider — low | medium | high,
|
|
24
|
+
* agy's own `--effort` vocabulary. Consumed ONLY by antigravity today
|
|
25
|
+
* (§143: exposed after the hang incident — the knob existed and we passed
|
|
26
|
+
* nothing); every other provider ignores it. `--llm-effort` wins over this
|
|
27
|
+
* per run. File-only like `dictionary`; validated at the consumer
|
|
28
|
+
* (`resolveLlmEffort` in produce.ts), so a hand-edited `"max"` is one
|
|
29
|
+
* warning and an ignored key, never a coerced effort level.
|
|
30
|
+
*/
|
|
31
|
+
llmEffort?: string;
|
|
22
32
|
/**
|
|
23
33
|
* Download URLs for models the ggerganov mirror doesn't host, keyed by the
|
|
24
34
|
* bare model name — a user's own fine-tune needs one line:
|
|
@@ -196,6 +206,11 @@ export function loadConfig(): OssclipConfig {
|
|
|
196
206
|
modelDir: process.env.OSSCLIP_MODEL_DIR ?? fileCfg.modelDir ?? DEFAULTS.modelDir,
|
|
197
207
|
model: process.env.OSSCLIP_MODEL ?? fileCfg.model ?? DEFAULTS.model,
|
|
198
208
|
fastModel: process.env.OSSCLIP_FAST_MODEL ?? fileCfg.fastModel,
|
|
209
|
+
// File-only, the `dictionary` posture — and deliberately NO env spelling
|
|
210
|
+
// (flag + config are the whole interface): validated where it is USED
|
|
211
|
+
// (`resolveLlmEffort` in produce.ts), so a hand-edited `"max"` earns one
|
|
212
|
+
// warning there and agy's default, never a coerced effort.
|
|
213
|
+
llmEffort: fileCfg.llmEffort,
|
|
199
214
|
speaker: process.env.OSSCLIP_SPEAKER ?? fileCfg.speaker,
|
|
200
215
|
openEditorAfterProduce: (process.env.OSSCLIP_OPEN_EDITOR ??
|
|
201
216
|
fileCfg.openEditorAfterProduce) as OpenEditorPref | undefined,
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
/**
|
|
30
|
+
* Trailing words that cannot end a headline — the truncation reads as broken.
|
|
31
|
+
* Auxiliaries dangle exactly like prepositions: a real run (2026-08-22)
|
|
32
|
+
* truncated to "AI Gave Me Too Many Ideas. I Had" because the set had none.
|
|
33
|
+
* "i" is here for the same incident: popping "Had" alone leaves "…Ideas. I",
|
|
34
|
+
* a subject with its sentence cut off.
|
|
35
|
+
*/
|
|
36
|
+
const DANGLING = new Set([
|
|
37
|
+
"a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "is", "it",
|
|
38
|
+
"of", "on", "or", "the", "to", "with", "that", "this", "my", "your", "so",
|
|
39
|
+
"had", "has", "have", "was", "were", "will", "can", "should", "i",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cut a headline down to `maxWords`, preferring a natural break.
|
|
44
|
+
*
|
|
45
|
+
* A dash or colon usually separates a complete claim from its elaboration, so
|
|
46
|
+
* the first clause is a real headline rather than a sentence with its end
|
|
47
|
+
* lopped off. Only when that is still too long does this truncate — and then
|
|
48
|
+
* it refuses to stop on a preposition or article, which is what makes a
|
|
49
|
+
* truncation look like a bug instead of an edit.
|
|
50
|
+
*/
|
|
51
|
+
export function coverHeadline(text: string, maxWords = COVER_MAX_WORDS): string {
|
|
52
|
+
const clean = text.trim().replace(/\s+/g, " ");
|
|
53
|
+
if (!clean) return clean;
|
|
54
|
+
const words = (s: string): string[] => s.split(" ").filter(Boolean);
|
|
55
|
+
if (words(clean).length <= maxWords) return clean;
|
|
56
|
+
|
|
57
|
+
// First clause, if it stands on its own — never a two-word fragment. Even
|
|
58
|
+
// when the clause is itself too long it is the better thing to cut down,
|
|
59
|
+
// since truncating it can never wander past the dash into the elaboration.
|
|
60
|
+
const clause = clean.split(/\s*[—–:]\s*|\s+-\s+/)[0]!.trim();
|
|
61
|
+
const base = words(clause).length >= 3 ? clause : clean;
|
|
62
|
+
const out = words(base).slice(0, maxWords);
|
|
63
|
+
while (out.length > 3 && DANGLING.has(out[out.length - 1]!.toLowerCase().replace(/\W/g, ""))) {
|
|
64
|
+
out.pop();
|
|
65
|
+
}
|
|
66
|
+
// A clause that ended on its own punctuation keeps it; a cut does not.
|
|
67
|
+
return out.join(" ").replace(/[,;:—–-]+$/, "");
|
|
68
|
+
}
|
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
|
+
}
|
package/src/cutlist.ts
CHANGED
|
@@ -329,3 +329,112 @@ export function buildCutlist({
|
|
|
329
329
|
|
|
330
330
|
return segments;
|
|
331
331
|
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The user's veto layer over the automatic cutlist (cut review step 3):
|
|
335
|
+
* `buildCutlist` PROPOSES removals, this is how the user DECLINES some of
|
|
336
|
+
* them — per category ("keep all pauses") and per individual span. Persisted
|
|
337
|
+
* as `overrides.json`'s `cleanup` key (`OverrideDocSchema.cleanup`, which
|
|
338
|
+
* owns the on-disk contract); this structural interface is what the pure
|
|
339
|
+
* functions below accept, so this module stays free of zod and of the
|
|
340
|
+
* overrides module — callable from produce AND from the editor bundle with
|
|
341
|
+
* nothing but the schema's own types.
|
|
342
|
+
*/
|
|
343
|
+
export interface CleanupChoices {
|
|
344
|
+
/**
|
|
345
|
+
* Category master switches. `false` = do not remove this reason's spans;
|
|
346
|
+
* absent (or a tolerated `true` on disk) = the default, remove as proposed.
|
|
347
|
+
*/
|
|
348
|
+
reasons?: Partial<Record<RemovalReason, boolean>>;
|
|
349
|
+
/** Individual vetoes, SOURCE seconds — see `vetoedRemovals` for the
|
|
350
|
+
* overlap-based matching rule. */
|
|
351
|
+
kept?: readonly { srcIn: number; srcOut: number }[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Whether a removal reason CAN be declined at all. `user` cuts come from the
|
|
356
|
+
* `cuts[]` array — declining your own cut is Restore on the cut, an existing
|
|
357
|
+
* gesture, and a second way to undo it would leave the entry behind as dead
|
|
358
|
+
* weight (there is no "not cut" state for a `cuts` entry to hold, per its
|
|
359
|
+
* schema comment). `clip` is the `--clip` window, a selection decision, not a
|
|
360
|
+
* cleanup — "keeping" a clip removal would silently un-clip the video. One
|
|
361
|
+
* predicate, exported, so produce, the panel's checkboxes and the timeline's
|
|
362
|
+
* seam handler cannot disagree about what is toggleable.
|
|
363
|
+
*/
|
|
364
|
+
export function cleanupVetoable(reason: RemovalReason | undefined): boolean {
|
|
365
|
+
return reason !== "user" && reason !== "clip";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The `remove` spans of `cutlist` that `choices` declines — the shared
|
|
370
|
+
* predicate under `applyCleanupChoices` (which re-keeps exactly these) and
|
|
371
|
+
* the editor's vetoed-seam state / produce's "kept N pause removal(s)" line
|
|
372
|
+
* (which both NAME them). One list, computed once, so what the seam shows as
|
|
373
|
+
* "will be kept" and what the render actually keeps cannot drift.
|
|
374
|
+
*
|
|
375
|
+
* Individual vetoes match by OVERLAP, deliberately NOT by float equality of
|
|
376
|
+
* endpoints: a re-produce can shift a removal's boundary by a frame (a
|
|
377
|
+
* changed silence threshold, a repair that re-stamps a word), and an
|
|
378
|
+
* exact-match veto that silently stops matching is the failure mode — the
|
|
379
|
+
* pause the user declined would quietly start being cut again. A partial
|
|
380
|
+
* overlap re-keeps the WHOLE removal span: a removal is one decision, not
|
|
381
|
+
* divisible.
|
|
382
|
+
*/
|
|
383
|
+
export function vetoedRemovals(
|
|
384
|
+
cutlist: readonly Segment[],
|
|
385
|
+
choices: CleanupChoices | undefined,
|
|
386
|
+
): Segment[] {
|
|
387
|
+
if (!choices) return [];
|
|
388
|
+
const kept = choices.kept ?? [];
|
|
389
|
+
return cutlist.filter(
|
|
390
|
+
(seg) =>
|
|
391
|
+
seg.kind === "remove" &&
|
|
392
|
+
cleanupVetoable(seg.reason) &&
|
|
393
|
+
((seg.reason !== undefined && choices.reasons?.[seg.reason] === false) ||
|
|
394
|
+
kept.some((k) => k.srcIn < seg.srcOut && k.srcOut > seg.srcIn)),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Apply the user's cleanup choices to the automatic cutlist: every vetoed
|
|
400
|
+
* removal (see `vetoedRemovals`) becomes a keep again, and adjacent keeps
|
|
401
|
+
* merge so the result stays the same canonical shape `buildCutlist` emits —
|
|
402
|
+
* a full partition of the input's range with no gaps, no overlaps, monotonic
|
|
403
|
+
* (the invariants `TimeMap`'s constructor checks). The partition holds BY
|
|
404
|
+
* CONSTRUCTION: spans are only relabelled and merged, never moved, so the
|
|
405
|
+
* covered range cannot change.
|
|
406
|
+
*
|
|
407
|
+
* `undefined`/empty choices return the input content unchanged — the
|
|
408
|
+
* regression anchor: an overrides.json with no `cleanup` key must produce a
|
|
409
|
+
* byte-identical render.
|
|
410
|
+
*
|
|
411
|
+
* ONE implementation, two callers (the `buildCoverRender` pattern): produce
|
|
412
|
+
* calls this between `buildCutlist` and the `TimeMap`; the editor calls it
|
|
413
|
+
* (via `@ossclip/core/browser`) to mark vetoed seams. A preview that
|
|
414
|
+
* disagrees with the render is worse than no preview.
|
|
415
|
+
*/
|
|
416
|
+
export function applyCleanupChoices(
|
|
417
|
+
cutlist: readonly Segment[],
|
|
418
|
+
choices: CleanupChoices | undefined,
|
|
419
|
+
): Segment[] {
|
|
420
|
+
const vetoed = new Set(vetoedRemovals(cutlist, choices));
|
|
421
|
+
if (vetoed.size === 0) return [...cutlist];
|
|
422
|
+
const out: Segment[] = [];
|
|
423
|
+
for (const seg of cutlist) {
|
|
424
|
+
// A re-kept span drops `reason`/`confidence` — they described a removal
|
|
425
|
+
// that is no longer happening, and a keep carrying "pause" would read as
|
|
426
|
+
// a seventh segment kind everywhere the partition is consumed.
|
|
427
|
+
const next: Segment = vetoed.has(seg)
|
|
428
|
+
? { srcIn: seg.srcIn, srcOut: seg.srcOut, kind: "keep" }
|
|
429
|
+
: seg;
|
|
430
|
+
const prev = out[out.length - 1];
|
|
431
|
+
if (prev !== undefined && prev.kind === "keep" && next.kind === "keep") {
|
|
432
|
+
// Merge in place — `prev` is always this function's own object (pushed
|
|
433
|
+
// below as a fresh literal when it is a keep), never the caller's.
|
|
434
|
+
prev.srcOut = next.srcOut;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
out.push(next.kind === "keep" ? { srcIn: next.srcIn, srcOut: next.srcOut, kind: "keep" } : next);
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
package/src/index.ts
CHANGED
package/src/overrides.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type SceneComponentId,
|
|
8
8
|
type Theme,
|
|
9
9
|
} from "./scene-schema";
|
|
10
|
+
import { RemovalReasonSchema } from "./schema";
|
|
10
11
|
import { resolveSceneProps } from "./scene-registry";
|
|
11
12
|
import type { CaptionLine, CaptionWord } from "./captions";
|
|
12
13
|
|
|
@@ -435,6 +436,42 @@ export const OverrideDocSchema = z.object({
|
|
|
435
436
|
}),
|
|
436
437
|
)
|
|
437
438
|
.default([]),
|
|
439
|
+
/**
|
|
440
|
+
* The user's VETO over the automatic cutlist (cut review step 3). `cuts`
|
|
441
|
+
* above is the user ADDING a removal; this is the user DECLINING one the
|
|
442
|
+
* pipeline proposed — opposite directions, deliberately not merged.
|
|
443
|
+
* Consumed by `applyCleanupChoices` (cutlist.ts, which owns the matching
|
|
444
|
+
* semantics), in produce and in the editor alike.
|
|
445
|
+
*
|
|
446
|
+
* `reasons` are the category master switches ("keep all pauses"). Only
|
|
447
|
+
* `false` is ever WRITTEN — a `true` entry restates the default, and the
|
|
448
|
+
* editor DELETES the key instead (the `hidden`/`captionsHidden` rule: an
|
|
449
|
+
* override with nothing to say). A `true` on disk is still parsed and
|
|
450
|
+
* means default, tolerantly. `user` and `clip` keys parse but are inert
|
|
451
|
+
* (`cleanupVetoable`): declining your own cut is Restore on the cut, and
|
|
452
|
+
* "keeping" the --clip window's removal would silently un-clip the video.
|
|
453
|
+
*
|
|
454
|
+
* `kept` are individual vetoes, in SOURCE seconds — and that anchoring is
|
|
455
|
+
* the whole trick, same as `cuts[].src` above: a bare output-seconds pair
|
|
456
|
+
* is meaningless once its own re-cut has happened, while source time is
|
|
457
|
+
* stable across every re-cut. This layer is therefore RECUT-IMMUNE BY
|
|
458
|
+
* CONSTRUCTION and — unlike `splits` and `scenes[*].timing` — needs NO
|
|
459
|
+
* entry in `remapOverridesThroughRecut`. Matching against the (possibly
|
|
460
|
+
* re-produced) cutlist is by OVERLAP, never float equality of endpoints;
|
|
461
|
+
* `vetoedRemovals` (cutlist.ts) states why.
|
|
462
|
+
*
|
|
463
|
+
* Optional-with-default like `splits`/`cuts`, so every overrides.json
|
|
464
|
+
* written before the key existed parses byte-identically; absent means
|
|
465
|
+
* today's behaviour exactly.
|
|
466
|
+
*/
|
|
467
|
+
cleanup: z
|
|
468
|
+
.object({
|
|
469
|
+
reasons: z.partialRecord(RemovalReasonSchema, z.boolean()).default({}),
|
|
470
|
+
kept: z
|
|
471
|
+
.array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
|
|
472
|
+
.default([]),
|
|
473
|
+
})
|
|
474
|
+
.default({ reasons: {}, kept: [] }),
|
|
438
475
|
});
|
|
439
476
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
440
477
|
|