@ossclip/core 0.1.16 → 0.1.18
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/concat.ts +7 -1
- package/src/retake.ts +90 -7
package/package.json
CHANGED
package/src/concat.ts
CHANGED
|
@@ -20,7 +20,13 @@ import { run } from "./exec";
|
|
|
20
20
|
* `listFolderVideos` and `concatFolder` are the only I/O.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The extensions a folder input may contain. Exported (§136) because the
|
|
25
|
+
* CLI's native file picker builds its dialog filters from this list: a
|
|
26
|
+
* picker that offers a file `concat` will later refuse is a trap, and the
|
|
27
|
+
* two lists silently diverging is exactly how that ships.
|
|
28
|
+
*/
|
|
29
|
+
export const VIDEO_EXTENSIONS = ["mov", "mp4", "m4v", "mkv", "webm", "avi"] as const;
|
|
24
30
|
const VIDEO_EXTENSION_SET = new Set<string>(VIDEO_EXTENSIONS);
|
|
25
31
|
|
|
26
32
|
function noVideoFilesError(folder: string): Error {
|
package/src/retake.ts
CHANGED
|
@@ -223,13 +223,18 @@ function silenceFraction(silences: readonly Span[], start: number, end: number):
|
|
|
223
223
|
* sits earlier is never cut, so a spurious split ends at a report line,
|
|
224
224
|
* not a shear through live audio.
|
|
225
225
|
*/
|
|
226
|
+
/** An Instance plus whether the restart split fragmented its coarse sentence. */
|
|
227
|
+
interface SentenceInstance extends Instance {
|
|
228
|
+
wasSplit: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
226
231
|
function buildInstances(
|
|
227
232
|
transcript: Transcript,
|
|
228
233
|
analysis: Pick<Analysis, "silences" | "fillers">,
|
|
229
234
|
transparentMarker?: string,
|
|
230
|
-
): Instance[] {
|
|
235
|
+
): { fragments: Instance[]; sentences: SentenceInstance[] } {
|
|
231
236
|
const words = transcript.words;
|
|
232
|
-
if (words.length === 0) return [];
|
|
237
|
+
if (words.length === 0) return { fragments: [], sentences: [] };
|
|
233
238
|
const fillerIndices = new Set(analysis.fillers.map((f) => f.wordIndex));
|
|
234
239
|
const marker = transparentMarker ? normalizeToken(transparentMarker) : undefined;
|
|
235
240
|
|
|
@@ -269,6 +274,7 @@ function buildInstances(
|
|
|
269
274
|
};
|
|
270
275
|
|
|
271
276
|
const instances: Instance[] = [];
|
|
277
|
+
const sentences: SentenceInstance[] = [];
|
|
272
278
|
for (const sent of coarse) {
|
|
273
279
|
// A sentence that is ALREADY silence-dominated end-to-end is the
|
|
274
280
|
// hallucination shape, not the restart shape: whisper sprinkles sparse
|
|
@@ -334,8 +340,17 @@ function buildInstances(
|
|
|
334
340
|
// runs out of words mid-sentence is a trailing abandoned partial, not a
|
|
335
341
|
// finished take, whatever fragment boundary it happens to land on.
|
|
336
342
|
instances.push(toInstance(fragStart, sent.end, isSentenceEnd(transcript, sent.end), true));
|
|
343
|
+
// The whole coarse sentence, rejoined across its restart splits, for the
|
|
344
|
+
// §135 sentence-level pass: VO-paced delivery pauses INSIDE a sentence,
|
|
345
|
+
// so both takes of a genuine retake fragment into pieces that never
|
|
346
|
+
// match each other. `wasSplit` records whether the split actually fired —
|
|
347
|
+
// the pass only acts where fragmentation could have hidden a match.
|
|
348
|
+
sentences.push({
|
|
349
|
+
...toInstance(sent.start, sent.end, isSentenceEnd(transcript, sent.end), true),
|
|
350
|
+
wasSplit: splitAfter.length > 0,
|
|
351
|
+
});
|
|
337
352
|
}
|
|
338
|
-
return instances;
|
|
353
|
+
return { fragments: instances, sentences };
|
|
339
354
|
}
|
|
340
355
|
|
|
341
356
|
// ---- matching -------------------------------------------------------------
|
|
@@ -427,11 +442,21 @@ function toPublic(i: Instance): RetakeInstance {
|
|
|
427
442
|
* a clause boundary, not an abandoned take: its own sentence continues
|
|
428
443
|
* without it, so it goes to `undecided` (report-only), never `cuts`.
|
|
429
444
|
*/
|
|
430
|
-
function buildGroup(
|
|
445
|
+
function buildGroup(
|
|
446
|
+
chain: readonly Instance[],
|
|
447
|
+
hallucinated: readonly Instance[],
|
|
448
|
+
// The fragment pass keeps its strict bar. The §135 sentence pass gates
|
|
449
|
+
// survivors at the HALLUCINATION bar instead: a whole sentence carrying
|
|
450
|
+
// deliberate mid-sentence pauses is ordinary read-aloud delivery — the
|
|
451
|
+
// field run's kept take sat at 36% dead air, over the fragment bar by one
|
|
452
|
+
// point, and a textbook retake went report-only for it. Below the
|
|
453
|
+
// hallucination bar, a sentence-level survivor is a real take.
|
|
454
|
+
survivorBar: number = RESTART_SPLIT_MIN_SIL,
|
|
455
|
+
): RetakeGroup {
|
|
431
456
|
const completes = chain.filter((i) => i.complete);
|
|
432
457
|
const lastComplete = completes[completes.length - 1];
|
|
433
458
|
const kept =
|
|
434
|
-
lastComplete !== undefined && lastComplete.silenceFrac <=
|
|
459
|
+
lastComplete !== undefined && lastComplete.silenceFrac <= survivorBar
|
|
435
460
|
? lastComplete
|
|
436
461
|
: undefined;
|
|
437
462
|
const hallu: RetakeHallucination[] = hallucinated.map((i) => ({
|
|
@@ -511,7 +536,65 @@ export function findRetakeGroups(
|
|
|
511
536
|
analysis: Pick<Analysis, "silences" | "fillers">,
|
|
512
537
|
opts: { transparentMarker?: string } = {},
|
|
513
538
|
): RetakeGroup[] {
|
|
514
|
-
const
|
|
539
|
+
const { fragments, sentences } = buildInstances(transcript, analysis, opts.transparentMarker);
|
|
540
|
+
const groups = chainGroups(fragments);
|
|
541
|
+
|
|
542
|
+
// §135 sentence-level pass — the VO field case. Deliberate mid-sentence
|
|
543
|
+
// pauses (read-aloud delivery) make the restart split shred BOTH takes of a
|
|
544
|
+
// genuine retake into fragments that never match each other, and the
|
|
545
|
+
// fragment pass above reports nothing ("takes half [3s] a second" vs
|
|
546
|
+
// "…half a millisecond" — whole-sentence similarity 0.875, zero fragment
|
|
547
|
+
// matches). This pass re-runs the SAME chain algorithm over whole coarse
|
|
548
|
+
// sentences, then keeps a group only when:
|
|
549
|
+
// (a) some member's sentence actually WAS split — unsplit sentences were
|
|
550
|
+
// already compared whole by the fragment pass, so acting on them here
|
|
551
|
+
// could only second-guess a decision that pass already made; and
|
|
552
|
+
// (b) no member's words are claimed by a fragment-pass group — including
|
|
553
|
+
// its `undecided` members, so a cut the abandonment/cut-validation
|
|
554
|
+
// rules (§128) deliberately DECLINED stays declined.
|
|
555
|
+
// C1-style parallel rhetoric is safe here by construction: those are
|
|
556
|
+
// different sentences, and whole-sentence similarity scores the divergence
|
|
557
|
+
// the fragment prefixes hid.
|
|
558
|
+
const claimed = new Set<number>();
|
|
559
|
+
for (const g of groups) {
|
|
560
|
+
const members = [g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided];
|
|
561
|
+
for (const m of members) {
|
|
562
|
+
if (!m) continue;
|
|
563
|
+
for (let w = m.startWord; w <= m.endWord; w++) claimed.add(w);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const splitByStartWord = new Map(sentences.map((s) => [s.startWord, s.wasSplit]));
|
|
567
|
+
for (const g of chainGroups(sentences, HALLUCINATION_SILENCE_FRAC)) {
|
|
568
|
+
const members = [g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided].filter(
|
|
569
|
+
(m): m is RetakeInstance => m !== null,
|
|
570
|
+
);
|
|
571
|
+
if (!members.some((m) => splitByStartWord.get(m.startWord))) continue;
|
|
572
|
+
let overlaps = false;
|
|
573
|
+
for (const m of members) {
|
|
574
|
+
for (let w = m.startWord; w <= m.endWord && !overlaps; w++) {
|
|
575
|
+
if (claimed.has(w)) overlaps = true;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (overlaps) continue;
|
|
579
|
+
groups.push(g);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Two passes can interleave in transcript order — the report reads in
|
|
583
|
+
// word order, whichever pass found the group.
|
|
584
|
+
groups.sort((a, b) => {
|
|
585
|
+
const first = (g: RetakeGroup): number =>
|
|
586
|
+
Math.min(
|
|
587
|
+
...[g.kept, ...g.cuts, ...g.hallucinated, ...g.undecided]
|
|
588
|
+
.filter((m): m is RetakeInstance => m !== null)
|
|
589
|
+
.map((m) => m.startWord),
|
|
590
|
+
);
|
|
591
|
+
return first(a) - first(b);
|
|
592
|
+
});
|
|
593
|
+
return groups;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** The chaining rule (doc block above), shared by both passes verbatim. */
|
|
597
|
+
function chainGroups(instances: readonly Instance[], survivorBar?: number): RetakeGroup[] {
|
|
515
598
|
const groups: RetakeGroup[] = [];
|
|
516
599
|
|
|
517
600
|
let anchor: Instance | null = null;
|
|
@@ -520,7 +603,7 @@ export function findRetakeGroups(
|
|
|
520
603
|
|
|
521
604
|
const finalize = (): void => {
|
|
522
605
|
if (chain.length >= 2 || (chain.length >= 1 && hallucinated.length > 0)) {
|
|
523
|
-
groups.push(buildGroup(chain, hallucinated));
|
|
606
|
+
groups.push(buildGroup(chain, hallucinated, survivorBar));
|
|
524
607
|
}
|
|
525
608
|
chain = [];
|
|
526
609
|
hallucinated = [];
|