@ossclip/core 0.1.31 → 0.1.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/browser.ts +19 -0
- package/src/captions.ts +101 -2
- package/src/config.ts +24 -0
- package/src/cover-in-video.ts +56 -0
- package/src/cutlist.ts +38 -1
- package/src/index.ts +4 -0
- package/src/ingest.ts +34 -0
- package/src/kept-takes.ts +170 -0
- package/src/overrides.ts +468 -35
- package/src/producer/youtube.ts +36 -2
- package/src/publish/captions.ts +79 -0
- package/src/publish/index.ts +3 -0
- package/src/publish/postiz.ts +232 -0
- package/src/publish/provider.ts +58 -0
- package/src/recut.ts +20 -4
- package/src/restamp.ts +383 -0
- package/src/retime-preview.ts +166 -60
- package/src/scene-schema.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,16 @@ 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;
|
|
136
155
|
/**
|
|
137
156
|
* USD per million tokens, keyed by model id or family substring — overrides
|
|
138
157
|
* the built-in assumptions in `producer/usage.ts` so a run's cost line
|
|
@@ -221,6 +240,11 @@ export function loadConfig(): OssclipConfig {
|
|
|
221
240
|
// resolveWatermark), so a hand-edited non-boolean stays OFF, the safe
|
|
222
241
|
// default for a credit.
|
|
223
242
|
watermark: fileCfg.watermark,
|
|
243
|
+
// File-only, `watermark`'s posture verbatim: the strict `=== true` lives
|
|
244
|
+
// at the consumer (produce's resolveCoverInVideo), so a hand-edited
|
|
245
|
+
// non-boolean stays OFF — the safe default for something that paints over
|
|
246
|
+
// the first frames of the hook.
|
|
247
|
+
coverInVideo: fileCfg.coverInVideo,
|
|
224
248
|
// File-only for the same reason as `watermark`: these are structured
|
|
225
249
|
// values a hand-editable JSON file supplies, and parse-don't-coerce says
|
|
226
250
|
// the strict checks live at the consumer — `validDictionary` /
|
|
@@ -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/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";
|
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
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { SceneCue } from "./scene-schema";
|
|
2
|
+
import type { TimeMap } from "./timemap";
|
|
3
|
+
import { SPLIT_MIN_PIECE_SEC } from "./overrides";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Carve KEPT (vetoed) and DISMISSED cleanup removals out of the plain-take
|
|
7
|
+
* cues, so revived material is a first-class block instead of an invisible
|
|
8
|
+
* stretch annexed by its neighbour (cut-review rework, 2026-08-26).
|
|
9
|
+
*
|
|
10
|
+
* Why annexation happens without this: the live memo's `retimeForPreview`
|
|
11
|
+
* only remaps EXISTING cue endpoints, and `TimeMap.toSource` at a seam
|
|
12
|
+
* returns the earlier preimage (timemap.ts's own boundary doc), so the cue
|
|
13
|
+
* after the seam starts exactly where the revived material begins — the
|
|
14
|
+
* stretch belongs to it, carries no id of its own, and cannot be selected,
|
|
15
|
+
* labeled, split, or trimmed.
|
|
16
|
+
*
|
|
17
|
+
* ONE implementation, two callers (the `applyCleanupChoices` pattern):
|
|
18
|
+
* produce carves between `fillPlainCues` and `splitCues` with this run's
|
|
19
|
+
* map, so `take-kept-*` ids exist server-side and framing edits on them
|
|
20
|
+
* survive a re-render; the editor carves after `retimeForPreview` with
|
|
21
|
+
* `livePreviewMap`'s newMap, so the block appears the moment a chip is
|
|
22
|
+
* clicked. Same ranges, same map semantics — the two cannot drift.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface KeptRange {
|
|
26
|
+
/** SOURCE seconds of the removal the user kept or dismissed. */
|
|
27
|
+
srcIn: number;
|
|
28
|
+
srcOut: number;
|
|
29
|
+
/**
|
|
30
|
+
* A dismissed range carves the same stable block but WITHOUT the `kept`
|
|
31
|
+
* tag: dismissed material is ordinary footage and must render as a normal
|
|
32
|
+
* take, while a vetoed-kept range renders in the revived state.
|
|
33
|
+
*/
|
|
34
|
+
dismissed?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The carved cue's id, from the range's SOURCE milliseconds — stable across
|
|
39
|
+
* re-cuts, veto toggles and re-produces by construction (§155: key on the
|
|
40
|
+
* property the disruption cannot move), so a framing edit on the revived
|
|
41
|
+
* block survives all of them.
|
|
42
|
+
*/
|
|
43
|
+
export function keptTakeId(srcIn: number): string {
|
|
44
|
+
return `take-kept-${Math.round(srcIn * 1000)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Below this, a leading/trailing remainder of the carved cue is float dust
|
|
48
|
+
* from seam math, not a piece anyone can edit — it folds into the carved
|
|
49
|
+
* block instead of surviving as a sliver cue. */
|
|
50
|
+
const REMAINDER_EPS = 0.05;
|
|
51
|
+
|
|
52
|
+
export interface CarveResult {
|
|
53
|
+
cues: SceneCue[];
|
|
54
|
+
reports: string[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* For each range: map its source edges onto `map`'s output clock (exact —
|
|
59
|
+
* a kept range is interior to a merged keep span) and split the covering
|
|
60
|
+
* PLAIN cue into up-to-three pieces, the middle one becoming the
|
|
61
|
+
* `take-kept-<srcInMs>` block. Rules, each stated where enforced:
|
|
62
|
+
*
|
|
63
|
+
* - a range shorter than `SPLIT_MIN_PIECE_SEC` carves nothing (chip-only,
|
|
64
|
+
* reported) — deliberately below `MIN_PLAIN_SEC` (0.6, fill.ts), because
|
|
65
|
+
* this is real footage the user asked to see, not an assembler gap;
|
|
66
|
+
* - a range covered by a GRAPHIC cue is left alone with a report — the
|
|
67
|
+
* graphic owns that window;
|
|
68
|
+
* - a range whose block already exists (produce carved it server-side, or
|
|
69
|
+
* an earlier call did) is skipped — carving is idempotent;
|
|
70
|
+
* - a range not fully inside one plain cue carves the part that is, with a
|
|
71
|
+
* report — never two cues sharing one id.
|
|
72
|
+
*/
|
|
73
|
+
export function carveKeptTakes(
|
|
74
|
+
cues: readonly SceneCue[],
|
|
75
|
+
ranges: readonly KeptRange[],
|
|
76
|
+
map: TimeMap,
|
|
77
|
+
): CarveResult {
|
|
78
|
+
const out = [...cues];
|
|
79
|
+
const reports: string[] = [];
|
|
80
|
+
for (const range of ranges) {
|
|
81
|
+
const id = keptTakeId(range.srcIn);
|
|
82
|
+
const label = `kept range ${range.srcIn.toFixed(3)}–${range.srcOut.toFixed(3)}s`;
|
|
83
|
+
if (out.some((c) => c.id === id || c.id.startsWith(`${id}@`))) continue; // already carved
|
|
84
|
+
if (range.srcOut - range.srcIn < SPLIT_MIN_PIECE_SEC) {
|
|
85
|
+
reports.push(`${label} is shorter than ${SPLIT_MIN_PIECE_SEC}s — shown in the lane only`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const outIn = map.toOutput(range.srcIn);
|
|
89
|
+
const outOut = map.toOutput(range.srcOut);
|
|
90
|
+
if (outIn === null || outOut === null || outOut <= outIn) {
|
|
91
|
+
// Not on this clock at all — the range's material is (still) removed
|
|
92
|
+
// here; nothing to carve, and clamping would mint a lie of a block.
|
|
93
|
+
reports.push(`${label} is not in this cut — no block carved`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const i = out.findIndex((c) => c.startSec <= outIn + 1e-6 && c.endSec > outIn + 1e-6);
|
|
97
|
+
const host = i === -1 ? undefined : out[i];
|
|
98
|
+
if (host === undefined) {
|
|
99
|
+
// A HOLE, not an annexation: a removal at the head (or against a
|
|
100
|
+
// graphic's edge) retimes the neighbouring take AWAY from the revived
|
|
101
|
+
// stretch instead of over it (`TimeMap.toSource`'s earlier-preimage
|
|
102
|
+
// rule points the old 0 at the neighbour's own source start). Nothing
|
|
103
|
+
// owns the window, so the block is minted from scratch — layout
|
|
104
|
+
// borrowed from the nearest plain cue so the revived footage frames
|
|
105
|
+
// like its neighbours, never like a graphic.
|
|
106
|
+
const overlapping = out.some((c) => c.startSec < outOut - 1e-6 && c.endSec > outIn + 1e-6);
|
|
107
|
+
if (overlapping) {
|
|
108
|
+
reports.push(`${label} straddles existing cues — no block carved`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const neighbour = [...out]
|
|
112
|
+
.filter((c) => c.kind === "plain")
|
|
113
|
+
.sort(
|
|
114
|
+
(a, b) => Math.abs(a.startSec - outIn) - Math.abs(b.startSec - outIn),
|
|
115
|
+
)[0];
|
|
116
|
+
const minted: SceneCue = {
|
|
117
|
+
id,
|
|
118
|
+
kind: "plain",
|
|
119
|
+
layout: neighbour?.layout ?? "video-top",
|
|
120
|
+
...(neighbour?.video !== undefined ? { video: neighbour.video } : {}),
|
|
121
|
+
startSec: outIn,
|
|
122
|
+
endSec: outOut,
|
|
123
|
+
...(range.dismissed === true
|
|
124
|
+
? {}
|
|
125
|
+
: { kept: { srcIn: range.srcIn, srcOut: range.srcOut } }),
|
|
126
|
+
};
|
|
127
|
+
const insertAt = out.findIndex((c) => c.startSec >= outOut - 1e-6);
|
|
128
|
+
out.splice(insertAt === -1 ? out.length : insertAt, 0, minted);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (host.kind !== "plain") {
|
|
132
|
+
// Absence means "graphic" (SceneCueSchema's kind doc) — either way,
|
|
133
|
+
// not ours to carve.
|
|
134
|
+
reports.push(`${label} sits under graphic "${host.id}" — the graphic keeps the window`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const end = Math.min(outOut, host.endSec);
|
|
138
|
+
if (end < outOut - 1e-6) {
|
|
139
|
+
reports.push(
|
|
140
|
+
`${label} crosses out of take "${host.id}" — carved up to its edge (${end.toFixed(3)}s)`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
// Pieces: [host.start, outIn] (host keeps its id), the carved block,
|
|
144
|
+
// [end, host.end] (host's id too — `splitCues`' both-halves-keep rule
|
|
145
|
+
// does not apply: these are the SAME take around a foreign block, and
|
|
146
|
+
// minting `@` names here would collide with the split-id namespace).
|
|
147
|
+
const carved: SceneCue = {
|
|
148
|
+
...host,
|
|
149
|
+
id,
|
|
150
|
+
startSec: Math.max(host.startSec, outIn),
|
|
151
|
+
endSec: end,
|
|
152
|
+
...(range.dismissed === true ? {} : { kept: { srcIn: range.srcIn, srcOut: range.srcOut } }),
|
|
153
|
+
};
|
|
154
|
+
// Sub-eps remainders fold into the carved block — a 20ms sliver take is
|
|
155
|
+
// seam float dust, not content (REMAINDER_EPS).
|
|
156
|
+
const lead = outIn - host.startSec;
|
|
157
|
+
const tail = host.endSec - end;
|
|
158
|
+
const pieces: SceneCue[] = [
|
|
159
|
+
...(lead >= REMAINDER_EPS ? [{ ...host, endSec: outIn }] : []),
|
|
160
|
+
lead >= REMAINDER_EPS ? carved : { ...carved, startSec: host.startSec },
|
|
161
|
+
...(tail >= REMAINDER_EPS ? [{ ...host, startSec: end }] : []),
|
|
162
|
+
];
|
|
163
|
+
if (tail < REMAINDER_EPS) {
|
|
164
|
+
const last = pieces[pieces.length - 1]!;
|
|
165
|
+
pieces[pieces.length - 1] = { ...last, endSec: host.endSec };
|
|
166
|
+
}
|
|
167
|
+
out.splice(i, 1, ...pieces);
|
|
168
|
+
}
|
|
169
|
+
return { cues: out, reports };
|
|
170
|
+
}
|