@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.
@@ -0,0 +1,324 @@
1
+
2
+ /**
3
+ * Letterbox detection (PLAN 2026-07-28 Task 7).
4
+ *
5
+ * A source file's frame is not always its picture. A screen-recorded or
6
+ * re-exported clip can carry black bars baked into the pixels — one real case
7
+ * probed as 1440×2560 portrait while the actual shot was a landscape strip
8
+ * with bars above and below. Every geometric consumer then reasoned about the
9
+ * wrong frame: `video-top` showed mostly bar, `blurred-behind` blurred black
10
+ * into more black, the face detector searched an area two-thirds empty, and
11
+ * `sourceAspect` described the container instead of the picture.
12
+ *
13
+ * The fix is one measurement, made early and consumed everywhere: the CONTENT
14
+ * RECT — the largest area that is ever non-black across sampled frames. It is
15
+ * a property of the source, cached beside `face.json`, and each downstream
16
+ * ffmpeg pass prepends a `crop` to it so the rest of the pipeline never sees
17
+ * the bars at all.
18
+ *
19
+ * Detection is ffmpeg's own `cropdetect` (parsed from stderr exactly the way
20
+ * `detectSilences` reads `silencedetect`), not hand-rolled pixel scanning.
21
+ * The union across samples is what keeps a dark shot honest: a bar has to be
22
+ * black in EVERY sampled frame, so a night scene that ever shows anything at
23
+ * its edges keeps its full frame.
24
+ */
25
+
26
+ /** Pixel rect inside the source frame. */
27
+ export interface ContentRect {
28
+ x: number;
29
+ y: number;
30
+ w: number;
31
+ h: number;
32
+ /** True when the rect IS the frame — nothing to trim, no crop pass runs. */
33
+ full: boolean;
34
+ }
35
+
36
+ /** One cropdetect measurement, with the source time it was taken at. */
37
+ export interface CropSample {
38
+ x: number;
39
+ y: number;
40
+ w: number;
41
+ h: number;
42
+ /** Source seconds, from cropdetect's own `t:` field. */
43
+ tSec: number;
44
+ }
45
+
46
+ /**
47
+ * Per-frame `crop=W:H:X:Y` lines from cropdetect's stderr, with timestamps.
48
+ *
49
+ * The timestamp is not decoration: a source whose framing changes mid-take
50
+ * needs to know WHEN it changed, not merely that two different rects were seen.
51
+ * ffmpeg prints `t:` on the same line, so this costs nothing.
52
+ */
53
+ export function parseCropdetect(stderr: string): CropSample[] {
54
+ const out: CropSample[] = [];
55
+ for (const line of stderr.split("\n")) {
56
+ const m = line.match(/crop=(\d+):(\d+):(\d+):(\d+)/);
57
+ if (!m) continue;
58
+ const t = line.match(/\bt:(-?[\d.]+)/);
59
+ out.push({
60
+ w: Number(m[1]),
61
+ h: Number(m[2]),
62
+ x: Number(m[3]),
63
+ y: Number(m[4]),
64
+ tSec: t ? Number(t[1]) : 0,
65
+ });
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /**
71
+ * A bar thinner than this fraction of its dimension is treated as not there.
72
+ * Encoder padding and edge vignetting produce a few dark rows on perfectly
73
+ * ordinary footage; trimming them would change every geometry downstream to
74
+ * chase two invisible pixels.
75
+ */
76
+ const SNAP_FRAC = 0.02;
77
+
78
+ /**
79
+ * Below this share of the frame the measurement is refused. A clip dark
80
+ * enough to "detect" a content rect this small is a clip cropdetect cannot be
81
+ * trusted on — a fade-from-black or a genuinely dim shot — and cropping a
82
+ * video to a sliver on bad evidence is far worse than leaving bars alone.
83
+ */
84
+ const MIN_CONTENT_FRAC = 0.25;
85
+
86
+ /**
87
+ * The stable rect from per-frame measurements: the UNION of everything any
88
+ * sample considered content, snapped per side so hairline bars don't trigger
89
+ * a crop, and refused outright when the result is implausibly small.
90
+ */
91
+ export function stableContentRect(
92
+ rects: ReadonlyArray<{ x: number; y: number; w: number; h: number }>,
93
+ width: number,
94
+ height: number,
95
+ ): ContentRect {
96
+ const whole: ContentRect = { x: 0, y: 0, w: width, h: height, full: true };
97
+ if (rects.length === 0 || width <= 0 || height <= 0) return whole;
98
+
99
+ let left = Infinity;
100
+ let top = Infinity;
101
+ let right = -Infinity;
102
+ let bottom = -Infinity;
103
+ for (const r of rects) {
104
+ left = Math.min(left, r.x);
105
+ top = Math.min(top, r.y);
106
+ right = Math.max(right, r.x + r.w);
107
+ bottom = Math.max(bottom, r.y + r.h);
108
+ }
109
+
110
+ // Snap each side independently: a real top bar must survive even when the
111
+ // left and right edges are content to the pixel.
112
+ if (left / width < SNAP_FRAC) left = 0;
113
+ if (top / height < SNAP_FRAC) top = 0;
114
+ if ((width - right) / width < SNAP_FRAC) right = width;
115
+ if ((height - bottom) / height < SNAP_FRAC) bottom = height;
116
+
117
+ const w = right - left;
118
+ const h = bottom - top;
119
+ if (w <= 0 || h <= 0) return whole;
120
+ if ((w * h) / (width * height) < MIN_CONTENT_FRAC) return whole;
121
+ if (left === 0 && top === 0 && w === width && h === height) return whole;
122
+ // Even offsets/sizes keep yuv420 encoders happy; expand outward so the
123
+ // rounding never shaves a row of picture (a black hairline would).
124
+ const x = left - (left % 2);
125
+ const y = top - (top % 2);
126
+ return {
127
+ x,
128
+ y,
129
+ w: Math.min(width - x, w + (w % 2)),
130
+ h: Math.min(height - y, h + (h % 2)),
131
+ full: false,
132
+ };
133
+ }
134
+
135
+ /** A stretch of source time over which the framing does not change. */
136
+ export interface ContentRectSegment {
137
+ /** Source seconds. */
138
+ startSec: number;
139
+ endSec: number;
140
+ rect: ContentRect;
141
+ }
142
+
143
+ /**
144
+ * A framing run must survive BOTH of these to be believed. Together they are
145
+ * what replaces the union rule's protection (PLAN Task C, step C2).
146
+ *
147
+ * The union existed because a genuinely dim frame can "detect" a false crop;
148
+ * measuring per segment reintroduces that risk at segment granularity, where a
149
+ * single dark frame in the middle of a good run could carve out a bogus crop.
150
+ * A run of one is therefore never a framing change — it is an anomaly, and it
151
+ * is absorbed into its neighbours. The wall-time floor covers the same failure
152
+ * on a densely-sampled source, where three consecutive odd frames can still
153
+ * span a third of a second.
154
+ */
155
+ const MIN_RUN_SAMPLES = 2;
156
+ const MIN_RUN_SEC = 0.75;
157
+
158
+ /** Two rects describe the same framing if every side agrees within a hair. */
159
+ function sameFraming(a: ContentRect, b: ContentRect, width: number, height: number): boolean {
160
+ const tolX = width * SNAP_FRAC;
161
+ const tolY = height * SNAP_FRAC;
162
+ return (
163
+ Math.abs(a.x - b.x) <= tolX &&
164
+ Math.abs(a.w - b.w) <= tolX &&
165
+ Math.abs(a.y - b.y) <= tolY &&
166
+ Math.abs(a.h - b.h) <= tolY
167
+ );
168
+ }
169
+
170
+ /**
171
+ * The source's framing over time (PLAN Task C).
172
+ *
173
+ * Task 7 modelled framing as one rect per source, which is right for a clip
174
+ * that was letterboxed once on export. The author's own clip is not that: it
175
+ * alternates a landscape strip with full-bleed portrait five times, 24.0s of
176
+ * 63.5s letterboxed. Under a single-rect model `stableContentRect` correctly
177
+ * refused to crop — a bar has to be black in EVERY sample and here it is not —
178
+ * so every bar rendered as a bar.
179
+ *
180
+ * Each sample is classified through `stableContentRect` on its own, so the
181
+ * hairline snapping and the implausibly-small refusal still apply per frame.
182
+ * Consecutive like-classified samples become runs; runs too small to believe
183
+ * are absorbed. A source with uniform framing collapses to exactly one segment,
184
+ * which is Task 7's behaviour unchanged.
185
+ */
186
+ export function contentRectTimeline(
187
+ samples: readonly CropSample[],
188
+ width: number,
189
+ height: number,
190
+ durationSec: number,
191
+ ): ContentRectSegment[] {
192
+ const whole: ContentRect = { x: 0, y: 0, w: width, h: height, full: true };
193
+ const wholeTimeline = [{ startSec: 0, endSec: durationSec, rect: whole }];
194
+ if (samples.length === 0 || width <= 0 || height <= 0) return wholeTimeline;
195
+
196
+ const sorted = [...samples].sort((a, b) => a.tSec - b.tSec);
197
+ const classified = sorted.map((s) => ({
198
+ tSec: s.tSec,
199
+ rect: stableContentRect([s], width, height),
200
+ }));
201
+
202
+ // Group consecutive like-framed samples.
203
+ type Run = { rect: ContentRect; from: number; to: number; count: number };
204
+ const runs: Run[] = [];
205
+ for (const c of classified) {
206
+ const last = runs[runs.length - 1];
207
+ if (last && sameFraming(last.rect, c.rect, width, height)) {
208
+ last.to = c.tSec;
209
+ last.count += 1;
210
+ } else {
211
+ runs.push({ rect: c.rect, from: c.tSec, to: c.tSec, count: 1 });
212
+ }
213
+ }
214
+
215
+ // Absorb runs too small to be a real framing change. Repeat until stable:
216
+ // dropping one run can make its neighbours adjacent and mergeable, and a
217
+ // single pass would leave those split.
218
+ let changed = true;
219
+ while (changed && runs.length > 1) {
220
+ changed = false;
221
+ for (let i = 0; i < runs.length; i++) {
222
+ const r = runs[i]!;
223
+ if (r.count >= MIN_RUN_SAMPLES && r.to - r.from >= MIN_RUN_SEC) continue;
224
+ // Absorb into the LONGER neighbour — the one more likely to be the truth.
225
+ const prev = runs[i - 1];
226
+ const next = runs[i + 1];
227
+ const into = !prev ? next : !next ? prev : prev.count >= next.count ? prev : next;
228
+ if (!into) continue;
229
+ into.from = Math.min(into.from, r.from);
230
+ into.to = Math.max(into.to, r.to);
231
+ into.count += r.count;
232
+ runs.splice(i, 1);
233
+ changed = true;
234
+ break;
235
+ }
236
+ }
237
+
238
+ // Merge any neighbours that now agree, then lay the runs onto the timeline.
239
+ const merged: Run[] = [];
240
+ for (const r of runs) {
241
+ const last = merged[merged.length - 1];
242
+ if (last && sameFraming(last.rect, r.rect, width, height)) {
243
+ last.to = Math.max(last.to, r.to);
244
+ last.count += r.count;
245
+ } else {
246
+ merged.push({ ...r });
247
+ }
248
+ }
249
+
250
+ return merged.map((r, i) => ({
251
+ // The change happened somewhere between the last sample of one run and the
252
+ // first of the next; the midpoint is the least-wrong guess and keeps the
253
+ // boundary off any sampled frame.
254
+ startSec: i === 0 ? 0 : (merged[i - 1]!.to + r.from) / 2,
255
+ endSec: i === merged.length - 1 ? durationSec : (r.to + merged[i + 1]!.from) / 2,
256
+ rect: r.rect,
257
+ }));
258
+ }
259
+
260
+ /**
261
+ * The exact framing-change instant inside a densely-sampled window
262
+ * (NORMALIZE plan, boundary refinement).
263
+ *
264
+ * The coarse timeline places a boundary midway between two 2 Hz samples —
265
+ * ±0.25s of slack, which was fine while the boundary only steered a render-
266
+ * time crop but is not fine once segments are BAKED: every frame on the wrong
267
+ * side of a baked boundary is cropped with the wrong window, and a quarter
268
+ * second of bar-edged frames at each of nine boundaries is exactly the class
269
+ * of artifact the bake exists to remove.
270
+ *
271
+ * Given per-frame samples spanning the coarse boundary, this finds the gap
272
+ * between the last frame still framed like `before` and the first framed like
273
+ * `after`, and returns its midpoint. Frames matching neither (transition
274
+ * wipes, encoder smear) are skipped. Null — keep the coarse boundary — when
275
+ * the window never actually straddles the change.
276
+ */
277
+ export function pickTransition(
278
+ samples: readonly CropSample[],
279
+ before: ContentRect,
280
+ after: ContentRect,
281
+ width: number,
282
+ height: number,
283
+ ): number | null {
284
+ let lastBefore: number | null = null;
285
+ for (const s of [...samples].sort((a, b) => a.tSec - b.tSec)) {
286
+ const rect = stableContentRect([s], width, height);
287
+ if (sameFraming(rect, before, width, height)) {
288
+ lastBefore = s.tSec;
289
+ } else if (sameFraming(rect, after, width, height)) {
290
+ // The first after-framed frame only counts once a before-framed frame
291
+ // has been seen — otherwise the window started past the change and the
292
+ // "transition" would be an artifact of where the window was cut.
293
+ return lastBefore === null ? null : (lastBefore + s.tSec) / 2;
294
+ }
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * The content rect active at a SOURCE time. Half-open, so a boundary belongs to
301
+ * the segment starting there. Times outside the timeline clamp to its ends
302
+ * rather than falling back to the full frame — a clamp keeps a rounding error
303
+ * at a boundary from flashing the bars back on for one frame.
304
+ */
305
+ export function contentRectAt(
306
+ timeline: readonly ContentRectSegment[],
307
+ tSec: number,
308
+ frame?: { width: number; height: number },
309
+ ): ContentRect {
310
+ if (timeline.length === 0) {
311
+ return { x: 0, y: 0, w: frame?.width ?? 0, h: frame?.height ?? 0, full: true };
312
+ }
313
+ if (tSec < timeline[0]!.startSec) return timeline[0]!.rect;
314
+ for (const seg of timeline) {
315
+ if (tSec >= seg.startSec && tSec < seg.endSec) return seg.rect;
316
+ }
317
+ return timeline[timeline.length - 1]!.rect;
318
+ }
319
+
320
+ /** The ffmpeg `crop=` filter string for a rect (no-op rects return ""). */
321
+ export function cropFilter(rect: ContentRect | null | undefined): string {
322
+ if (!rect || rect.full) return "";
323
+ return `crop=${rect.w}:${rect.h}:${rect.x}:${rect.y}`;
324
+ }
package/src/cover.ts ADDED
@@ -0,0 +1,216 @@
1
+ import { readFile, unlink } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { run } from "./exec";
4
+
5
+ /**
6
+ * Cover image selection (FINDINGS §31).
7
+ *
8
+ * Instagram and Facebook both accept a custom uploaded cover, so nothing has
9
+ * to be pickable from the video's own frames — which is why ossclip writes a
10
+ * separate `<out>.cover.jpg` rather than burning a title card into the head of
11
+ * the reel. Spending the first 2-3 seconds on a static card would fight the
12
+ * "hook in the first ~2s" policy directly, and a separate file can be
13
+ * restyled without re-rendering a minute of video.
14
+ *
15
+ * This module picks WHICH frame. The banner is drawn by the renderer.
16
+ */
17
+
18
+ /**
19
+ * A cover banner is a headline, not a sentence (FINDINGS §35). The producer
20
+ * shipped 13 words across five lines by reusing the video's hook verbatim; at
21
+ * grid-tile size that is unreadable. The reference covers run 4-9 words.
22
+ *
23
+ * Stated in the schema AND enforced here, because a `.describe()` is a request
24
+ * and this is a constraint — the same reason `normalizeBeatSheet` exists.
25
+ */
26
+ export const COVER_MAX_WORDS = 9;
27
+
28
+ /** Trailing words that cannot end a headline — the truncation reads as broken. */
29
+ const DANGLING = new Set([
30
+ "a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "is", "it",
31
+ "of", "on", "or", "the", "to", "with", "that", "this", "my", "your", "so",
32
+ ]);
33
+
34
+ /**
35
+ * Cut a headline down to `maxWords`, preferring a natural break.
36
+ *
37
+ * A dash or colon usually separates a complete claim from its elaboration, so
38
+ * the first clause is a real headline rather than a sentence with its end
39
+ * lopped off. Only when that is still too long does this truncate — and then
40
+ * it refuses to stop on a preposition or article, which is what makes a
41
+ * truncation look like a bug instead of an edit.
42
+ */
43
+ export function coverHeadline(text: string, maxWords = COVER_MAX_WORDS): string {
44
+ const clean = text.trim().replace(/\s+/g, " ");
45
+ if (!clean) return clean;
46
+ const words = (s: string): string[] => s.split(" ").filter(Boolean);
47
+ if (words(clean).length <= maxWords) return clean;
48
+
49
+ // First clause, if it stands on its own — never a two-word fragment. Even
50
+ // when the clause is itself too long it is the better thing to cut down,
51
+ // since truncating it can never wander past the dash into the elaboration.
52
+ const clause = clean.split(/\s*[—–:]\s*|\s+-\s+/)[0]!.trim();
53
+ const base = words(clause).length >= 3 ? clause : clean;
54
+ const out = words(base).slice(0, maxWords);
55
+ while (out.length > 3 && DANGLING.has(out[out.length - 1]!.toLowerCase().replace(/\W/g, ""))) {
56
+ out.pop();
57
+ }
58
+ // A clause that ended on its own punctuation keeps it; a cut does not.
59
+ return out.join(" ").replace(/[,;:—–-]+$/, "");
60
+ }
61
+
62
+ /** Where the face sits in the COVER frame, as fractions of it. */
63
+ export interface CoverFace {
64
+ centerXFrac: number;
65
+ centerYFrac: number;
66
+ sizeFrac: number;
67
+ }
68
+
69
+ export interface CoverCandidate {
70
+ timeSec: number;
71
+ /** Variance of the Laplacian — higher is sharper, lower is motion-blurred. */
72
+ sharpness: number;
73
+ hasFace: boolean;
74
+ /** The box, when one was found — the banner routes around it (FINDINGS §33). */
75
+ face?: CoverFace;
76
+ score: number;
77
+ }
78
+
79
+ /**
80
+ * Detection frame: the 9:16 cover frame at analysis size.
81
+ *
82
+ * Deliberately NOT face.ts's plain `scale`. That one measures the SOURCE, and
83
+ * its fractions feed the video crop math. This one measures the COVER, which
84
+ * is a centre crop to 1080×1920 — so it applies the identical crop first.
85
+ * Without that, a face box measured on a stretched 16:9 source would place the
86
+ * cover banner against geometry the cover does not have.
87
+ */
88
+ const DET_W = 360;
89
+ const DET_H = 640;
90
+ export const COVER_CROP_VF =
91
+ `scale=${DET_W}:${DET_H}:force_original_aspect_ratio=increase,crop=${DET_W}:${DET_H}`;
92
+
93
+ /**
94
+ * Variance of the Laplacian over a grayscale frame — the standard cheap
95
+ * sharpness measure. A frame caught mid-motion has most of its energy smeared
96
+ * away and scores low, which is exactly what a cover must not be.
97
+ */
98
+ export function laplacianVariance(pixels: Uint8Array, w: number, h: number): number {
99
+ let sum = 0;
100
+ let sumSq = 0;
101
+ let n = 0;
102
+ for (let y = 1; y < h - 1; y++) {
103
+ for (let x = 1; x < w - 1; x++) {
104
+ const i = y * w + x;
105
+ const lap =
106
+ 4 * pixels[i]! - pixels[i - 1]! - pixels[i + 1]! - pixels[i - w]! - pixels[i + w]!;
107
+ sum += lap;
108
+ sumSq += lap * lap;
109
+ n++;
110
+ }
111
+ }
112
+ if (n === 0) return 0;
113
+ const mean = sum / n;
114
+ return sumSq / n - mean * mean;
115
+ }
116
+
117
+ /**
118
+ * Score a candidate. A face is close to mandatory — a cover without the
119
+ * speaker is a cover for a different video — and among frames that have one,
120
+ * sharpness decides. Earlier frames win ties so the cover matches the opening.
121
+ */
122
+ export function scoreCandidate(c: {
123
+ timeSec: number;
124
+ durationSec: number;
125
+ sharpness: number;
126
+ hasFace: boolean;
127
+ maxSharpness: number;
128
+ }): number {
129
+ const face = c.hasFace ? 1 : 0;
130
+ const sharp = c.maxSharpness > 0 ? c.sharpness / c.maxSharpness : 0;
131
+ const earliness = 1 - Math.min(1, c.timeSec / Math.max(1e-6, c.durationSec));
132
+ return face * 2 + sharp + earliness * 0.3;
133
+ }
134
+
135
+ export interface PickCoverOptions {
136
+ /** Frames to sample across the searched window. */
137
+ samples?: number;
138
+ /** Fraction of the take to search — the cover should match the opening. */
139
+ searchFraction?: number;
140
+ cacheDir?: string;
141
+ /**
142
+ * Locates the face in a sampled frame, in that frame's own fractions.
143
+ * Returns the box rather than a boolean because the banner has to route
144
+ * around it (FINDINGS §33), and the frame it was measured on is the only
145
+ * frame whose geometry is certainly the cover's.
146
+ */
147
+ detectFace?: (pixels: Uint8Array, w: number, h: number) => CoverFace | null;
148
+ /**
149
+ * ffmpeg filter trimming the source to its content rect (PLAN Task 7),
150
+ * applied BEFORE the cover's own centre crop — otherwise the cover frames a
151
+ * canvas that is two-thirds baked-in black bar.
152
+ */
153
+ cropVf?: string;
154
+ }
155
+
156
+ /**
157
+ * Pick the best cover frame from the take: sharp, face present, early.
158
+ *
159
+ * Note on "eyes open" (§31): pico's cascade locates a face box but carries no
160
+ * eye state, and adding a landmark model for one thumbnail is not worth the
161
+ * dependency — sharpness plus a face is what this measures. A blink is a real
162
+ * residual risk; `--cover <path>` is the escape hatch.
163
+ */
164
+ export async function pickCoverFrame(
165
+ tools: { ffmpegPath: string },
166
+ videoPath: string,
167
+ durationSec: number,
168
+ opts: PickCoverOptions = {},
169
+ ): Promise<CoverCandidate | null> {
170
+ const samples = opts.samples ?? 12;
171
+ const searchFraction = opts.searchFraction ?? 0.2;
172
+ const window = Math.max(1, durationSec * searchFraction);
173
+ const candidates: CoverCandidate[] = [];
174
+ const raw: Array<{
175
+ timeSec: number;
176
+ sharpness: number;
177
+ hasFace: boolean;
178
+ face?: CoverFace;
179
+ }> = [];
180
+
181
+ for (let i = 0; i < samples; i++) {
182
+ const t = (window * (i + 0.5)) / samples;
183
+ const framePath = join(opts.cacheDir ?? ".", `cover-frame-${i}.gray`);
184
+ await run(tools.ffmpegPath, [
185
+ "-v", "error",
186
+ "-ss", t.toFixed(3),
187
+ "-i", videoPath,
188
+ "-frames:v", "1",
189
+ "-vf", `${opts.cropVf ? `${opts.cropVf},` : ""}${COVER_CROP_VF}`,
190
+ "-pix_fmt", "gray",
191
+ "-f", "rawvideo",
192
+ "-y", framePath,
193
+ ]);
194
+ const pixels = new Uint8Array(await readFile(framePath));
195
+ await unlink(framePath).catch(() => {});
196
+ if (pixels.length < DET_W * DET_H) continue;
197
+ const face = opts.detectFace?.(pixels, DET_W, DET_H) ?? undefined;
198
+ raw.push({
199
+ timeSec: t,
200
+ sharpness: laplacianVariance(pixels, DET_W, DET_H),
201
+ hasFace: face !== undefined,
202
+ face,
203
+ });
204
+ }
205
+ if (raw.length === 0) return null;
206
+
207
+ const maxSharpness = Math.max(...raw.map((r) => r.sharpness));
208
+ for (const r of raw) {
209
+ candidates.push({
210
+ ...r,
211
+ score: scoreCandidate({ ...r, durationSec: window, maxSharpness }),
212
+ });
213
+ }
214
+ candidates.sort((a, b) => b.score - a.score);
215
+ return candidates[0]!;
216
+ }
package/src/cta.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Which comment-CTA asks the keyword mechanic is allowed to fire on.
3
+ *
4
+ * `ChatMock`'s keyword path (FINDINGS §16/§28b) implements exactly one shape of
5
+ * ask: **"comment AGENTS and I'll send it"** — a distinctive word the viewer
6
+ * literally types, given the whole frame because the ask IS the message, with
7
+ * the same word quoted in the caption while it is on screen.
8
+ *
9
+ * A **"reply with a number"** ask is a different shape wearing the same clothes.
10
+ * The author's clip says "which one did you not know? Type in the comments the
11
+ * number of it"; the producer read `number` as the keyword, so the render showed
12
+ * a `"NUMBER"` pill and the caption read `comments the "NUMBER"`. Nobody is
13
+ * meant to type the word "number" — they are meant to reply with a digit the
14
+ * producer cannot know in advance. The mechanic has nothing to render, so it
15
+ * must not fire at all.
16
+ *
17
+ * The discriminator is lexical and deliberately narrow: the words below are
18
+ * REFERENTIAL — they point at the reply rather than being it. A content noun
19
+ * ("guide", "template", "agents") stays usable, because rejecting those would
20
+ * break the very CTA the feature exists for. A rejection is always reported;
21
+ * silently dropping a CTA the author wrote is worse than rendering a wrong one,
22
+ * because only one of those is visible.
23
+ */
24
+
25
+ /**
26
+ * Words that name the reply instead of being it. Not a stopword list — several
27
+ * of these are perfectly good nouns elsewhere; they are unusable only in the
28
+ * one position "the word you type in the comments".
29
+ */
30
+ const REFERENTIAL = new Set([
31
+ "number", "numbers", "digit", "answer", "answers", "reply", "replies",
32
+ "comment", "comments", "response", "responses", "word", "words",
33
+ "below", "above", "thing", "something", "anything", "option", "choice",
34
+ "yours", "mine", "same", "which",
35
+ ]);
36
+
37
+ /**
38
+ * Function words. A CTA keyword is a thing the viewer types on purpose, so a
39
+ * pronoun or determiner is always a misread of the sentence around it.
40
+ */
41
+ const FUNCTION_WORDS = new Set([
42
+ "it", "this", "that", "these", "those", "one", "ones", "them", "they",
43
+ "me", "you", "your", "my", "our", "their", "his", "her", "its",
44
+ "a", "an", "the", "of", "to", "in", "on", "at", "for", "and", "or",
45
+ "is", "are", "was", "be", "do", "did", "does", "here", "there", "what",
46
+ "who", "how", "why", "when", "where", "if", "so", "but", "yes", "no",
47
+ ]);
48
+
49
+ /**
50
+ * Why this keyword cannot drive the CTA mechanic, or `null` when it can.
51
+ *
52
+ * Returns a human-readable reason rather than a boolean so the caller can log
53
+ * WHICH rule fired — a silent suppression here would look identical to a take
54
+ * that simply had no CTA.
55
+ */
56
+ export function rejectCtaKeyword(keyword: string | undefined | null): string | null {
57
+ const word = (keyword ?? "").trim().toLowerCase();
58
+ if (!word) return "empty";
59
+ // A digit is the reply itself, never the word naming it.
60
+ if (/^\d+$/.test(word)) return `"${word}" is a digit, not a word to type`;
61
+ if (word.length < 3) return `"${word}" is too short to be a comment keyword`;
62
+ if (REFERENTIAL.has(word)) {
63
+ return `"${word}" names the reply rather than being it — this is a ` +
64
+ `"reply with a number" ask, not a comment-a-keyword ask`;
65
+ }
66
+ if (FUNCTION_WORDS.has(word)) return `"${word}" is a function word`;
67
+ return null;
68
+ }