@ossclip/core 0.1.7 → 0.1.10
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/blooper.ts +82 -6
- package/src/concat.ts +461 -0
- package/src/cutlist.ts +77 -7
- package/src/index.ts +3 -0
- package/src/overrides.ts +157 -2
- package/src/phonetics.ts +6 -1
- package/src/recut.ts +335 -0
- package/src/retake.ts +607 -0
- package/src/scene-schema.ts +9 -1
- package/src/timemap.ts +37 -0
package/src/cutlist.ts
CHANGED
|
@@ -63,10 +63,19 @@ export interface BuildCutlistArgs {
|
|
|
63
63
|
/**
|
|
64
64
|
* Spans the speaker marked as bloopers out loud (R27 §122), from
|
|
65
65
|
* `findBloopSpans`. Passed in rather than detected here so this stays a pure
|
|
66
|
-
* function of its arguments
|
|
67
|
-
* that can put a `retake` cut in the timeline.
|
|
66
|
+
* function of its arguments.
|
|
68
67
|
*/
|
|
69
68
|
bloops?: readonly { startWord: number; endWord: number; startSec: number; endSec: number }[];
|
|
69
|
+
/**
|
|
70
|
+
* Spans `findRetakeGroups` (R27 §128) elected to cut — the deterministic
|
|
71
|
+
* "keep only the last complete take" detector for the flub the speaker did
|
|
72
|
+
* NOT mark. Also a `reason: "retake"` cut, and also passed in rather than
|
|
73
|
+
* detected here, for the same purity reason as `bloops`: `buildCutlist`
|
|
74
|
+
* still has no judgement of its own about what a bad take looks like, it
|
|
75
|
+
* just folds whichever spans two independent detectors handed it into the
|
|
76
|
+
* one partition.
|
|
77
|
+
*/
|
|
78
|
+
retakes?: readonly { startWord: number; endWord: number; startSec: number; endSec: number }[];
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
export function buildCutlist({
|
|
@@ -75,11 +84,13 @@ export function buildCutlist({
|
|
|
75
84
|
duration,
|
|
76
85
|
level,
|
|
77
86
|
bloops,
|
|
87
|
+
retakes,
|
|
78
88
|
}: BuildCutlistArgs): Segment[] {
|
|
79
89
|
const keepAll: Segment[] = [{ srcIn: 0, srcOut: duration, kind: "keep" }];
|
|
80
90
|
// `exact` means exact: it is the escape hatch for "touch nothing", and a
|
|
81
|
-
// blooper cut is still a cut. --blooper-marker
|
|
82
|
-
//
|
|
91
|
+
// blooper or retake cut is still a cut. --blooper-marker or
|
|
92
|
+
// --collapse-retakes with --cleanup exact is a contradiction, and the flag
|
|
93
|
+
// the user typed second does not get to win.
|
|
83
94
|
if (level === "exact") return keepAll;
|
|
84
95
|
const policy = POLICIES[level];
|
|
85
96
|
const words = transcript.words;
|
|
@@ -105,6 +116,21 @@ export function buildCutlist({
|
|
|
105
116
|
});
|
|
106
117
|
}
|
|
107
118
|
|
|
119
|
+
// Same injection, same reason, lower confidence (R27 §128): a marker is the
|
|
120
|
+
// speaker asserting "this attempt is bad" — confidence 1. A retake group is
|
|
121
|
+
// this codebase inferring it from token similarity and the hallucination
|
|
122
|
+
// guard, so it earns 0.9, not 1 — the report and any future confidence-
|
|
123
|
+
// gated behavior can tell a supplied fact from an inferred one.
|
|
124
|
+
for (const r of retakes ?? []) {
|
|
125
|
+
removals.push({
|
|
126
|
+
start: r.startSec,
|
|
127
|
+
end: r.endSec,
|
|
128
|
+
reason: "retake",
|
|
129
|
+
confidence: 0.9,
|
|
130
|
+
source: "acoustic",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
108
134
|
for (const pause of analysis.cuttable) {
|
|
109
135
|
// Lead and tail are decided by the SILENCE's position in the file, not by
|
|
110
136
|
// comparing it to a word stamp (R27 §127). Whisper's `-ml 1` stamps stretch
|
|
@@ -178,7 +204,23 @@ export function buildCutlist({
|
|
|
178
204
|
// keep. Acoustic boundaries are exempt: whisper's `-ml 1` stamps stretch a
|
|
179
205
|
// word's end all the way to the next word's start, so a pause *always* looks
|
|
180
206
|
// like it is "inside" a word — applying this rule to them cancels every cut.
|
|
181
|
-
|
|
207
|
+
//
|
|
208
|
+
// Fillers are excluded here ONLY when `policy.removeFillers` says they're
|
|
209
|
+
// actually being removed (fix wave final review, findings §124's
|
|
210
|
+
// follow-up): this same `protectedWords` list also feeds
|
|
211
|
+
// `hasProtectedWordInside` below, which Task 6 widened to fold a wordless
|
|
212
|
+
// keep-gap up to `policy.pauseMin` (1.2s at light). Excluding fillers
|
|
213
|
+
// unconditionally meant a lone "um" sitting in a gap between two silence
|
|
214
|
+
// removals read as "wordless" even at `light`, where `removeFillers` is
|
|
215
|
+
// false and the filler was never scheduled for removal at all — so the
|
|
216
|
+
// fold silently ate it, cutting a word `light`'s own contract promises to
|
|
217
|
+
// keep. The transcript-boundary loop right below is unaffected: it only
|
|
218
|
+
// ever runs for `source === "transcript"` removals, which only exist when
|
|
219
|
+
// `policy.removeFillers` created them (the `if (policy.removeFillers)`
|
|
220
|
+
// block above) — so at `light`, that loop already sees zero such removals
|
|
221
|
+
// and this widened list changes nothing for it, verified by reading rather
|
|
222
|
+
// than assumed.
|
|
223
|
+
const protectedWords = words.filter((_, i) => !policy.removeFillers || !fillerIndices.has(i));
|
|
182
224
|
for (const r of removals) {
|
|
183
225
|
if (r.source !== "transcript") continue;
|
|
184
226
|
for (const w of protectedWords) {
|
|
@@ -193,12 +235,40 @@ export function buildCutlist({
|
|
|
193
235
|
return mid > start && mid < end;
|
|
194
236
|
});
|
|
195
237
|
|
|
196
|
-
// Merge removals that overlap, or whose in-between keep is a wordless
|
|
238
|
+
// Merge removals that overlap, or whose in-between keep is a wordless
|
|
239
|
+
// sliver — folded in regardless of length, not just when it's already
|
|
240
|
+
// under MIN_KEEP. A 0.37s wordless gap between two `silence` removals
|
|
241
|
+
// shipped in a real cleanup run because the old condition ANDed the
|
|
242
|
+
// wordless check to the length check, so `hasProtectedWordInside` was only
|
|
243
|
+
// ever asked once the gap was already short — a wordless gap that cleared
|
|
244
|
+
// MIN_KEEP was never asked at all (findings §124). MIN_KEEP's own comment
|
|
245
|
+
// already says wordless fragments fold; this makes the code do it.
|
|
246
|
+
//
|
|
247
|
+
// The fold is capped at `policy.pauseMin`, not left unbounded — folds any
|
|
248
|
+
// wordless gap UP TO pauseMin, refuses anything past it. The cap isn't
|
|
249
|
+
// about protecting short gaps; it's about what a gap LONGER than pauseMin
|
|
250
|
+
// sitting between two removals implies. The interior-pause branch above
|
|
251
|
+
// already generates its own removal for every genuinely silent stretch
|
|
252
|
+
// longer than pauseMin (`pauseDur <= policy.pauseMin` is the only case it
|
|
253
|
+
// skips) — so if a wordless-per-transcript gap that long survives here
|
|
254
|
+
// as bare space between two OTHER removals, the acoustic detector looked
|
|
255
|
+
// at it and did NOT call it silence. That's a live-audio signal the
|
|
256
|
+
// transcript can't see (a breath, laughter, room action, b-roll audio)
|
|
257
|
+
// being kept safe from a rule that only knows "the transcript found no
|
|
258
|
+
// words." A gap AT OR UNDER pauseMin, by contrast, is exactly the field
|
|
259
|
+
// bug's shape (0.37s, standard's 0.7s pauseMin): debris left over once
|
|
260
|
+
// both its neighbors are already cut, not a stretch the detector had any
|
|
261
|
+
// chance to flag on its own. `Math.max` with MIN_KEEP is defensive, not
|
|
262
|
+
// load-bearing: every current pauseMin already exceeds MIN_KEEP.
|
|
197
263
|
const merged: Removal[] = [];
|
|
198
264
|
for (const r of removals) {
|
|
199
265
|
if (r.end - r.start < 0.05) continue;
|
|
200
266
|
const prev = merged[merged.length - 1];
|
|
201
|
-
|
|
267
|
+
const gap = prev ? r.start - prev.end : Number.POSITIVE_INFINITY;
|
|
268
|
+
const overlapping = prev !== undefined && gap <= 1e-6;
|
|
269
|
+
const wordless = prev !== undefined && !hasProtectedWordInside(prev.end, r.start);
|
|
270
|
+
const foldableGap = wordless && gap <= Math.max(MIN_KEEP, policy.pauseMin);
|
|
271
|
+
if (prev && (overlapping || foldableGap)) {
|
|
202
272
|
const prevDur = prev.end - prev.start;
|
|
203
273
|
const curDur = r.end - r.start;
|
|
204
274
|
prev.end = Math.max(prev.end, r.end);
|
package/src/index.ts
CHANGED
|
@@ -6,12 +6,15 @@ export * from "./assemble";
|
|
|
6
6
|
export * from "./fill";
|
|
7
7
|
export * from "./producer/index";
|
|
8
8
|
export * from "./timemap";
|
|
9
|
+
export * from "./recut";
|
|
9
10
|
export * from "./ingest";
|
|
11
|
+
export * from "./concat";
|
|
10
12
|
export * from "./transcribe";
|
|
11
13
|
export * from "./analyze";
|
|
12
14
|
export * from "./cutlist";
|
|
13
15
|
export * from "./clip";
|
|
14
16
|
export * from "./blooper";
|
|
17
|
+
export * from "./retake";
|
|
15
18
|
export * from "./captions";
|
|
16
19
|
export * from "./zoom";
|
|
17
20
|
export * from "./grounding";
|
package/src/overrides.ts
CHANGED
|
@@ -23,6 +23,19 @@ export const ElementTransformSchema = z.object({
|
|
|
23
23
|
dx: z.number().optional(),
|
|
24
24
|
dy: z.number().optional(),
|
|
25
25
|
scale: z.number().positive().optional(),
|
|
26
|
+
/**
|
|
27
|
+
* The element is deleted — SOFTLY (PLAN Task 2, one level down from
|
|
28
|
+
* `SceneOverrideSchema.hidden` below): `editStyle`
|
|
29
|
+
* (packages/scenes/src/editable.ts) suppresses it at the one chokepoint
|
|
30
|
+
* every component's leaf renders its edit style through, so no
|
|
31
|
+
* per-component change is needed and the remaining siblings close the
|
|
32
|
+
* gap on their own — the same delete semantics a whole SCENE gets,
|
|
33
|
+
* scoped to one of its elements. The backing array
|
|
34
|
+
* (`props.messages`/`lines`/`nodes`/`items`) is never touched — ids are
|
|
35
|
+
* positional, so hiding `message-1` can't renumber `message-2` out from
|
|
36
|
+
* under its own edits.
|
|
37
|
+
*/
|
|
38
|
+
hidden: z.boolean().optional(),
|
|
26
39
|
});
|
|
27
40
|
export type ElementTransform = z.infer<typeof ElementTransformSchema>;
|
|
28
41
|
|
|
@@ -169,6 +182,51 @@ export const OverrideDocSchema = z.object({
|
|
|
169
182
|
* split lands on graphic cues and takes alike.
|
|
170
183
|
*/
|
|
171
184
|
splits: z.array(z.number().nonnegative()).default([]),
|
|
185
|
+
/**
|
|
186
|
+
* User cuts — ranges of the OUTPUT to remove, in the output seconds of the
|
|
187
|
+
* CURRENT render-props (what the user saw when they cut) (PLAN 2026-08-04
|
|
188
|
+
* Task 4). Optional-with-default like `splits` above, so an `overrides.json`
|
|
189
|
+
* written before this field existed still parses unchanged. Consumed by
|
|
190
|
+
* `produce.ts` (subtracted from the automatic cutlist's keep-spans) and by
|
|
191
|
+
* `recut.ts`'s `remapOverridesThroughRecut`, which re-anchors every OTHER
|
|
192
|
+
* absolute-output-seconds value in this doc through the resulting re-cut —
|
|
193
|
+
* see that module's docstring for why a bare "shift everything after the
|
|
194
|
+
* cut point" is not the actual rule.
|
|
195
|
+
*
|
|
196
|
+
* `src` (review fix wave, PLAN 2026-08-04 Task 4): a cut's `startSec`/
|
|
197
|
+
* `endSec` alone are meaningless without knowing WHICH render-props they
|
|
198
|
+
* were drawn against — a bare output-seconds pair has no faithful
|
|
199
|
+
* representation once its own cut has happened (Task 4b's Bug A: remapping
|
|
200
|
+
* it through the very recut it caused collapses it to a zero-width point).
|
|
201
|
+
* `src` is the cut's resolved SOURCE-time range, computed once — the first
|
|
202
|
+
* produce run that sees a `src`-less cut resolves it against the
|
|
203
|
+
* render-props the user was looking at, and the write-back records it here
|
|
204
|
+
* — and used directly, unconverted, on every run after that. `startSec`/
|
|
205
|
+
* `endSec` are left exactly as the user drew them even once `src` exists:
|
|
206
|
+
* a historical record of what render-props they were looking at, never
|
|
207
|
+
* authoritative again once `src` is present.
|
|
208
|
+
*
|
|
209
|
+
* The editor (PLAN 2026-08-04 Task 4c) MUST NEVER WRITE OR
|
|
210
|
+
* PRESERVE-AND-MODIFY `src` ITSELF — resolving it is produce's job alone.
|
|
211
|
+
* Creating a cut writes ONLY `{startSec, endSec}`; if a cut's range is
|
|
212
|
+
* ever edited/moved (not currently exposed, but the rule holds for any
|
|
213
|
+
* future gesture that would), its `src` is DELETED rather than carried
|
|
214
|
+
* forward, so the next produce re-resolves it against the render-props
|
|
215
|
+
* current at that point rather than an anchor drawn for a range that no
|
|
216
|
+
* longer means the same thing; Restore removes the WHOLE entry, `src`
|
|
217
|
+
* included — there is no "not cut" state for one array entry to hold.
|
|
218
|
+
*/
|
|
219
|
+
cuts: z
|
|
220
|
+
.array(
|
|
221
|
+
z.object({
|
|
222
|
+
startSec: z.number().nonnegative(),
|
|
223
|
+
endSec: z.number().nonnegative(),
|
|
224
|
+
src: z
|
|
225
|
+
.object({ startSec: z.number().nonnegative(), endSec: z.number().nonnegative() })
|
|
226
|
+
.optional(),
|
|
227
|
+
}),
|
|
228
|
+
)
|
|
229
|
+
.default([]),
|
|
172
230
|
});
|
|
173
231
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
174
232
|
|
|
@@ -364,6 +422,33 @@ export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): Dro
|
|
|
364
422
|
return { cues: out, hidden };
|
|
365
423
|
}
|
|
366
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Split at the stored split points BEFORE dropping hidden cues (PLAN
|
|
427
|
+
* 2026-08-04 Task 1, bug 3).
|
|
428
|
+
*
|
|
429
|
+
* `dropHiddenCues` matches by the EXACT id the user deleted, and a split
|
|
430
|
+
* root's `hidden` is meant to apply only to "the root's own post-split
|
|
431
|
+
* segment (the half that still carries the bare id)" per `effectiveOverride`
|
|
432
|
+
* above — but `produce.ts` and the editor's live memo both called
|
|
433
|
+
* `dropHiddenCues` on the ROOT cues, before `splitCues` had run. That erased
|
|
434
|
+
* the ENTIRE pre-split window (both halves at once); the plain fill then
|
|
435
|
+
* covered it with one take, and the later `splitCues` call cut that PLAIN
|
|
436
|
+
* take into two plain pieces — killing the graphic on the half the user
|
|
437
|
+
* never asked to delete (field case: `scene-6` deleted, `scene-6@36400` died
|
|
438
|
+
* with it). Splitting first means a hidden root id only ever matches the
|
|
439
|
+
* post-split cue that kept the bare id, exactly the exception
|
|
440
|
+
* `effectiveOverride` already promises. Safe to call again after the fill's
|
|
441
|
+
* own `splitCues` pass — re-splitting at a boundary that already exists is a
|
|
442
|
+
* no-op (the split point sits exactly on the joint, so neither piece's
|
|
443
|
+
* window contains it).
|
|
444
|
+
*/
|
|
445
|
+
export function splitThenDropHidden(
|
|
446
|
+
cues: readonly SceneCue[],
|
|
447
|
+
doc: OverrideDoc,
|
|
448
|
+
): DropHiddenResult {
|
|
449
|
+
return dropHiddenCues(splitCues(cues, doc.splits), doc);
|
|
450
|
+
}
|
|
451
|
+
|
|
367
452
|
export interface AppliedCaptionEdits {
|
|
368
453
|
lines: CaptionLine[];
|
|
369
454
|
/** Edits whose guard failed — the word at that index is not what they knew. */
|
|
@@ -434,6 +519,43 @@ export function clearElementTransform(
|
|
|
434
519
|
return { ...doc, scenes: { ...doc.scenes, [sceneId]: { ...scene, elements: rest } } };
|
|
435
520
|
}
|
|
436
521
|
|
|
522
|
+
/**
|
|
523
|
+
* Un-hide ONE element: DELETE only the `hidden` key (PLAN Task 2), so a
|
|
524
|
+
* nudge/scale made before the delete survives the restore — the
|
|
525
|
+
* element-level mirror of `restoreScene`'s "delete the key, don't write a
|
|
526
|
+
* false-ish value" rule below. `clearElementTransform` above stays the FULL
|
|
527
|
+
* reset (nudges included); this is the narrower "bring it back as it was"
|
|
528
|
+
* gesture the element panel's Restore button offers.
|
|
529
|
+
*/
|
|
530
|
+
export function restoreElement(
|
|
531
|
+
doc: OverrideDoc,
|
|
532
|
+
sceneId: string,
|
|
533
|
+
elementId: string,
|
|
534
|
+
): OverrideDoc {
|
|
535
|
+
const scene = doc.scenes[sceneId];
|
|
536
|
+
const entry = scene?.elements[elementId];
|
|
537
|
+
if (!scene || !entry?.hidden) return doc;
|
|
538
|
+
const { hidden: _dropped, ...rest } = entry;
|
|
539
|
+
// `elements` merges per ID, not per FIELD (`effectiveOverride` above:
|
|
540
|
+
// `elements: { ...base.elements, ...own.elements }` replaces a shared id
|
|
541
|
+
// WHOLESALE, unlike `video`/`pip`, which merge field by field). Review
|
|
542
|
+
// fix wave, PLAN Task 2: a half whose own entry was ONLY `{hidden:true}`
|
|
543
|
+
// would otherwise be left with the empty leftover `{}` once `hidden` is
|
|
544
|
+
// stripped — and that empty object still wins the wholesale merge,
|
|
545
|
+
// permanently shadowing whatever nudge the split ROOT had for this id,
|
|
546
|
+
// even though "restore keeps nudges" is exactly this function's promise.
|
|
547
|
+
// Dropping the key entirely once nothing but `hidden` was ever on it lets
|
|
548
|
+
// the root's own entry (if any) show through again — same "delete rather
|
|
549
|
+
// than leave an inert key" instinct as clearVideo/clearTiming.
|
|
550
|
+
const { [elementId]: _own, ...withoutEntry } = scene.elements;
|
|
551
|
+
const elements =
|
|
552
|
+
Object.keys(rest).length > 0 ? { ...scene.elements, [elementId]: rest } : withoutEntry;
|
|
553
|
+
return {
|
|
554
|
+
...doc,
|
|
555
|
+
scenes: { ...doc.scenes, [sceneId]: { ...scene, elements } },
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
437
559
|
/**
|
|
438
560
|
* Un-pin: DELETE the `timing` override so the scene goes back to tracking its
|
|
439
561
|
* word anchors. Distinct from setting a timing that happens to match the
|
|
@@ -469,6 +591,25 @@ export interface ReclampResult {
|
|
|
469
591
|
adjusted: string[];
|
|
470
592
|
}
|
|
471
593
|
|
|
594
|
+
/**
|
|
595
|
+
* The scene a cue id belongs to, stripping a split half's `@ms` suffix —
|
|
596
|
+
* same idiom `splitCues` itself uses to derive a later half's id from its
|
|
597
|
+
* root, and the same one `effectiveOverride` above inlines to find a half's
|
|
598
|
+
* root entry. Two cues sharing a root are the SAME scene, cut in two.
|
|
599
|
+
*
|
|
600
|
+
* Exported (PLAN Task 2 review fix) for the editor's own use: a hidden
|
|
601
|
+
* ELEMENT on a split half can be inherited from the root (`elements`
|
|
602
|
+
* merges per id in `effectiveOverride`, and `elements` is NOT in that
|
|
603
|
+
* function's inheritance-exclusion list the way `timing`/`hidden` are) —
|
|
604
|
+
* Inspector.tsx's per-row Restore has to know whether the `hidden` it's
|
|
605
|
+
* offering to undo lives on the half's own doc entry or the root's, and
|
|
606
|
+
* this is the same root-id derivation either side of that decision needs.
|
|
607
|
+
*/
|
|
608
|
+
export function splitRootId(id: string): string {
|
|
609
|
+
const at = id.indexOf("@");
|
|
610
|
+
return at === -1 ? id : id.slice(0, at);
|
|
611
|
+
}
|
|
612
|
+
|
|
472
613
|
/**
|
|
473
614
|
* Re-clamp every PINNED cue's absolute timing against its current neighbours
|
|
474
615
|
* in the array, in order.
|
|
@@ -492,8 +633,22 @@ export function reclampPinnedTiming(cues: readonly SceneCue[]): ReclampResult {
|
|
|
492
633
|
if (!cue.pinned) continue;
|
|
493
634
|
const prev = out[i - 1];
|
|
494
635
|
const next = out[i + 1];
|
|
495
|
-
|
|
496
|
-
|
|
636
|
+
// Two adjacent pinned cues that are split halves of the SAME scene are
|
|
637
|
+
// not independent neighbours (PLAN 2026-08-04 Task 1 follow-up, found in
|
|
638
|
+
// review): `splitThenDropHidden` now runs `splitCues` before this
|
|
639
|
+
// function, so a pinned+split scene reaches here as two entries, both
|
|
640
|
+
// still `pinned: true`, with an EXACT boundary (`left.endSec ===
|
|
641
|
+
// right.startSec`) that `splitCues` already cut. Applying the usual
|
|
642
|
+
// `PINNED_GAP_SEC` buffer between them would carve a sliver out of the
|
|
643
|
+
// seam that `fillPlainCues` then fills with a spurious plain take
|
|
644
|
+
// spliced between the two halves — bug 3 again, one layer up, even
|
|
645
|
+
// though the cue array itself looks correctly split.
|
|
646
|
+
const prevIsSibling = prev?.pinned === true && splitRootId(prev.id) === splitRootId(cue.id);
|
|
647
|
+
const nextIsSibling = next?.pinned === true && splitRootId(next.id) === splitRootId(cue.id);
|
|
648
|
+
const lo = prev ? prev.endSec + (prevIsSibling ? 0 : PINNED_GAP_SEC) : 0;
|
|
649
|
+
const hi = next
|
|
650
|
+
? next.startSec - (nextIsSibling ? 0 : PINNED_GAP_SEC)
|
|
651
|
+
: Number.POSITIVE_INFINITY;
|
|
497
652
|
let s = Math.min(Math.max(cue.startSec, lo), Math.max(lo, hi - MIN_PINNED_SCENE_SEC));
|
|
498
653
|
let e = Math.max(Math.min(cue.endSec, hi), s + MIN_PINNED_SCENE_SEC);
|
|
499
654
|
if (e > hi) {
|
package/src/phonetics.ts
CHANGED
|
@@ -69,7 +69,12 @@ function phraseKey(text: string): string {
|
|
|
69
69
|
.join("");
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// Exported: blooper.ts reuses this directly for the marker fuzzy-match arm
|
|
73
|
+
// (Task 3, editor-dogfood-fixes plan) instead of a second copy — plain edit
|
|
74
|
+
// distance on the raw normalized word, not the phonetic key `soundsLike`
|
|
75
|
+
// compares, because "blooper"/"looker" fails the onset test below yet is
|
|
76
|
+
// exactly the mishearing the marker match needs to catch.
|
|
77
|
+
export function levenshtein(a: string, b: string): number {
|
|
73
78
|
if (a === b) return 0;
|
|
74
79
|
if (a.length === 0) return b.length;
|
|
75
80
|
if (b.length === 0) return a.length;
|