@ossclip/core 0.1.0
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/LICENSE +27 -0
- package/README.md +20 -0
- package/package.json +29 -0
- package/src/analyze.ts +299 -0
- package/src/assemble.ts +124 -0
- package/src/browser.ts +24 -0
- package/src/captions.ts +92 -0
- package/src/clip.ts +306 -0
- package/src/config.ts +66 -0
- package/src/content-rect-detect.ts +162 -0
- package/src/content-rect.ts +324 -0
- package/src/cover.ts +216 -0
- package/src/cta.ts +68 -0
- package/src/cutlist.ts +170 -0
- package/src/exec.ts +36 -0
- package/src/face.ts +519 -0
- package/src/fill.ts +110 -0
- package/src/framing.ts +277 -0
- package/src/grounding.ts +130 -0
- package/src/index.ts +27 -0
- package/src/ingest.ts +83 -0
- package/src/normalize.ts +397 -0
- package/src/overrides.ts +509 -0
- package/src/phonetics.ts +129 -0
- package/src/producer/anthropic.ts +73 -0
- package/src/producer/beats.ts +330 -0
- package/src/producer/claude-cli.ts +150 -0
- package/src/producer/gemini.ts +197 -0
- package/src/producer/index.ts +217 -0
- package/src/producer/mock.ts +101 -0
- package/src/producer/provider.ts +42 -0
- package/src/producer/repair.ts +474 -0
- package/src/producer/scene-props.ts +212 -0
- package/src/producer/tiered.ts +56 -0
- package/src/producer/usage.ts +426 -0
- package/src/report.ts +36 -0
- package/src/scene-registry.ts +246 -0
- package/src/scene-schema.ts +203 -0
- package/src/schema.ts +177 -0
- package/src/source-text.ts +348 -0
- package/src/timemap.ts +115 -0
- package/src/transcribe.ts +67 -0
- package/src/zoom.ts +154 -0
package/src/cutlist.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { Analysis, CleanupLevel, RemovalReason, Segment, Transcript } from "./schema";
|
|
2
|
+
|
|
3
|
+
interface LevelPolicy {
|
|
4
|
+
/** Only tighten agreed pauses longer than this (seconds). */
|
|
5
|
+
pauseMin: number;
|
|
6
|
+
/** Resulting gap length after tightening. */
|
|
7
|
+
tightenTo: number;
|
|
8
|
+
removeFillers: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const POLICIES: Record<Exclude<CleanupLevel, "exact">, LevelPolicy> = {
|
|
12
|
+
light: { pauseMin: 1.2, tightenTo: 0.3, removeFillers: false },
|
|
13
|
+
standard: { pauseMin: 0.7, tightenTo: 0.22, removeFillers: true },
|
|
14
|
+
aggressive: { pauseMin: 0.5, tightenTo: 0.18, removeFillers: true },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Dead air kept before the first word / after the last word. Exported for
|
|
18
|
+
* the clip bound (R19 §93), so a clip boundary breathes like a take boundary. */
|
|
19
|
+
export const LEAD_KEEP = 0.25;
|
|
20
|
+
export const TAIL_KEEP = 0.35;
|
|
21
|
+
/** Removals shorter than this aren't worth a visible cut. */
|
|
22
|
+
const MIN_REMOVAL = 0.15;
|
|
23
|
+
/** Padding kept between a filler cut and its neighbor words. */
|
|
24
|
+
const FILLER_NEIGHBOR_PAD = 0.02;
|
|
25
|
+
/**
|
|
26
|
+
* Minimum audio kept at each end of a silence-derived cut, so a word's release
|
|
27
|
+
* and the next word's attack survive a slightly over-eager detector. Pause
|
|
28
|
+
* tightening usually reserves more than this; these are floors, not additions.
|
|
29
|
+
*/
|
|
30
|
+
const SILENCE_PAD_IN = 0.06;
|
|
31
|
+
const SILENCE_PAD_OUT = 0.1;
|
|
32
|
+
/** Kept fragments shorter than this (holding no word) are folded into the cut. */
|
|
33
|
+
const MIN_KEEP = 0.25;
|
|
34
|
+
|
|
35
|
+
interface Removal {
|
|
36
|
+
start: number;
|
|
37
|
+
end: number;
|
|
38
|
+
reason: RemovalReason;
|
|
39
|
+
confidence: number;
|
|
40
|
+
/**
|
|
41
|
+
* Acoustic removals sit inside verified silence, so their boundaries may land
|
|
42
|
+
* inside a (stretched) word stamp; transcript-derived ones must not.
|
|
43
|
+
*/
|
|
44
|
+
source: "acoustic" | "transcript";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface BuildCutlistArgs {
|
|
48
|
+
transcript: Transcript;
|
|
49
|
+
analysis: Analysis;
|
|
50
|
+
duration: number;
|
|
51
|
+
level: CleanupLevel;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildCutlist({ transcript, analysis, duration, level }: BuildCutlistArgs): Segment[] {
|
|
55
|
+
const keepAll: Segment[] = [{ srcIn: 0, srcOut: duration, kind: "keep" }];
|
|
56
|
+
if (level === "exact") return keepAll;
|
|
57
|
+
const policy = POLICIES[level];
|
|
58
|
+
const words = transcript.words;
|
|
59
|
+
const first = words[0];
|
|
60
|
+
const last = words[words.length - 1];
|
|
61
|
+
const fillerIndices = new Set(analysis.fillers.map((f) => f.wordIndex));
|
|
62
|
+
|
|
63
|
+
const removals: Removal[] = [];
|
|
64
|
+
|
|
65
|
+
for (const pause of analysis.cuttable) {
|
|
66
|
+
const isLead = first !== undefined && pause.end <= first.start + 1e-6;
|
|
67
|
+
const isTail = last !== undefined && pause.start >= last.end - 1e-6;
|
|
68
|
+
if (isLead) {
|
|
69
|
+
// Trim dead air before the first word down to LEAD_KEEP (hook starts fast).
|
|
70
|
+
const end = Math.min(pause.end, (first?.start ?? pause.end) - LEAD_KEEP);
|
|
71
|
+
if (end - pause.start >= MIN_REMOVAL) {
|
|
72
|
+
removals.push({ start: pause.start, end, reason: "silence", confidence: 0.95, source: "acoustic" });
|
|
73
|
+
}
|
|
74
|
+
} else if (isTail) {
|
|
75
|
+
const start = Math.max(pause.start, (last?.end ?? pause.start) + TAIL_KEEP);
|
|
76
|
+
if (pause.end - start >= MIN_REMOVAL) {
|
|
77
|
+
removals.push({ start, end: pause.end, reason: "silence", confidence: 0.95, source: "acoustic" });
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
const pauseDur = pause.end - pause.start;
|
|
81
|
+
if (pauseDur <= policy.pauseMin) continue;
|
|
82
|
+
// Keep 40% of the residual gap after the previous word (trailing energy),
|
|
83
|
+
// 60% before the next (breath pre-roll) — never less than the safety pads.
|
|
84
|
+
const start = pause.start + Math.max(policy.tightenTo * 0.4, SILENCE_PAD_IN);
|
|
85
|
+
const end = pause.end - Math.max(policy.tightenTo * 0.6, SILENCE_PAD_OUT);
|
|
86
|
+
if (end - start >= MIN_REMOVAL) {
|
|
87
|
+
removals.push({
|
|
88
|
+
start,
|
|
89
|
+
end,
|
|
90
|
+
reason: pauseDur > 1.0 ? "silence" : "pause",
|
|
91
|
+
confidence: 0.9,
|
|
92
|
+
source: "acoustic",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (policy.removeFillers) {
|
|
99
|
+
for (const f of analysis.fillers) {
|
|
100
|
+
const prev = words[f.wordIndex - 1];
|
|
101
|
+
const next = words[f.wordIndex + 1];
|
|
102
|
+
let start = f.start - 0.04;
|
|
103
|
+
let end = f.end + 0.04;
|
|
104
|
+
if (prev) start = Math.max(start, prev.end + FILLER_NEIGHBOR_PAD);
|
|
105
|
+
if (next) end = Math.min(end, next.start - FILLER_NEIGHBOR_PAD);
|
|
106
|
+
if (end - start >= 0.05) {
|
|
107
|
+
removals.push({ start, end, reason: "filler", confidence: 0.8, source: "transcript" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const r of removals) {
|
|
113
|
+
r.start = Math.max(0, r.start);
|
|
114
|
+
r.end = Math.min(duration, r.end);
|
|
115
|
+
}
|
|
116
|
+
removals.sort((a, b) => a.start - b.start);
|
|
117
|
+
|
|
118
|
+
// A transcript-derived boundary must never land inside a word we intend to
|
|
119
|
+
// keep. Acoustic boundaries are exempt: whisper's `-ml 1` stamps stretch a
|
|
120
|
+
// word's end all the way to the next word's start, so a pause *always* looks
|
|
121
|
+
// like it is "inside" a word — applying this rule to them cancels every cut.
|
|
122
|
+
const protectedWords = words.filter((_, i) => !fillerIndices.has(i));
|
|
123
|
+
for (const r of removals) {
|
|
124
|
+
if (r.source !== "transcript") continue;
|
|
125
|
+
for (const w of protectedWords) {
|
|
126
|
+
if (r.start > w.start && r.start < w.end) r.start = w.end + FILLER_NEIGHBOR_PAD;
|
|
127
|
+
if (r.end > w.start && r.end < w.end) r.end = w.start - FILLER_NEIGHBOR_PAD;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const hasProtectedWordInside = (start: number, end: number): boolean =>
|
|
132
|
+
protectedWords.some((w) => {
|
|
133
|
+
const mid = (w.start + w.end) / 2;
|
|
134
|
+
return mid > start && mid < end;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Merge removals that overlap, or whose in-between keep is a wordless sliver.
|
|
138
|
+
const merged: Removal[] = [];
|
|
139
|
+
for (const r of removals) {
|
|
140
|
+
if (r.end - r.start < 0.05) continue;
|
|
141
|
+
const prev = merged[merged.length - 1];
|
|
142
|
+
if (prev && (r.start <= prev.end + 1e-6 || (r.start - prev.end < MIN_KEEP && !hasProtectedWordInside(prev.end, r.start)))) {
|
|
143
|
+
const prevDur = prev.end - prev.start;
|
|
144
|
+
const curDur = r.end - r.start;
|
|
145
|
+
prev.end = Math.max(prev.end, r.end);
|
|
146
|
+
if (curDur > prevDur) prev.reason = r.reason;
|
|
147
|
+
prev.confidence = Math.min(prev.confidence, r.confidence);
|
|
148
|
+
} else {
|
|
149
|
+
merged.push({ ...r });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const final = merged.filter((r) => r.end - r.start >= 0.05);
|
|
154
|
+
|
|
155
|
+
// Full partition of [0, duration].
|
|
156
|
+
const segments: Segment[] = [];
|
|
157
|
+
let cursor = 0;
|
|
158
|
+
for (const r of final) {
|
|
159
|
+
if (r.start > cursor) segments.push({ srcIn: cursor, srcOut: r.start, kind: "keep" });
|
|
160
|
+
segments.push({ srcIn: r.start, srcOut: r.end, kind: "remove", reason: r.reason, confidence: r.confidence });
|
|
161
|
+
cursor = r.end;
|
|
162
|
+
}
|
|
163
|
+
if (cursor < duration) segments.push({ srcIn: cursor, srcOut: duration, kind: "keep" });
|
|
164
|
+
|
|
165
|
+
// Sanity valve: if analysis went haywire and nearly everything vanished, keep the take.
|
|
166
|
+
const kept = segments.filter((s) => s.kind === "keep").reduce((acc, s) => acc + (s.srcOut - s.srcIn), 0);
|
|
167
|
+
if (kept < Math.min(0.5, duration * 0.05)) return keepAll;
|
|
168
|
+
|
|
169
|
+
return segments;
|
|
170
|
+
}
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface ExecResult {
|
|
4
|
+
stdout: string;
|
|
5
|
+
stderr: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Minimal spawn wrapper: collects stdout/stderr, optionally feeds stdin,
|
|
10
|
+
* throws with the stderr tail attached unless allowNonZero.
|
|
11
|
+
*/
|
|
12
|
+
export function run(
|
|
13
|
+
bin: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
opts: { allowNonZero?: boolean; stdin?: string } = {},
|
|
16
|
+
): Promise<ExecResult> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
19
|
+
let stdout = "";
|
|
20
|
+
let stderr = "";
|
|
21
|
+
child.stdout.on("data", (c: Buffer) => (stdout += c.toString()));
|
|
22
|
+
child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
|
|
23
|
+
child.on("error", (err) => reject(new Error(`${bin} failed to start: ${err.message}`)));
|
|
24
|
+
child.on("close", (code) => {
|
|
25
|
+
if (code !== 0 && !opts.allowNonZero) {
|
|
26
|
+
reject(new Error(`${bin} ${args.join(" ")} failed (exit ${code}):\n${stderr.slice(-2000)}`));
|
|
27
|
+
} else {
|
|
28
|
+
resolve({ stdout, stderr });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
if (opts.stdin !== undefined) {
|
|
32
|
+
child.stdin.write(opts.stdin);
|
|
33
|
+
}
|
|
34
|
+
child.stdin.end();
|
|
35
|
+
});
|
|
36
|
+
}
|