@ossclip/core 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/package.json +1 -1
- package/src/browser.ts +19 -0
- package/src/captions.ts +101 -2
- package/src/config.ts +64 -10
- package/src/cover-in-video.ts +56 -0
- package/src/cutlist.ts +38 -1
- package/src/exec.ts +13 -2
- package/src/grounding.ts +35 -13
- package/src/index.ts +5 -0
- package/src/ingest.ts +35 -1
- package/src/kept-takes.ts +170 -0
- package/src/overrides.ts +468 -35
- package/src/producer/caption-regen.ts +132 -0
- package/src/producer/index.ts +1 -0
- package/src/producer/youtube.ts +36 -2
- package/src/publish/captions.ts +102 -0
- package/src/publish/delivery.ts +303 -0
- package/src/publish/index.ts +6 -0
- package/src/publish/limits.ts +52 -0
- package/src/publish/postiz.ts +320 -0
- package/src/publish/progress.ts +75 -0
- package/src/publish/provider.ts +81 -0
- package/src/recut.ts +20 -4
- package/src/resolution.ts +114 -0
- package/src/restamp.ts +383 -0
- package/src/retime-preview.ts +166 -60
- package/src/scene-schema.ts +13 -0
- package/src/transcribe.ts +13 -0
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -35,6 +35,11 @@ export {
|
|
|
35
35
|
// set need the bundled font" (2026-08-17 — two conditions would drift).
|
|
36
36
|
export {
|
|
37
37
|
backfillSrcStart,
|
|
38
|
+
// The editor rebuilds the caption track over REVIVED material with the
|
|
39
|
+
// same builder + packing matrix produce renders with (cut-review rework
|
|
40
|
+
// follow-up) — a second packer is how preview and render would drift.
|
|
41
|
+
buildCaptionLines,
|
|
42
|
+
captionPackingFor,
|
|
38
43
|
captionsNeedNastaliq,
|
|
39
44
|
lineDirection,
|
|
40
45
|
NASTALIQ_FONT_NAME,
|
|
@@ -69,9 +74,15 @@ export { COVER_MAX_WORDS, coverHeadline } from "./cover-headline";
|
|
|
69
74
|
export {
|
|
70
75
|
applyCleanupChoices,
|
|
71
76
|
cleanupVetoable,
|
|
77
|
+
dismissedRemovals,
|
|
72
78
|
vetoedRemovals,
|
|
73
79
|
type CleanupChoices,
|
|
74
80
|
} from "./cutlist";
|
|
81
|
+
// Revived-material carving (cut-review rework 2026-08-26), browser-safe:
|
|
82
|
+
// kept-takes.ts imports only scene-schema, timemap and overrides types —
|
|
83
|
+
// all already in this surface's graph. Editor and produce carve with the
|
|
84
|
+
// SAME function (the applyCleanupChoices pattern).
|
|
85
|
+
export { carveKeptTakes, keptTakeId, type KeptRange, type CarveResult } from "./kept-takes";
|
|
75
86
|
// The live post-veto preview (cut review step 4), VALUE exports and
|
|
76
87
|
// browser-safe: retime-preview.ts composes cutlist + recut + timemap — all
|
|
77
88
|
// already in this surface's runtime graph (recut.ts imports only overrides
|
|
@@ -99,6 +110,14 @@ export {
|
|
|
99
110
|
type RetimeablePreviewProps,
|
|
100
111
|
type RetimedPreviewFields,
|
|
101
112
|
} from "./retime-preview";
|
|
113
|
+
// The caption re-key half of the re-transcribe splice (Phase A 2026-08-26),
|
|
114
|
+
// browser-safe by construction: restamp.ts imports `captionKeyFor` from
|
|
115
|
+
// ./overrides (already on this surface) plus types, and NOTHING else — see its
|
|
116
|
+
// header for why the token normalizer is restated there rather than imported
|
|
117
|
+
// from analyze.ts (which reaches child_process). The `useEdits` reducer owns
|
|
118
|
+
// `overrides.json`; the server that re-decodes the audio never writes it, so
|
|
119
|
+
// the re-key has to run in the browser.
|
|
120
|
+
export { rekeyCaptionRecords, type RekeyResult, type StampMove } from "./restamp";
|
|
102
121
|
export type {
|
|
103
122
|
Probe,
|
|
104
123
|
Production,
|
package/src/captions.ts
CHANGED
|
@@ -143,8 +143,103 @@ export interface CaptionOptions {
|
|
|
143
143
|
* the start toward it. Display-only: cuts, analysis and the transcript
|
|
144
144
|
* itself never see this, and `srcStart` keeps the RAW source stamp so the
|
|
145
145
|
* §137 edit anchor does not move.
|
|
146
|
+
*
|
|
147
|
+
* 2.0 → 1.5 (field case 2026-08-26, a revived retake): its smears SLIPPED
|
|
148
|
+
* UNDER the 2.0 bar — `dedicated` was stamped 2.43s, so the clamp still left
|
|
149
|
+
* the word squatting on screen for a full 2.0s with the karaoke highlight
|
|
150
|
+
* stuck on it, and 8 more words in that one transcript were the same shape.
|
|
151
|
+
* 1.5s exceeds any genuinely spoken English word, so the tighter bar cannot
|
|
152
|
+
* truncate real speech, and the 2026-08-17 incident still shows "Okay," for
|
|
153
|
+
* its final 1.5s. ONE lever, by doctrine: the end side is the trustworthy
|
|
154
|
+
* edge and is never clamped, so how hard the start pulls toward it is the
|
|
155
|
+
* only number here — a second constant would just be this one, twice.
|
|
156
|
+
*/
|
|
157
|
+
export const MAX_CAPTION_WORD_LEAD_SEC = 1.5;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The floor on how long a caption LINE stays on screen. Whisper's stamps can
|
|
161
|
+
* cram a burst of words into no time at all — field case 2026-08-26, a
|
|
162
|
+
* revived retake: ten words ("context could read 50 files and then gives a
|
|
163
|
+
* clean") inside 0.25s, 0.01–0.05s each, which packs into 3-word lines with
|
|
164
|
+
* ~0.06s windows. Rendered faithfully that is a flash nobody can read at any
|
|
165
|
+
* speed, so the display repairs it: a too-short line borrows from the GAP
|
|
166
|
+
* that follows it.
|
|
167
|
+
*
|
|
168
|
+
* SLACK ONLY, and there usually is none. On the transcript this came from,
|
|
169
|
+
* 98% of adjacent word pairs have a gap of ≤0 (the §18 contiguous-stamp
|
|
170
|
+
* chain, `parseWhisperJson` sets `next.start = w.end`), so on a zero-gap run
|
|
171
|
+
* of flash lines this sweep does NOTHING and the captions stay fast. That is
|
|
172
|
+
* the honest limit: a display cannot slow speech down, only spend slack that
|
|
173
|
+
* exists — which is also why the sweep is monotone and single-pass, never
|
|
174
|
+
* pushing a later line to make room. The slack it does find is largely what
|
|
175
|
+
* `MAX_CAPTION_WORD_LEAD_SEC` above creates by pulling a smeared start
|
|
176
|
+
* forward.
|
|
177
|
+
*/
|
|
178
|
+
export const MIN_CAPTION_LINE_DWELL_SEC = 0.7;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Extend every line that would flash by less than `MIN_CAPTION_LINE_DWELL_SEC`
|
|
182
|
+
* into the gap after it — the display-side repair of a crammed stamp burst.
|
|
183
|
+
*
|
|
184
|
+
* Forward, single-pass, monotone: only line ENDS move, and only later, so the
|
|
185
|
+
* caps below can be read off the ORIGINAL lines and no line can be pushed by
|
|
186
|
+
* one before it. Bounds, all three of which the packer already respects for
|
|
187
|
+
* `hold` (`buildCaptionLines` below) and which therefore cannot be dropped
|
|
188
|
+
* here without re-opening what they were added for:
|
|
189
|
+
* - the next line's START — never overlap, never reorder (§115,
|
|
190
|
+
* `packages/scenes/src/frames.ts`: no two lines may share a frame);
|
|
191
|
+
* - the next BREAKPOINT — a line held across a scene-cue edge sits in the
|
|
192
|
+
* WRONG layout's caption band and can land on a card or the face
|
|
193
|
+
* (FINDINGS §6b), and readability is not worth that;
|
|
194
|
+
* - `maxEnd`, the output duration — there are no frames past it to draw on.
|
|
195
|
+
* The last line is free of the NEIGHBOUR bound only; the other two still hold.
|
|
196
|
+
*
|
|
197
|
+
* Never shortens a line, and never touches `words`: the dwell is the LINE's
|
|
198
|
+
* window, and stretching the last word's karaoke stamp to fill it would just
|
|
199
|
+
* move the stuck-highlight bug from `MAX_CAPTION_WORD_LEAD_SEC`'s case into
|
|
200
|
+
* this one. Lines with no slack to take are returned VERBATIM. Pure, so the
|
|
201
|
+
* whole bounds matrix is testable without a packer.
|
|
202
|
+
*/
|
|
203
|
+
export function enforceLineDwell(
|
|
204
|
+
lines: readonly CaptionLine[],
|
|
205
|
+
opts: { breakpoints?: readonly number[]; maxEnd?: number } = {},
|
|
206
|
+
): CaptionLine[] {
|
|
207
|
+
const breakpoints = [...(opts.breakpoints ?? [])].sort((a, b) => a - b);
|
|
208
|
+
return lines.map((line, i) => {
|
|
209
|
+
if (line.end - line.start >= MIN_CAPTION_LINE_DWELL_SEC) return line;
|
|
210
|
+
let end = line.start + MIN_CAPTION_LINE_DWELL_SEC;
|
|
211
|
+
const next = lines[i + 1];
|
|
212
|
+
if (next) end = Math.min(end, next.start);
|
|
213
|
+
// Same predicate as the hold clamp, so the two agree on which boundary is
|
|
214
|
+
// "this line's": strictly after its start, with the packer's epsilon.
|
|
215
|
+
const boundary = breakpoints.find((b) => b > line.start + 1e-6);
|
|
216
|
+
if (boundary !== undefined) end = Math.min(end, boundary);
|
|
217
|
+
if (opts.maxEnd !== undefined) end = Math.min(end, opts.maxEnd);
|
|
218
|
+
// A cap at or before where the line already ended is no slack at all —
|
|
219
|
+
// return the line itself, so an untouched track stays byte-identical.
|
|
220
|
+
if (end <= line.end) return line;
|
|
221
|
+
return { ...line, end };
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Landscape draws captions at 44px on a 1920px frame against portrait's
|
|
227
|
+
* 64px on 1080px (`captionFontSizeFor`) — roughly 2.6× the horizontal text
|
|
228
|
+
* budget — so the portrait default's 3-word lines look sparse there;
|
|
229
|
+
* landscape packs 6 words over 2.4s, double the core defaults. Portrait
|
|
230
|
+
* returns those defaults VERBATIM — the core defaults are portrait's
|
|
231
|
+
* contract and its output must stay byte-identical. Lived in produce.ts
|
|
232
|
+
* until the cut-review follow-up; the editor's live caption rebuild packs
|
|
233
|
+
* with this same matrix, so it moved to the one browser-safe home.
|
|
146
234
|
*/
|
|
147
|
-
export
|
|
235
|
+
export function captionPackingFor(landscape: boolean): {
|
|
236
|
+
maxWordsPerLine: number;
|
|
237
|
+
maxLineDuration: number;
|
|
238
|
+
} {
|
|
239
|
+
return landscape
|
|
240
|
+
? { maxWordsPerLine: 6, maxLineDuration: 2.4 }
|
|
241
|
+
: { maxWordsPerLine: 3, maxLineDuration: 1.2 };
|
|
242
|
+
}
|
|
148
243
|
|
|
149
244
|
export function buildCaptionLines(
|
|
150
245
|
transcript: Transcript,
|
|
@@ -211,5 +306,9 @@ export function buildCaptionLines(
|
|
|
211
306
|
// A single word physically spanning a boundary stays readable to its end.
|
|
212
307
|
line.end = Math.min(Math.max(end, lastWordEnd), map.outputDuration);
|
|
213
308
|
}
|
|
214
|
-
|
|
309
|
+
// LAST, on the finished windows: `hold` has already had its say, so the
|
|
310
|
+
// dwell floor caps at an absolute `start + MIN_CAPTION_LINE_DWELL_SEC`
|
|
311
|
+
// rather than adding to what the hold produced — a line already long
|
|
312
|
+
// enough is returned untouched instead of held twice.
|
|
313
|
+
return enforceLineDwell(lines, { breakpoints, maxEnd: map.outputDuration });
|
|
215
314
|
}
|
package/src/config.ts
CHANGED
|
@@ -67,6 +67,15 @@ export interface OssclipConfig {
|
|
|
67
67
|
* `--watermark` / `--no-watermark` win over this per run.
|
|
68
68
|
*/
|
|
69
69
|
watermark?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Overlay the cover image on the opening frames of every produce run, for
|
|
72
|
+
* the platforms that ignore an uploaded cover and use frame 1. DEFAULT OFF:
|
|
73
|
+
* the overlay costs the first fraction of the hook, so it is a choice about
|
|
74
|
+
* where you publish, not a default anyone should inherit.
|
|
75
|
+
* `--cover-in-video` / `--no-cover-in-video` win over this per run
|
|
76
|
+
* (`resolveCoverInVideo`), the `watermark` contract exactly.
|
|
77
|
+
*/
|
|
78
|
+
coverInVideo?: boolean;
|
|
70
79
|
/**
|
|
71
80
|
* Terms of art the speaker uses — "JSON", "ossclip", "Genkit" — biasing
|
|
72
81
|
* transcription (whisper `--prompt`), vouching repair corrections, and
|
|
@@ -133,6 +142,24 @@ export interface OssclipConfig {
|
|
|
133
142
|
* one warning and the default, never a coerced tab count.
|
|
134
143
|
*/
|
|
135
144
|
renderConcurrency?: number;
|
|
145
|
+
/**
|
|
146
|
+
* Base URL of the user's own self-hosted Postiz instance
|
|
147
|
+
* (https://postiz.com), the backend `ossclip publish` posts through —
|
|
148
|
+
* "https://postiz.example.com" or "http://localhost:5000". Non-secret, so
|
|
149
|
+
* it may live here; the API key is `OSSCLIP_POSTIZ_API_KEY` in the
|
|
150
|
+
* ENVIRONMENT only (env.ts's documented rule — secrets never live in
|
|
151
|
+
* config.json). File-only like `audience`; validated at the consumer
|
|
152
|
+
* (`publishConfigured` in the CLI), never coerced.
|
|
153
|
+
*/
|
|
154
|
+
postizUrl?: string;
|
|
155
|
+
/**
|
|
156
|
+
* `--resolution`'s default for this machine: "auto" (keep what the source
|
|
157
|
+
* has, capped at 2160), "1080" (the built-in default), "1440" or "2160".
|
|
158
|
+
* File-only, the `watermark` posture: validated where it is USED
|
|
159
|
+
* (`resolveResolution` in produce.ts), so a hand-edited "4k" earns one
|
|
160
|
+
* warning and the 1080 default rather than a coerced render size.
|
|
161
|
+
*/
|
|
162
|
+
resolution?: string;
|
|
136
163
|
/**
|
|
137
164
|
* USD per million tokens, keyed by model id or family substring — overrides
|
|
138
165
|
* the built-in assumptions in `producer/usage.ts` so a run's cost line
|
|
@@ -199,28 +226,50 @@ export function loadConfig(): OssclipConfig {
|
|
|
199
226
|
} catch {
|
|
200
227
|
// no config file — fine
|
|
201
228
|
}
|
|
229
|
+
return resolveConfig(fileCfg, process.env);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The pure half of `loadConfig` — the file-vs-env-vs-default resolution with
|
|
234
|
+
* no homedir read, so the mapping itself is testable (config.test.ts). Split
|
|
235
|
+
* out after the 2026-08-27 publish E2E: `postizUrl` sat on the TYPE and in
|
|
236
|
+
* the docs but this hand-written mapping never copied it, so publish reported
|
|
237
|
+
* "missing postizUrl" against a config.json that plainly had it — a key that
|
|
238
|
+
* exists only in the type is invisible at runtime, and nothing could say so
|
|
239
|
+
* while the mapping lived behind the filesystem.
|
|
240
|
+
*/
|
|
241
|
+
export function resolveConfig(
|
|
242
|
+
fileCfg: Partial<OssclipConfig>,
|
|
243
|
+
env: NodeJS.ProcessEnv,
|
|
244
|
+
): OssclipConfig {
|
|
202
245
|
return {
|
|
203
|
-
ffmpegPath:
|
|
204
|
-
ffprobePath:
|
|
205
|
-
whisperPath:
|
|
206
|
-
modelDir:
|
|
207
|
-
model:
|
|
208
|
-
fastModel:
|
|
246
|
+
ffmpegPath: env.OSSCLIP_FFMPEG ?? fileCfg.ffmpegPath ?? DEFAULTS.ffmpegPath,
|
|
247
|
+
ffprobePath: env.OSSCLIP_FFPROBE ?? fileCfg.ffprobePath ?? DEFAULTS.ffprobePath,
|
|
248
|
+
whisperPath: env.OSSCLIP_WHISPER ?? fileCfg.whisperPath ?? DEFAULTS.whisperPath,
|
|
249
|
+
modelDir: env.OSSCLIP_MODEL_DIR ?? fileCfg.modelDir ?? DEFAULTS.modelDir,
|
|
250
|
+
model: env.OSSCLIP_MODEL ?? fileCfg.model ?? DEFAULTS.model,
|
|
251
|
+
fastModel: env.OSSCLIP_FAST_MODEL ?? fileCfg.fastModel,
|
|
209
252
|
// File-only, the `dictionary` posture — and deliberately NO env spelling
|
|
210
253
|
// (flag + config are the whole interface): validated where it is USED
|
|
211
254
|
// (`resolveLlmEffort` in produce.ts), so a hand-edited `"max"` earns one
|
|
212
255
|
// warning there and agy's default, never a coerced effort.
|
|
213
256
|
llmEffort: fileCfg.llmEffort,
|
|
214
|
-
speaker:
|
|
215
|
-
openEditorAfterProduce: (
|
|
216
|
-
|
|
217
|
-
|
|
257
|
+
speaker: env.OSSCLIP_SPEAKER ?? fileCfg.speaker,
|
|
258
|
+
openEditorAfterProduce: (env.OSSCLIP_OPEN_EDITOR ?? fileCfg.openEditorAfterProduce) as
|
|
259
|
+
| OpenEditorPref
|
|
260
|
+
| undefined,
|
|
261
|
+
browserExecutable: env.OSSCLIP_BROWSER ?? fileCfg.browserExecutable,
|
|
218
262
|
// File-only, like `pricing`: an env spelling would arrive as a string,
|
|
219
263
|
// and "false" is truthy — parse-don't-coerce says no such trap. The
|
|
220
264
|
// strict `=== true` check lives at the consumer (produce's
|
|
221
265
|
// resolveWatermark), so a hand-edited non-boolean stays OFF, the safe
|
|
222
266
|
// default for a credit.
|
|
223
267
|
watermark: fileCfg.watermark,
|
|
268
|
+
// File-only, `watermark`'s posture verbatim: the strict `=== true` lives
|
|
269
|
+
// at the consumer (produce's resolveCoverInVideo), so a hand-edited
|
|
270
|
+
// non-boolean stays OFF — the safe default for something that paints over
|
|
271
|
+
// the first frames of the hook.
|
|
272
|
+
coverInVideo: fileCfg.coverInVideo,
|
|
224
273
|
// File-only for the same reason as `watermark`: these are structured
|
|
225
274
|
// values a hand-editable JSON file supplies, and parse-don't-coerce says
|
|
226
275
|
// the strict checks live at the consumer — `validDictionary` /
|
|
@@ -248,5 +297,10 @@ export function loadConfig(): OssclipConfig {
|
|
|
248
297
|
thumbnailBrief: fileCfg.thumbnailBrief,
|
|
249
298
|
thumbnailModel: fileCfg.thumbnailModel,
|
|
250
299
|
pricing: fileCfg.pricing,
|
|
300
|
+
// File-only, non-secret by declaration (the field's own doc): the API key
|
|
301
|
+
// deliberately lives in the environment (publish.ts's
|
|
302
|
+
// `publishConfigured`), so this is only the instance URL.
|
|
303
|
+
postizUrl: fileCfg.postizUrl,
|
|
304
|
+
resolution: fileCfg.resolution,
|
|
251
305
|
};
|
|
252
306
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--cover-in-video` (§93): the cover image OVERLAID on the short's opening
|
|
3
|
+
* frames, for the platforms that ignore an uploaded cover and use frame 1.
|
|
4
|
+
*
|
|
5
|
+
* OVERLAY, never insertion. Inserting a still at the head would shift every
|
|
6
|
+
* output instant after it — audio, spans, splits, pinned timing, caption
|
|
7
|
+
* stamps — which is the §93 A/V-sync trap the roadmap item refused to rush.
|
|
8
|
+
* Painting over frames that already exist changes no clock at all, so nothing
|
|
9
|
+
* downstream has to be re-anchored and an off run stays byte-identical.
|
|
10
|
+
*
|
|
11
|
+
* The cost of the overlay is the mirror image: whatever it covers is LOST for
|
|
12
|
+
* its duration, not delayed. That is what bounds the window below.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Longest the cover may sit on top of the video. Half a second is about the
|
|
17
|
+
* shortest a still reads as a deliberate first frame in a feed scrub; more
|
|
18
|
+
* than that and the overlay is eating the hook the whole pipeline exists to
|
|
19
|
+
* put in the first two seconds.
|
|
20
|
+
*/
|
|
21
|
+
export const COVER_IN_VIDEO_CAP_SEC = 0.5;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shortest window worth rendering. A take whose first word lands at 0.04s
|
|
25
|
+
* would otherwise get a one-or-two-frame flash that reads as a glitch rather
|
|
26
|
+
* than a cover — and the floor deliberately eats the head of that first word,
|
|
27
|
+
* because a cover nobody can see is not a cover.
|
|
28
|
+
*/
|
|
29
|
+
export const COVER_IN_VIDEO_FLOOR_SEC = 0.2;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How long the cover overlay lasts, in OUTPUT seconds.
|
|
33
|
+
*
|
|
34
|
+
* It ends at the FIRST WORD's start: the moment speech begins is the moment
|
|
35
|
+
* the overlay starts costing content, and the head of a take is usually a
|
|
36
|
+
* breath or a settle nobody misses. Clamped into `[floorSec, capSec]` — see
|
|
37
|
+
* the two constants for what each bound is protecting.
|
|
38
|
+
*
|
|
39
|
+
* `words` are OUTPUT-clock words (caption words, post-cut): the caller owns
|
|
40
|
+
* the clock, this owns the arithmetic. No words at all — a `--no-produce` run,
|
|
41
|
+
* a silent take, a transcript that came back empty — takes the cap, since
|
|
42
|
+
* there is no speech for the overlay to be in the way of.
|
|
43
|
+
*
|
|
44
|
+
* Pure so the whole matrix is testable without a transcript on disk.
|
|
45
|
+
*/
|
|
46
|
+
export function coverInVideoWindow(
|
|
47
|
+
words: readonly { start: number }[],
|
|
48
|
+
opts: { capSec: number; floorSec: number },
|
|
49
|
+
): number {
|
|
50
|
+
const first = words[0]?.start;
|
|
51
|
+
// `Number.isFinite`, not a truthiness check: a first word at exactly 0 is a
|
|
52
|
+
// real value (it floors below), while a NaN start from a mangled transcript
|
|
53
|
+
// must fall back to the cap rather than propagate into a frame count.
|
|
54
|
+
if (first === undefined || !Number.isFinite(first)) return opts.capSec;
|
|
55
|
+
return Math.min(opts.capSec, Math.max(opts.floorSec, first));
|
|
56
|
+
}
|
package/src/cutlist.ts
CHANGED
|
@@ -349,6 +349,15 @@ export interface CleanupChoices {
|
|
|
349
349
|
/** Individual vetoes, SOURCE seconds — see `vetoedRemovals` for the
|
|
350
350
|
* overlap-based matching rule. */
|
|
351
351
|
kept?: readonly { srcIn: number; srcOut: number }[];
|
|
352
|
+
/**
|
|
353
|
+
* Dismissed proposals ("not a retake") — the classification itself was
|
|
354
|
+
* wrong, so the material is ordinary footage and the marker disappears.
|
|
355
|
+
* SOURCE seconds, overlap-matched like `kept`. Kept in its own list, NOT
|
|
356
|
+
* folded into `kept`: `vetoedRemovals` feeds the "kept · <reason>" visual
|
|
357
|
+
* state and produce's kept-N line, and a dismissed marker must read as
|
|
358
|
+
* neither. `applyCleanupChoices` re-keeps both.
|
|
359
|
+
*/
|
|
360
|
+
dismissed?: readonly { srcIn: number; srcOut: number }[];
|
|
352
361
|
}
|
|
353
362
|
|
|
354
363
|
/**
|
|
@@ -413,11 +422,39 @@ export function vetoedRemovals(
|
|
|
413
422
|
* (via `@ossclip/core/browser`) to mark vetoed seams. A preview that
|
|
414
423
|
* disagrees with the render is worse than no preview.
|
|
415
424
|
*/
|
|
425
|
+
/**
|
|
426
|
+
* The `remove` spans of `cutlist` that `choices.dismissed` reclassifies away
|
|
427
|
+
* — `vetoedRemovals`' exact matching rule (overlap, never float equality; a
|
|
428
|
+
* partial overlap dismisses the WHOLE removal — one decision, not divisible)
|
|
429
|
+
* over the other list. Separate function on purpose: the two lists mean
|
|
430
|
+
* different things to every DISPLAY surface (a veto is "kept", a dismissal
|
|
431
|
+
* is "there was never anything here"), while `applyCleanupChoices` re-keeps
|
|
432
|
+
* the union because the RENDER outcome is identical.
|
|
433
|
+
*/
|
|
434
|
+
export function dismissedRemovals(
|
|
435
|
+
cutlist: readonly Segment[],
|
|
436
|
+
choices: CleanupChoices | undefined,
|
|
437
|
+
): Segment[] {
|
|
438
|
+
const dismissed = choices?.dismissed ?? [];
|
|
439
|
+
if (dismissed.length === 0) return [];
|
|
440
|
+
return cutlist.filter(
|
|
441
|
+
(seg) =>
|
|
442
|
+
seg.kind === "remove" &&
|
|
443
|
+
cleanupVetoable(seg.reason) &&
|
|
444
|
+
dismissed.some((d) => d.srcIn < seg.srcOut && d.srcOut > seg.srcIn),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
416
448
|
export function applyCleanupChoices(
|
|
417
449
|
cutlist: readonly Segment[],
|
|
418
450
|
choices: CleanupChoices | undefined,
|
|
419
451
|
): Segment[] {
|
|
420
|
-
const vetoed = new Set(
|
|
452
|
+
const vetoed = new Set([
|
|
453
|
+
...vetoedRemovals(cutlist, choices),
|
|
454
|
+
// Dismissed proposals re-keep identically — the difference is display
|
|
455
|
+
// state and permanence, not render outcome (dismissedRemovals' doc).
|
|
456
|
+
...dismissedRemovals(cutlist, choices),
|
|
457
|
+
]);
|
|
421
458
|
if (vetoed.size === 0) return [...cutlist];
|
|
422
459
|
const out: Segment[] = [];
|
|
423
460
|
for (const seg of cutlist) {
|
package/src/exec.ts
CHANGED
|
@@ -12,13 +12,24 @@ export interface ExecResult {
|
|
|
12
12
|
export function run(
|
|
13
13
|
bin: string,
|
|
14
14
|
args: string[],
|
|
15
|
-
opts: {
|
|
15
|
+
opts: {
|
|
16
|
+
allowNonZero?: boolean;
|
|
17
|
+
stdin?: string;
|
|
18
|
+
/** Per-chunk stdout tap, IN ADDITION to collection — the delivery
|
|
19
|
+
* encode's `-progress pipe:1` stream needs live chunks, not the
|
|
20
|
+
* post-mortem transcript. */
|
|
21
|
+
onStdout?: (chunk: string) => void;
|
|
22
|
+
} = {},
|
|
16
23
|
): Promise<ExecResult> {
|
|
17
24
|
return new Promise((resolve, reject) => {
|
|
18
25
|
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
19
26
|
let stdout = "";
|
|
20
27
|
let stderr = "";
|
|
21
|
-
child.stdout.on("data", (c: Buffer) =>
|
|
28
|
+
child.stdout.on("data", (c: Buffer) => {
|
|
29
|
+
const text = c.toString();
|
|
30
|
+
stdout += text;
|
|
31
|
+
opts.onStdout?.(text);
|
|
32
|
+
});
|
|
22
33
|
child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
|
|
23
34
|
child.on("error", (err) => reject(new Error(`${bin} failed to start: ${err.message}`)));
|
|
24
35
|
child.on("close", (code) => {
|
package/src/grounding.ts
CHANGED
|
@@ -102,6 +102,39 @@ function stringsOf(value: unknown): string[] {
|
|
|
102
102
|
return [];
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The tokens in `text` that the transcript nowhere supports, in text order,
|
|
107
|
+
* duplicates kept. This IS the grounding rule — `checkGrounding` walks scene
|
|
108
|
+
* fields through it, and the publish panel's caption regenerate runs it over
|
|
109
|
+
* a rewritten caption as an ADVISORY (captions legitimately contain brand and
|
|
110
|
+
* platform words never spoken, so its callers show notes, never a block).
|
|
111
|
+
* One spelling on purpose: the module header's §17 history is what a second
|
|
112
|
+
* copy of the supported() relaxation would eventually re-earn.
|
|
113
|
+
*
|
|
114
|
+
* The spoken set is rebuilt per call — cheap at real transcript sizes, and
|
|
115
|
+
* the price of keeping the rule callable on a single string.
|
|
116
|
+
*
|
|
117
|
+
* The transcript parameter demands only spoken text, which is all the rule
|
|
118
|
+
* reads: a full `Transcript` satisfies it, and so does the edit server's
|
|
119
|
+
* leniently-read transcript.json (words filtered to those with string text,
|
|
120
|
+
* timing not re-validated for a check that never looks at it).
|
|
121
|
+
*/
|
|
122
|
+
export function ungroundedTokens(
|
|
123
|
+
text: string,
|
|
124
|
+
transcript: { words: ReadonlyArray<{ text: string }> },
|
|
125
|
+
speaker?: string,
|
|
126
|
+
): string[] {
|
|
127
|
+
const spoken = new Set([
|
|
128
|
+
...transcript.words.flatMap((w) => tokenize(w.text)),
|
|
129
|
+
...(speaker ? tokenize(speaker) : []),
|
|
130
|
+
]);
|
|
131
|
+
const supported = (token: string): boolean =>
|
|
132
|
+
spoken.has(token) ||
|
|
133
|
+
spoken.has(`${token}s`) ||
|
|
134
|
+
(token.endsWith("s") && spoken.has(token.slice(0, -1)));
|
|
135
|
+
return tokenize(text).filter((token) => needsSupport(token) && !supported(token));
|
|
136
|
+
}
|
|
137
|
+
|
|
105
138
|
export function checkGrounding(
|
|
106
139
|
scenes: readonly Scene[],
|
|
107
140
|
transcript: Transcript,
|
|
@@ -113,25 +146,14 @@ export function checkGrounding(
|
|
|
113
146
|
*/
|
|
114
147
|
speaker?: string,
|
|
115
148
|
): GroundingIssue[] {
|
|
116
|
-
const spoken = new Set([
|
|
117
|
-
...transcript.words.flatMap((w) => tokenize(w.text)),
|
|
118
|
-
...(speaker ? tokenize(speaker) : []),
|
|
119
|
-
]);
|
|
120
|
-
const supported = (token: string): boolean =>
|
|
121
|
-
spoken.has(token) ||
|
|
122
|
-
spoken.has(`${token}s`) ||
|
|
123
|
-
(token.endsWith("s") && spoken.has(token.slice(0, -1)));
|
|
124
|
-
|
|
125
149
|
const issues: GroundingIssue[] = [];
|
|
126
150
|
for (const scene of scenes) {
|
|
127
151
|
const fields = CHECKED_FIELDS[scene.component] ?? [];
|
|
128
152
|
const merged = { ...scene.props, ...scene.overrides };
|
|
129
153
|
for (const field of fields) {
|
|
130
154
|
for (const text of stringsOf(merged[field])) {
|
|
131
|
-
for (const token of
|
|
132
|
-
|
|
133
|
-
issues.push({ sceneId: scene.id, component: scene.component, field, token });
|
|
134
|
-
}
|
|
155
|
+
for (const token of ungroundedTokens(text, transcript, speaker)) {
|
|
156
|
+
issues.push({ sceneId: scene.id, component: scene.component, field, token });
|
|
135
157
|
}
|
|
136
158
|
}
|
|
137
159
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./recut";
|
|
|
10
10
|
export * from "./ingest";
|
|
11
11
|
export * from "./concat";
|
|
12
12
|
export * from "./transcribe";
|
|
13
|
+
export * from "./restamp";
|
|
13
14
|
export * from "./analyze";
|
|
14
15
|
export * from "./cutlist";
|
|
15
16
|
export * from "./retime-preview";
|
|
@@ -28,6 +29,9 @@ export * from "./normalize";
|
|
|
28
29
|
export * from "./framing";
|
|
29
30
|
export * from "./face";
|
|
30
31
|
export * from "./cover";
|
|
32
|
+
export * from "./cover-in-video";
|
|
33
|
+
export * from "./publish/index";
|
|
34
|
+
export * from "./kept-takes";
|
|
31
35
|
export * from "./thumbnail";
|
|
32
36
|
export * from "./source-text";
|
|
33
37
|
export * from "./report";
|
|
@@ -38,4 +42,5 @@ export * from "./export-premiere-xml";
|
|
|
38
42
|
export * from "./export-premiere-project";
|
|
39
43
|
export * from "./export-xmeml-util";
|
|
40
44
|
export * from "./config";
|
|
45
|
+
export * from "./resolution";
|
|
41
46
|
export { run } from "./exec";
|
package/src/ingest.ts
CHANGED
|
@@ -85,6 +85,40 @@ export async function extractAudio(tools: IngestTools, src: string, outWav: stri
|
|
|
85
85
|
]);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Extract ONE span of an existing wav, same 16 kHz mono PCM shape
|
|
90
|
+
* (2026-08-26, the caption re-alignment pass).
|
|
91
|
+
*
|
|
92
|
+
* Fed the workdir's `audio.wav`, which `extractAudio` above already wrote at
|
|
93
|
+
* 16k/mono/pcm_s16le — so this is a sample-exact cut, not a re-encode, and it
|
|
94
|
+
* costs milliseconds even on a long source. Re-slicing from the ORIGINAL video
|
|
95
|
+
* would decode video frames for nothing and hand whisper an audio stream
|
|
96
|
+
* conditioned differently from the one the first pass decoded, which is
|
|
97
|
+
* exactly the variable a re-transcription is trying to hold still.
|
|
98
|
+
*
|
|
99
|
+
* `-ss` goes BEFORE `-i`: as an input option ffmpeg seeks the demuxer and
|
|
100
|
+
* starts decoding at the span, instead of decoding the whole file and
|
|
101
|
+
* discarding everything ahead of it. On PCM that seek is exact, so the clip's
|
|
102
|
+
* stamps are `spanStart`-relative with no drift to compensate for
|
|
103
|
+
* (`alignRestamp` adds the offset back).
|
|
104
|
+
*/
|
|
105
|
+
export async function extractAudioSpan(
|
|
106
|
+
tools: IngestTools,
|
|
107
|
+
wav: string,
|
|
108
|
+
outWav: string,
|
|
109
|
+
fromSec: number,
|
|
110
|
+
durSec: number,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
await run(tools.ffmpegPath, [
|
|
113
|
+
"-y",
|
|
114
|
+
"-ss", fromSec.toFixed(3),
|
|
115
|
+
"-i", wav,
|
|
116
|
+
"-t", durSec.toFixed(3),
|
|
117
|
+
"-vn", "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le",
|
|
118
|
+
outWav,
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
|
|
88
122
|
/**
|
|
89
123
|
* Headroom over the exact displayed size so a zoomed span never renders from
|
|
90
124
|
* below-native pixels (2026-08-17 render-speed pass). The two motion drivers
|
|
@@ -103,7 +137,7 @@ export interface MezzanineScale {
|
|
|
103
137
|
}
|
|
104
138
|
|
|
105
139
|
/** Nearest even dimension — yuv420 chroma subsampling needs both axes even. */
|
|
106
|
-
function evenDim(v: number): number {
|
|
140
|
+
export function evenDim(v: number): number {
|
|
107
141
|
return Math.max(2, 2 * Math.round(v / 2));
|
|
108
142
|
}
|
|
109
143
|
|