@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- const VIDEO_EXTENSIONS = ["mov", "mp4", "m4v", "mkv", "webm", "avi"] as const;
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(chain: readonly Instance[], hallucinated: readonly Instance[]): RetakeGroup {
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 <= RESTART_SPLIT_MIN_SIL
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 instances = buildInstances(transcript, analysis, opts.transparentMarker);
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 = [];