keepitreel 0.1.1 → 0.1.2

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/README.md CHANGED
@@ -199,8 +199,9 @@ Renders `start` to `end` of a clip as a 9:16 segment: the landscape picture sits
199
199
  | `workDir` | `string` | required | Where the output and the files ffmpeg reads go: the text, and the filter graph (`<name>.filter`), which keeps clips with hundreds of subtitle words within Windows' command-line limit |
200
200
  | `name` | `string` | required | The output's base name |
201
201
  | `subtitles` | `SubtitleChunk[]` | `[]` | From [`.subtitleChunks()`](#subtitlechunksoptions) |
202
- | `hook` | `{ band: { y, h, opacity }, lines, outSeconds }` | | A full-width ink band with text, up from the first frame, fading out `outSeconds` before the end |
202
+ | `hook` | `{ band?: { y, h, opacity }, lines, outSeconds? }` | | Text up from the first frame, on a full-width ink band when `band` is given, fading out `outSeconds` before the end. Without `outSeconds` it stays to the end |
203
203
  | `tag` | `{ box: { x, y, w, h, opacity }, lines, delay?, outSeconds?, fade? }` | | An ink box with text that fades in after `delay` and out before the end. With `fade: false` it is up for the whole clip |
204
+ | `fill` | `string` | `FILL_9_16` | The filter chain that turns the source picture into the 1080x1920 frame, for a layout of your own (for example the picture cropped and placed higher, over a darker blur) |
204
205
  | `hold` | `number` | `0` | Seconds the first frame is held, silent, before the clip plays. Subtitles and mute windows move with it |
205
206
  | `mute` | `[number, number][]` | `[]` | Clip-relative windows where the sound is replaced by a beep |
206
207
  | `loudness` | `number` | | Target loudness in LUFS, e.g. `-14`. The clip's sound is normalized to it before beeps are mixed in. Without it the sound is left as it is |
@@ -223,6 +224,10 @@ Splits clip-relative words into one-line, centered chunks, breaking at sentence
223
224
  | `maxWords` | `number` | `5` | |
224
225
  | `hold` | `number` | `0.6` | Seconds a chunk stays up after its last word when the next one is not right after |
225
226
  | `maxWidth` | `number` | `TEXT_WIDTH` | |
227
+ | `breakAfter` | `RegExp` | `/[.?!]["']?$/` | Words that end a piece, as sentence ends do. `/[.,?!]["']?$/` also breaks after commas |
228
+ | `pause` | `number` | | A silence longer than this many seconds between two words also ends a piece |
229
+ | `border` | `{ width, color }` | 3 px, the renderer's ink at 85% | The outline around each word |
230
+ | `shadow` | `{ x, y, color }` | | A shadow under each word, offset by `x` and `y` px |
226
231
 
227
232
  #### .concat(segments, workDir, outputPath)
228
233
 
@@ -252,6 +257,18 @@ Returns the width in pixels of `text` in the font with key `font` at `size`.
252
257
 
253
258
  Returns the largest size up to `size` at which `text` fits in `maxWidth` (default `TEXT_WIDTH`).
254
259
 
260
+ #### .wrapText(text, font, size, options?)
261
+
262
+ Returns `text` wrapped into lines no wider than `maxWidth` (default `TEXT_WIDTH`). With `balance: true` the lines are as even as the fewest lines allow, instead of full lines and a short last one.
263
+
264
+ #### .printable(text, font)
265
+
266
+ Returns `text` without the characters the font has no glyph for, such as emoji, with spaces collapsed. ffmpeg draws those as empty boxes.
267
+
268
+ #### .capHeight(font, size)
269
+
270
+ Returns the height of the font's capital letters at `size`. `baseline = top + capHeight` places text by the top of its capitals.
271
+
255
272
  #### .drawTexts(lines, out, workDir, name)
256
273
 
257
274
  Returns ffmpeg `drawtext` filters for [text lines](#text-lines), for filter graphs of your own. Each line fades in, rising by `rise` pixels, from its `delay`. With `out` (`{ end, seconds }`) it fades away over the last `seconds` before `end`. The text goes through files in `workDir`, named after `name`, so it needs no escaping.
@@ -282,6 +299,7 @@ The `lines` of a hook or tag, and the input of `.drawTexts()`:
282
299
  | `delay` | `number` | `0` | Seconds before the line fades in. `-0.25` has it fully in at the first frame |
283
300
  | `rise` | `number` | `18` | Pixels the line rises while it fades in |
284
301
  | `window` | `[number, number]` | | Seconds the line is shown, from the first up to, not including, the second |
302
+ | `box` | `{ color, border }` | | A box behind the text, `border` px around it, or `[vertical, horizontal]` |
285
303
 
286
304
  In a hook or tag, `.renderClip()` sets `delay` and `rise` itself: the lines follow the band's or box's fade and do not rise.
287
305
 
@@ -42,7 +42,7 @@ function defaultFfmpeg() {
42
42
  return "ffmpeg";
43
43
  }
44
44
  }
45
- var endsSentence = (text) => /[.?!]["']?$/.test(text);
45
+ var SENTENCE_END = /[.?!]["']?$/;
46
46
  var Renderer = class {
47
47
  ffmpegPath;
48
48
  ink;
@@ -96,6 +96,46 @@ ${stderr.slice(-1500)}`);
96
96
  const width = this.textWidth(text, font, size);
97
97
  return width <= maxWidth ? size : Math.floor(size * maxWidth / width);
98
98
  }
99
+ /** Height of the font's capital letters at `size`, for placing text by its top. */
100
+ capHeight(font, size) {
101
+ const f = this.font(font);
102
+ return (f.tables.os2?.sCapHeight ?? f.ascender) * size / f.unitsPerEm;
103
+ }
104
+ /** `text` without the characters `font` has no glyph for, such as emoji, with spaces collapsed. */
105
+ printable(text, font) {
106
+ const f = this.font(font);
107
+ return [...text].filter((ch) => /\s/.test(ch) || f.charToGlyphIndex(ch) > 0).join("").replace(/\s+/g, " ").trim();
108
+ }
109
+ /**
110
+ * `text` wrapped into lines of at most `maxWidth`. With `balance`, the lines are as even as
111
+ * the fewest lines allow, instead of full lines and a short last one.
112
+ */
113
+ wrapText(text, font, size, { maxWidth = TEXT_WIDTH, balance = false } = {}) {
114
+ const wrap = (width) => {
115
+ const lines2 = [];
116
+ let line = "";
117
+ for (const word of text.split(" ")) {
118
+ const next = line ? `${line} ${word}` : word;
119
+ if (!line || this.textWidth(next, font, size) <= width) line = next;
120
+ else {
121
+ lines2.push(line);
122
+ line = word;
123
+ }
124
+ }
125
+ if (line) lines2.push(line);
126
+ return lines2;
127
+ };
128
+ const lines = wrap(maxWidth);
129
+ if (!balance || lines.length < 2) return lines;
130
+ let lo = 0;
131
+ let hi = maxWidth;
132
+ while (hi - lo > 1) {
133
+ const mid = (lo + hi) / 2;
134
+ if (wrap(mid).length <= lines.length) hi = mid;
135
+ else lo = mid;
136
+ }
137
+ return wrap(hi);
138
+ }
99
139
  /** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
100
140
  inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }) {
101
141
  let src = `color=c=${this.ink}:s=${w}x${h}:r=${FPS}:d=${duration},format=rgba,colorchannelmixer=aa=${opacity}`;
@@ -110,7 +150,7 @@ ${stderr.slice(-1500)}`);
110
150
  */
111
151
  drawTexts(lines, out, workDir, name) {
112
152
  return lines.map((line, i) => {
113
- const { x = MARGIN, baseline, delay = 0, rise = TEXT_RISE, window } = line;
153
+ const { x = MARGIN, baseline, delay = 0, rise = TEXT_RISE, window, box } = line;
114
154
  const p = `clip((t-${delay})/${TEXT_IN_SECONDS},0,1)`;
115
155
  const eased = `(${p}*(2-${p}))`;
116
156
  const alpha = out ? `${eased}*clip((${out.end}-t)/${out.seconds},0,1)` : eased;
@@ -120,7 +160,7 @@ ${stderr.slice(-1500)}`);
120
160
  `${name}-line${i}.txt`,
121
161
  { ...line, x },
122
162
  `y='${y}':alpha='${alpha}'` + // Half-open, so lines with back-to-back windows never share a frame.
123
- (window ? `:enable='gte(t,${window[0]})*lt(t,${window[1]})'` : "")
163
+ (window ? `:enable='gte(t,${window[0]})*lt(t,${window[1]})'` : "") + (box ? `:box=1:boxcolor=${box.color}:boxborderw=${[box.border].flat().join("|")}` : "")
124
164
  );
125
165
  });
126
166
  }
@@ -135,15 +175,36 @@ ${stderr.slice(-1500)}`);
135
175
  * further while it is wider than `maxWidth`.
136
176
  */
137
177
  subtitleChunks(options) {
138
- const { words, duration, font, color, style, activeColor, size = 56, baseline = 1445, maxWords = 5, hold = 0.6, maxWidth = TEXT_WIDTH } = options;
178
+ const {
179
+ words,
180
+ duration,
181
+ font,
182
+ color,
183
+ style,
184
+ activeColor,
185
+ size = 56,
186
+ baseline = 1445,
187
+ maxWords = 5,
188
+ hold = 0.6,
189
+ maxWidth = TEXT_WIDTH,
190
+ breakAfter = SENTENCE_END,
191
+ pause,
192
+ border,
193
+ shadow
194
+ } = options;
139
195
  const styled = words.map((w) => ({ ...w, font, color, ...style?.(w) }));
140
196
  const space = this.textWidth(" ", font, size);
141
197
  const width = (ws) => ws.reduce((sum, w) => sum + this.textWidth(w.text, w.font, size), 0) + (ws.length - 1) * space;
142
198
  const sentences = [];
143
199
  let current = [];
144
200
  for (const word of styled) {
201
+ const prev = current[current.length - 1];
202
+ if (prev && pause != null && word.start - prev.end > pause) {
203
+ sentences.push(current);
204
+ current = [];
205
+ }
145
206
  current.push(word);
146
- if (endsSentence(word.text)) {
207
+ if (breakAfter.test(word.text)) {
147
208
  sentences.push(current);
148
209
  current = [];
149
210
  }
@@ -176,7 +237,7 @@ ${stderr.slice(-1500)}`);
176
237
  const lineEnd = Math.max(end, first.start + 0.1);
177
238
  let x = (WIDTH - width(group)) / 2;
178
239
  const placed = group.map((w, j) => {
179
- const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
240
+ const word = { text: w.text, font: w.font, color: w.color, size, baseline, x, ...border ? { border } : {}, ...shadow ? { shadow } : {} };
180
241
  const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
181
242
  if (spokenColor) {
182
243
  const from = Math.max(start, w.start);
@@ -194,7 +255,9 @@ ${stderr.slice(-1500)}`);
194
255
  return chunks.flatMap(
195
256
  (chunk, c) => chunk.words.flatMap((word, w) => {
196
257
  const file = `${name}-sub${c}-${w}.txt`;
197
- const look = `y=${word.baseline}-ascent:borderw=3:bordercolor=${this.ink}@0.85`;
258
+ const { width, color } = word.border ?? { width: 3, color: `${this.ink}@0.85` };
259
+ const shadow = word.shadow ? `:shadowx=${word.shadow.x}:shadowy=${word.shadow.y}:shadowcolor=${word.shadow.color}` : "";
260
+ const look = `y=${word.baseline}-ascent:borderw=${width}:bordercolor=${color}${shadow}`;
198
261
  const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
199
262
  if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
200
263
  const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
@@ -209,7 +272,7 @@ ${stderr.slice(-1500)}`);
209
272
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
210
273
  * and beeps. Returns the file name in `workDir`.
211
274
  */
212
- async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
275
+ async renderClip({ clipPath, start, end, tag, hook, fill = FILL_9_16, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
213
276
  const duration = end - start + hold;
214
277
  const held = subtitles.map((c) => ({
215
278
  start: c.start + hold,
@@ -219,7 +282,7 @@ ${stderr.slice(-1500)}`);
219
282
  const subs = this.drawSubtitles(held, workDir, name);
220
283
  const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
221
284
  const out = hook ? "[pre]" : "[v]";
222
- const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${FILL_9_16}`;
285
+ const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${fill}`;
223
286
  const sound = `[0:a]${loudness == null ? "" : `loudnorm=I=${loudness}:TP=-1.5:LRA=11,`}aresample=${SAMPLE_RATE}${hold ? `,adelay=${Math.round(hold * 1e3)}:all=1` : ""}`;
224
287
  let video = `${withSubs(input)}${out}`;
225
288
  if (tag) {
@@ -243,9 +306,14 @@ ${stderr.slice(-1500)}`);
243
306
  }
244
307
  if (hook) {
245
308
  const { band, lines, outSeconds } = hook;
246
- const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut: { start: duration - outSeconds, seconds: outSeconds } });
247
- const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds: outSeconds }, workDir, `${name}-hook`);
248
- video += `;${ink}[band];[pre][band]overlay=0:${band.y}:format=auto,${texts.join(",")}[v]`;
309
+ const fadeOut = outSeconds ? { start: duration - outSeconds, seconds: outSeconds } : null;
310
+ const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), fadeOut && { end: duration, seconds: fadeOut.seconds }, workDir, `${name}-hook`);
311
+ if (band) {
312
+ const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut });
313
+ video += `;${ink}[band];[pre][band]overlay=0:${band.y}:format=auto,${texts.join(",")}[v]`;
314
+ } else {
315
+ video += `;[pre]${texts.join(",") || "null"}[v]`;
316
+ }
249
317
  }
250
318
  let audio = `${sound}[a]`;
251
319
  if (mute.length) {
@@ -45,6 +45,22 @@ interface TextLine {
45
45
  rise?: number;
46
46
  /** [from, to) seconds the line is shown. */
47
47
  window?: [number, number];
48
+ /** A box behind the text, `border` px around it (or [vertical, horizontal]). */
49
+ box?: {
50
+ color: string;
51
+ border: number | [number, number];
52
+ };
53
+ }
54
+ /** Outline around subtitle words. */
55
+ interface TextBorder {
56
+ width: number;
57
+ color: string;
58
+ }
59
+ /** Shadow under subtitle words, offset by `x` and `y` px. */
60
+ interface TextShadow {
61
+ x: number;
62
+ y: number;
63
+ color: string;
48
64
  }
49
65
  /** Fades the text out over the last `seconds` before `end`. */
50
66
  interface FadeOut {
@@ -85,6 +101,9 @@ interface PlacedWord {
85
101
  end: number;
86
102
  color: string;
87
103
  };
104
+ /** Without it, a 3 px outline in the renderer's ink at 85%. */
105
+ border?: TextBorder;
106
+ shadow?: TextShadow;
88
107
  }
89
108
  /** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
90
109
  interface SubtitleChunk {
@@ -131,6 +150,18 @@ interface SubtitleOptions {
131
150
  hold?: number;
132
151
  /** @default TEXT_WIDTH */
133
152
  maxWidth?: number;
153
+ /**
154
+ * Words that end a piece, like sentence ends do. Pass `/[.,?!]["']?$/` to also break after
155
+ * commas.
156
+ *
157
+ * @default /[.?!]["']?$/
158
+ */
159
+ breakAfter?: RegExp;
160
+ /** A silence longer than this many seconds between two words also ends a piece. */
161
+ pause?: number;
162
+ /** @default a 3 px outline in the renderer's ink at 85% */
163
+ border?: TextBorder;
164
+ shadow?: TextShadow;
134
165
  }
135
166
  interface ClipOptions {
136
167
  clipPath: string;
@@ -157,16 +188,26 @@ interface ClipOptions {
157
188
  outSeconds?: number;
158
189
  fade?: boolean;
159
190
  };
160
- /** A full-width ink band with text, up from the first frame, fading out before the clip ends. */
191
+ /**
192
+ * Text up from the first frame, on a full-width ink band when `band` is given, fading out
193
+ * `outSeconds` before the clip ends. Without `outSeconds` it stays to the end.
194
+ */
161
195
  hook?: {
162
- band: {
196
+ band?: {
163
197
  y: number;
164
198
  h: number;
165
199
  opacity: number;
166
200
  };
167
201
  lines: TextLine[];
168
- outSeconds: number;
202
+ outSeconds?: number;
169
203
  };
204
+ /**
205
+ * Filter chain that turns the source picture into the 1080x1920 frame, for a layout of your
206
+ * own: it reads the source picture and outputs the frame.
207
+ *
208
+ * @default FILL_9_16
209
+ */
210
+ fill?: string;
170
211
  /**
171
212
  * Seconds the first frame is held, silent, before the clip plays. Subtitles and mute
172
213
  * windows move with it.
@@ -197,6 +238,18 @@ declare class Renderer {
197
238
  textWidth(text: string, font: string, size: number): number;
198
239
  /** Largest size up to `size` at which the text fits in `maxWidth`. */
199
240
  fitSize(text: string, font: string, size: number, maxWidth?: number): number;
241
+ /** Height of the font's capital letters at `size`, for placing text by its top. */
242
+ capHeight(font: string, size: number): number;
243
+ /** `text` without the characters `font` has no glyph for, such as emoji, with spaces collapsed. */
244
+ printable(text: string, font: string): string;
245
+ /**
246
+ * `text` wrapped into lines of at most `maxWidth`. With `balance`, the lines are as even as
247
+ * the fewest lines allow, instead of full lines and a short last one.
248
+ */
249
+ wrapText(text: string, font: string, size: number, { maxWidth, balance }?: {
250
+ maxWidth?: number | undefined;
251
+ balance?: boolean | undefined;
252
+ }): string[];
200
253
  /** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
201
254
  inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }: InkLayerOptions): string;
202
255
  /**
@@ -218,7 +271,7 @@ declare class Renderer {
218
271
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
219
272
  * and beeps. Returns the file name in `workDir`.
220
273
  */
221
- renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
274
+ renderClip({ clipPath, start, end, tag, hook, fill, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
222
275
  /**
223
276
  * 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
224
277
  * `start`/`end`, the whole file.
@@ -244,4 +297,4 @@ declare class Renderer {
244
297
  concat(segments: string[], workDir: string, outputPath: string): Promise<void>;
245
298
  }
246
299
 
247
- export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextLine, type TimedWord, WIDTH };
300
+ export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextBorder, type TextLine, type TextShadow, type TimedWord, WIDTH };
@@ -45,6 +45,22 @@ interface TextLine {
45
45
  rise?: number;
46
46
  /** [from, to) seconds the line is shown. */
47
47
  window?: [number, number];
48
+ /** A box behind the text, `border` px around it (or [vertical, horizontal]). */
49
+ box?: {
50
+ color: string;
51
+ border: number | [number, number];
52
+ };
53
+ }
54
+ /** Outline around subtitle words. */
55
+ interface TextBorder {
56
+ width: number;
57
+ color: string;
58
+ }
59
+ /** Shadow under subtitle words, offset by `x` and `y` px. */
60
+ interface TextShadow {
61
+ x: number;
62
+ y: number;
63
+ color: string;
48
64
  }
49
65
  /** Fades the text out over the last `seconds` before `end`. */
50
66
  interface FadeOut {
@@ -85,6 +101,9 @@ interface PlacedWord {
85
101
  end: number;
86
102
  color: string;
87
103
  };
104
+ /** Without it, a 3 px outline in the renderer's ink at 85%. */
105
+ border?: TextBorder;
106
+ shadow?: TextShadow;
88
107
  }
89
108
  /** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
90
109
  interface SubtitleChunk {
@@ -131,6 +150,18 @@ interface SubtitleOptions {
131
150
  hold?: number;
132
151
  /** @default TEXT_WIDTH */
133
152
  maxWidth?: number;
153
+ /**
154
+ * Words that end a piece, like sentence ends do. Pass `/[.,?!]["']?$/` to also break after
155
+ * commas.
156
+ *
157
+ * @default /[.?!]["']?$/
158
+ */
159
+ breakAfter?: RegExp;
160
+ /** A silence longer than this many seconds between two words also ends a piece. */
161
+ pause?: number;
162
+ /** @default a 3 px outline in the renderer's ink at 85% */
163
+ border?: TextBorder;
164
+ shadow?: TextShadow;
134
165
  }
135
166
  interface ClipOptions {
136
167
  clipPath: string;
@@ -157,16 +188,26 @@ interface ClipOptions {
157
188
  outSeconds?: number;
158
189
  fade?: boolean;
159
190
  };
160
- /** A full-width ink band with text, up from the first frame, fading out before the clip ends. */
191
+ /**
192
+ * Text up from the first frame, on a full-width ink band when `band` is given, fading out
193
+ * `outSeconds` before the clip ends. Without `outSeconds` it stays to the end.
194
+ */
161
195
  hook?: {
162
- band: {
196
+ band?: {
163
197
  y: number;
164
198
  h: number;
165
199
  opacity: number;
166
200
  };
167
201
  lines: TextLine[];
168
- outSeconds: number;
202
+ outSeconds?: number;
169
203
  };
204
+ /**
205
+ * Filter chain that turns the source picture into the 1080x1920 frame, for a layout of your
206
+ * own: it reads the source picture and outputs the frame.
207
+ *
208
+ * @default FILL_9_16
209
+ */
210
+ fill?: string;
170
211
  /**
171
212
  * Seconds the first frame is held, silent, before the clip plays. Subtitles and mute
172
213
  * windows move with it.
@@ -197,6 +238,18 @@ declare class Renderer {
197
238
  textWidth(text: string, font: string, size: number): number;
198
239
  /** Largest size up to `size` at which the text fits in `maxWidth`. */
199
240
  fitSize(text: string, font: string, size: number, maxWidth?: number): number;
241
+ /** Height of the font's capital letters at `size`, for placing text by its top. */
242
+ capHeight(font: string, size: number): number;
243
+ /** `text` without the characters `font` has no glyph for, such as emoji, with spaces collapsed. */
244
+ printable(text: string, font: string): string;
245
+ /**
246
+ * `text` wrapped into lines of at most `maxWidth`. With `balance`, the lines are as even as
247
+ * the fewest lines allow, instead of full lines and a short last one.
248
+ */
249
+ wrapText(text: string, font: string, size: number, { maxWidth, balance }?: {
250
+ maxWidth?: number | undefined;
251
+ balance?: boolean | undefined;
252
+ }): string[];
200
253
  /** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
201
254
  inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }: InkLayerOptions): string;
202
255
  /**
@@ -218,7 +271,7 @@ declare class Renderer {
218
271
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
219
272
  * and beeps. Returns the file name in `workDir`.
220
273
  */
221
- renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
274
+ renderClip({ clipPath, start, end, tag, hook, fill, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
222
275
  /**
223
276
  * 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
224
277
  * `start`/`end`, the whole file.
@@ -244,4 +297,4 @@ declare class Renderer {
244
297
  concat(segments: string[], workDir: string, outputPath: string): Promise<void>;
245
298
  }
246
299
 
247
- export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextLine, type TimedWord, WIDTH };
300
+ export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextBorder, type TextLine, type TextShadow, type TimedWord, WIDTH };
@@ -38,7 +38,7 @@ function defaultFfmpeg() {
38
38
  return "ffmpeg";
39
39
  }
40
40
  }
41
- var endsSentence = (text) => /[.?!]["']?$/.test(text);
41
+ var SENTENCE_END = /[.?!]["']?$/;
42
42
  var Renderer = class {
43
43
  ffmpegPath;
44
44
  ink;
@@ -92,6 +92,46 @@ ${stderr.slice(-1500)}`);
92
92
  const width = this.textWidth(text, font, size);
93
93
  return width <= maxWidth ? size : Math.floor(size * maxWidth / width);
94
94
  }
95
+ /** Height of the font's capital letters at `size`, for placing text by its top. */
96
+ capHeight(font, size) {
97
+ const f = this.font(font);
98
+ return (f.tables.os2?.sCapHeight ?? f.ascender) * size / f.unitsPerEm;
99
+ }
100
+ /** `text` without the characters `font` has no glyph for, such as emoji, with spaces collapsed. */
101
+ printable(text, font) {
102
+ const f = this.font(font);
103
+ return [...text].filter((ch) => /\s/.test(ch) || f.charToGlyphIndex(ch) > 0).join("").replace(/\s+/g, " ").trim();
104
+ }
105
+ /**
106
+ * `text` wrapped into lines of at most `maxWidth`. With `balance`, the lines are as even as
107
+ * the fewest lines allow, instead of full lines and a short last one.
108
+ */
109
+ wrapText(text, font, size, { maxWidth = TEXT_WIDTH, balance = false } = {}) {
110
+ const wrap = (width) => {
111
+ const lines2 = [];
112
+ let line = "";
113
+ for (const word of text.split(" ")) {
114
+ const next = line ? `${line} ${word}` : word;
115
+ if (!line || this.textWidth(next, font, size) <= width) line = next;
116
+ else {
117
+ lines2.push(line);
118
+ line = word;
119
+ }
120
+ }
121
+ if (line) lines2.push(line);
122
+ return lines2;
123
+ };
124
+ const lines = wrap(maxWidth);
125
+ if (!balance || lines.length < 2) return lines;
126
+ let lo = 0;
127
+ let hi = maxWidth;
128
+ while (hi - lo > 1) {
129
+ const mid = (lo + hi) / 2;
130
+ if (wrap(mid).length <= lines.length) hi = mid;
131
+ else lo = mid;
132
+ }
133
+ return wrap(hi);
134
+ }
95
135
  /** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
96
136
  inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }) {
97
137
  let src = `color=c=${this.ink}:s=${w}x${h}:r=${FPS}:d=${duration},format=rgba,colorchannelmixer=aa=${opacity}`;
@@ -106,7 +146,7 @@ ${stderr.slice(-1500)}`);
106
146
  */
107
147
  drawTexts(lines, out, workDir, name) {
108
148
  return lines.map((line, i) => {
109
- const { x = MARGIN, baseline, delay = 0, rise = TEXT_RISE, window } = line;
149
+ const { x = MARGIN, baseline, delay = 0, rise = TEXT_RISE, window, box } = line;
110
150
  const p = `clip((t-${delay})/${TEXT_IN_SECONDS},0,1)`;
111
151
  const eased = `(${p}*(2-${p}))`;
112
152
  const alpha = out ? `${eased}*clip((${out.end}-t)/${out.seconds},0,1)` : eased;
@@ -116,7 +156,7 @@ ${stderr.slice(-1500)}`);
116
156
  `${name}-line${i}.txt`,
117
157
  { ...line, x },
118
158
  `y='${y}':alpha='${alpha}'` + // Half-open, so lines with back-to-back windows never share a frame.
119
- (window ? `:enable='gte(t,${window[0]})*lt(t,${window[1]})'` : "")
159
+ (window ? `:enable='gte(t,${window[0]})*lt(t,${window[1]})'` : "") + (box ? `:box=1:boxcolor=${box.color}:boxborderw=${[box.border].flat().join("|")}` : "")
120
160
  );
121
161
  });
122
162
  }
@@ -131,15 +171,36 @@ ${stderr.slice(-1500)}`);
131
171
  * further while it is wider than `maxWidth`.
132
172
  */
133
173
  subtitleChunks(options) {
134
- const { words, duration, font, color, style, activeColor, size = 56, baseline = 1445, maxWords = 5, hold = 0.6, maxWidth = TEXT_WIDTH } = options;
174
+ const {
175
+ words,
176
+ duration,
177
+ font,
178
+ color,
179
+ style,
180
+ activeColor,
181
+ size = 56,
182
+ baseline = 1445,
183
+ maxWords = 5,
184
+ hold = 0.6,
185
+ maxWidth = TEXT_WIDTH,
186
+ breakAfter = SENTENCE_END,
187
+ pause,
188
+ border,
189
+ shadow
190
+ } = options;
135
191
  const styled = words.map((w) => ({ ...w, font, color, ...style?.(w) }));
136
192
  const space = this.textWidth(" ", font, size);
137
193
  const width = (ws) => ws.reduce((sum, w) => sum + this.textWidth(w.text, w.font, size), 0) + (ws.length - 1) * space;
138
194
  const sentences = [];
139
195
  let current = [];
140
196
  for (const word of styled) {
197
+ const prev = current[current.length - 1];
198
+ if (prev && pause != null && word.start - prev.end > pause) {
199
+ sentences.push(current);
200
+ current = [];
201
+ }
141
202
  current.push(word);
142
- if (endsSentence(word.text)) {
203
+ if (breakAfter.test(word.text)) {
143
204
  sentences.push(current);
144
205
  current = [];
145
206
  }
@@ -172,7 +233,7 @@ ${stderr.slice(-1500)}`);
172
233
  const lineEnd = Math.max(end, first.start + 0.1);
173
234
  let x = (WIDTH - width(group)) / 2;
174
235
  const placed = group.map((w, j) => {
175
- const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
236
+ const word = { text: w.text, font: w.font, color: w.color, size, baseline, x, ...border ? { border } : {}, ...shadow ? { shadow } : {} };
176
237
  const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
177
238
  if (spokenColor) {
178
239
  const from = Math.max(start, w.start);
@@ -190,7 +251,9 @@ ${stderr.slice(-1500)}`);
190
251
  return chunks.flatMap(
191
252
  (chunk, c) => chunk.words.flatMap((word, w) => {
192
253
  const file = `${name}-sub${c}-${w}.txt`;
193
- const look = `y=${word.baseline}-ascent:borderw=3:bordercolor=${this.ink}@0.85`;
254
+ const { width, color } = word.border ?? { width: 3, color: `${this.ink}@0.85` };
255
+ const shadow = word.shadow ? `:shadowx=${word.shadow.x}:shadowy=${word.shadow.y}:shadowcolor=${word.shadow.color}` : "";
256
+ const look = `y=${word.baseline}-ascent:borderw=${width}:bordercolor=${color}${shadow}`;
194
257
  const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
195
258
  if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
196
259
  const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
@@ -205,7 +268,7 @@ ${stderr.slice(-1500)}`);
205
268
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
206
269
  * and beeps. Returns the file name in `workDir`.
207
270
  */
208
- async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
271
+ async renderClip({ clipPath, start, end, tag, hook, fill = FILL_9_16, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
209
272
  const duration = end - start + hold;
210
273
  const held = subtitles.map((c) => ({
211
274
  start: c.start + hold,
@@ -215,7 +278,7 @@ ${stderr.slice(-1500)}`);
215
278
  const subs = this.drawSubtitles(held, workDir, name);
216
279
  const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
217
280
  const out = hook ? "[pre]" : "[v]";
218
- const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${FILL_9_16}`;
281
+ const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${fill}`;
219
282
  const sound = `[0:a]${loudness == null ? "" : `loudnorm=I=${loudness}:TP=-1.5:LRA=11,`}aresample=${SAMPLE_RATE}${hold ? `,adelay=${Math.round(hold * 1e3)}:all=1` : ""}`;
220
283
  let video = `${withSubs(input)}${out}`;
221
284
  if (tag) {
@@ -239,9 +302,14 @@ ${stderr.slice(-1500)}`);
239
302
  }
240
303
  if (hook) {
241
304
  const { band, lines, outSeconds } = hook;
242
- const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut: { start: duration - outSeconds, seconds: outSeconds } });
243
- const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds: outSeconds }, workDir, `${name}-hook`);
244
- video += `;${ink}[band];[pre][band]overlay=0:${band.y}:format=auto,${texts.join(",")}[v]`;
305
+ const fadeOut = outSeconds ? { start: duration - outSeconds, seconds: outSeconds } : null;
306
+ const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), fadeOut && { end: duration, seconds: fadeOut.seconds }, workDir, `${name}-hook`);
307
+ if (band) {
308
+ const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut });
309
+ video += `;${ink}[band];[pre][band]overlay=0:${band.y}:format=auto,${texts.join(",")}[v]`;
310
+ } else {
311
+ video += `;[pre]${texts.join(",") || "null"}[v]`;
312
+ }
245
313
  }
246
314
  let audio = `${sound}[a]`;
247
315
  if (mute.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keepitreel",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Building blocks for automated short-form video: Whisper transcription, ffmpeg reel rendering, and publishing to YouTube Shorts, Facebook, Instagram and TikTok.",
5
5
  "type": "module",
6
6
  "exports": {