keepitreel 0.1.0 → 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
@@ -196,31 +196,38 @@ Renders `start` to `end` of a clip as a 9:16 segment: the landscape picture sits
196
196
  |---|---|---|---|
197
197
  | `clipPath` | `string` | required | The source video |
198
198
  | `start`, `end` | `number` | required | Seconds into the clip |
199
- | `workDir` | `string` | required | Where the output and the text files ffmpeg reads go |
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 |
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 |
206
208
 
207
209
  `lines` are [text lines](#text-lines).
208
210
 
209
211
  #### .subtitleChunks(options)
210
212
 
211
- Splits clip-relative words into one-line, centered chunks, breaking at sentence ends. A sentence longer than `maxWords` is cut into the fewest pieces that fit, differing by at most one word ("I heard you / were in town."): seven words become 4 + 3, and twenty-one become 5 + 4 + 4 + 4 + 4 rather than leaving one word over. A piece is split further while it is wider than `maxWidth`, so a word too wide to share a line gets one to itself. Returns an array of `{ start, end, words }`, where each word is placed with its `x`, `font`, `color` and `size`.
213
+ Splits clip-relative words into one-line, centered chunks, breaking at sentence ends. A sentence longer than `maxWords` is cut into the fewest pieces that fit, differing by at most one word ("I heard you / were in town."): seven words become 4 + 3, and twenty-one become 5 + 4 + 4 + 4 + 4 rather than leaving one word over. A piece is split further while it is wider than `maxWidth`, so a word too wide to share a line gets one to itself. Returns an array of `{ start, end, words }`, where each word is placed with its `x`, `font`, `color` and `size`, plus `active: { start, end, color }` when `activeColor` is given.
212
214
 
213
215
  | Option | Type | Default | |
214
216
  |---|---|---|---|
215
217
  | `words` | `{ text, start, end }[]` | required | Clip-relative seconds |
216
218
  | `duration` | `number` | required | Length of the clip. No chunk stays up past it |
217
219
  | `font`, `color` | `string` | required | A font key and an ffmpeg color |
218
- | `style` | `(word) => { font?, color?, text? }` | | Per-word font, color or text, for a highlighted or blanked-out word |
220
+ | `style` | `(word) => { font?, color?, text?, activeColor? }` | | Per-word font, color or text, for a highlighted or blanked-out word. Its `activeColor` overrides the option for that word, and `null` keeps the word's own color while it is spoken |
221
+ | `activeColor` | `string` | | Karaoke style: each word takes this color while it is spoken, from its start to the next word's start (the line's end for the last word), so Whisper's early end times leave no gap. Without it words keep their color the whole line |
219
222
  | `size` | `number` | `56` | |
220
223
  | `baseline` | `number` | `1445` | Below the picture of a 4:3 film and above the apps' bottom UI zone |
221
224
  | `maxWords` | `number` | `5` | |
222
225
  | `hold` | `number` | `0.6` | Seconds a chunk stays up after its last word when the next one is not right after |
223
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 |
224
231
 
225
232
  #### .concat(segments, workDir, outputPath)
226
233
 
@@ -250,6 +257,18 @@ Returns the width in pixels of `text` in the font with key `font` at `size`.
250
257
 
251
258
  Returns the largest size up to `size` at which `text` fits in `maxWidth` (default `TEXT_WIDTH`).
252
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
+
253
272
  #### .drawTexts(lines, out, workDir, name)
254
273
 
255
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.
@@ -280,6 +299,7 @@ The `lines` of a hook or tag, and the input of `.drawTexts()`:
280
299
  | `delay` | `number` | `0` | Seconds before the line fades in. `-0.25` has it fully in at the first frame |
281
300
  | `rise` | `number` | `18` | Pixels the line rises while it fades in |
282
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]` |
283
303
 
284
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.
285
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, 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
  }
@@ -172,39 +233,57 @@ ${stderr.slice(-1500)}`);
172
233
  const next = groups[i + 1]?.[0];
173
234
  const holdEnd = Math.min(duration, last.end + hold);
174
235
  const end = next ? Math.min(next.start, holdEnd) : holdEnd;
236
+ const start = Math.max(0, first.start);
237
+ const lineEnd = Math.max(end, first.start + 0.1);
175
238
  let x = (WIDTH - width(group)) / 2;
176
- const placed = group.map((w) => {
177
- const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
239
+ const placed = group.map((w, j) => {
240
+ const word = { text: w.text, font: w.font, color: w.color, size, baseline, x, ...border ? { border } : {}, ...shadow ? { shadow } : {} };
241
+ const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
242
+ if (spokenColor) {
243
+ const from = Math.max(start, w.start);
244
+ const to = j < group.length - 1 ? group[j + 1].start : lineEnd;
245
+ word.active = { start: from, end: Math.max(from, to), color: spokenColor };
246
+ }
178
247
  x += this.textWidth(w.text, w.font, size) + space;
179
248
  return word;
180
249
  });
181
- return { start: Math.max(0, first.start), end: Math.max(end, first.start + 0.1), words: placed };
250
+ return { start, end: lineEnd, words: placed };
182
251
  });
183
252
  }
184
253
  // Each word is its own drawtext so a word can take another color.
185
254
  drawSubtitles(chunks, workDir, name) {
186
255
  return chunks.flatMap(
187
- (chunk, c) => chunk.words.map(
188
- (word, w) => this.drawtext(
189
- workDir,
190
- `${name}-sub${c}-${w}.txt`,
191
- word,
192
- `y=${word.baseline}-ascent:borderw=3:bordercolor=${this.ink}@0.85:enable='between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})'`
193
- )
194
- )
256
+ (chunk, c) => chunk.words.flatMap((word, w) => {
257
+ const file = `${name}-sub${c}-${w}.txt`;
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}`;
261
+ const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
262
+ if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
263
+ const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
264
+ return [
265
+ this.drawtext(workDir, file, word, `${look}:enable='${shown}*not(${spoken})'`),
266
+ this.drawtext(workDir, file, { ...word, color: word.active.color }, `${look}:enable='${spoken}'`)
267
+ ];
268
+ })
195
269
  );
196
270
  }
197
271
  /**
198
272
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
199
273
  * and beeps. Returns the file name in `workDir`.
200
274
  */
201
- async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], workDir, name }) {
275
+ async renderClip({ clipPath, start, end, tag, hook, fill = FILL_9_16, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
202
276
  const duration = end - start + hold;
203
- const subs = this.drawSubtitles(subtitles.map((c) => ({ ...c, start: c.start + hold, end: c.end + hold })), workDir, name);
277
+ const held = subtitles.map((c) => ({
278
+ start: c.start + hold,
279
+ end: c.end + hold,
280
+ words: c.words.map((w) => w.active ? { ...w, active: { ...w.active, start: w.active.start + hold, end: w.active.end + hold } } : w)
281
+ }));
282
+ const subs = this.drawSubtitles(held, workDir, name);
204
283
  const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
205
284
  const out = hook ? "[pre]" : "[v]";
206
- const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${FILL_9_16}`;
207
- const sound = `[0:a]aresample=${SAMPLE_RATE}${hold ? `,adelay=${Math.round(hold * 1e3)}:all=1` : ""}`;
285
+ const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${fill}`;
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` : ""}`;
208
287
  let video = `${withSubs(input)}${out}`;
209
288
  if (tag) {
210
289
  const { box, lines, delay = 0, outSeconds = 0, fade = true } = tag;
@@ -227,18 +306,25 @@ ${stderr.slice(-1500)}`);
227
306
  }
228
307
  if (hook) {
229
308
  const { band, lines, outSeconds } = hook;
230
- const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut: { start: duration - outSeconds, seconds: outSeconds } });
231
- const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds: outSeconds }, workDir, `${name}-hook`);
232
- 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
+ }
233
317
  }
234
318
  let audio = `${sound}[a]`;
235
319
  if (mute.length) {
236
320
  const muted = `gt(${mute.map(([a, b]) => `between(t,${(a + hold).toFixed(3)},${(b + hold).toFixed(3)})`).join("+")},0)`;
237
321
  audio = `${sound},aformat=channel_layouts=stereo,volume=volume=0:enable='${muted}'[dry];aevalsrc='${BEEP_LEVEL}*sin(2*PI*${BEEP_HZ}*t)*${muted}':c=stereo:s=${SAMPLE_RATE}:d=${duration.toFixed(3)}[beep];[dry][beep]amix=inputs=2:normalize=0:duration=first[a]`;
238
322
  }
323
+ const graph = `${name}.filter`;
324
+ fs.writeFileSync(path.join(workDir, graph), `${video};${audio}`);
239
325
  const output = `${name}.mp4`;
240
326
  await this.run(
241
- ["-ss", String(start), "-to", String(end), "-i", path.resolve(clipPath), "-filter_complex", `${video};${audio}`, "-map", "[v]", "-map", "[a]", ...OUTPUT_ARGS, output],
327
+ ["-ss", String(start), "-to", String(end), "-i", path.resolve(clipPath), "-filter_complex_script", graph, "-map", "[v]", "-map", "[a]", ...OUTPUT_ARGS, output],
242
328
  workDir
243
329
  );
244
330
  return output;
@@ -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 {
@@ -79,6 +95,15 @@ interface PlacedWord {
79
95
  size: number;
80
96
  baseline: number;
81
97
  x: number;
98
+ /** While the word is spoken (clip-relative seconds), it is drawn in `color` instead. */
99
+ active?: {
100
+ start: number;
101
+ end: number;
102
+ color: string;
103
+ };
104
+ /** Without it, a 3 px outline in the renderer's ink at 85%. */
105
+ border?: TextBorder;
106
+ shadow?: TextShadow;
82
107
  }
83
108
  /** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
84
109
  interface SubtitleChunk {
@@ -92,12 +117,21 @@ interface SubtitleOptions {
92
117
  duration: number;
93
118
  font: string;
94
119
  color: string;
95
- /** Per-word font, color or text, such as a highlighted or blanked-out word. */
120
+ /**
121
+ * Per-word font, color or text, such as a highlighted or blanked-out word, and its own
122
+ * `activeColor` (`null` keeps the word's color while it is spoken).
123
+ */
96
124
  style?: (word: TimedWord) => {
97
125
  font?: string;
98
126
  color?: string;
99
127
  text?: string;
128
+ activeColor?: string | null;
100
129
  };
130
+ /**
131
+ * Color of each word while it is spoken, karaoke style: from its start to the next word's
132
+ * start, so early end times leave no gap, and to the line's end for its last word.
133
+ */
134
+ activeColor?: string;
101
135
  /** @default 56 */
102
136
  size?: number;
103
137
  /**
@@ -116,6 +150,18 @@ interface SubtitleOptions {
116
150
  hold?: number;
117
151
  /** @default TEXT_WIDTH */
118
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;
119
165
  }
120
166
  interface ClipOptions {
121
167
  clipPath: string;
@@ -142,16 +188,26 @@ interface ClipOptions {
142
188
  outSeconds?: number;
143
189
  fade?: boolean;
144
190
  };
145
- /** 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
+ */
146
195
  hook?: {
147
- band: {
196
+ band?: {
148
197
  y: number;
149
198
  h: number;
150
199
  opacity: number;
151
200
  };
152
201
  lines: TextLine[];
153
- outSeconds: number;
202
+ outSeconds?: number;
154
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;
155
211
  /**
156
212
  * Seconds the first frame is held, silent, before the clip plays. Subtitles and mute
157
213
  * windows move with it.
@@ -160,6 +216,11 @@ interface ClipOptions {
160
216
  subtitles?: SubtitleChunk[];
161
217
  /** [start, end] windows (clip-relative seconds) where the sound is replaced by a beep. */
162
218
  mute?: [number, number][];
219
+ /**
220
+ * Target integrated loudness in LUFS (e.g. -14). The clip's sound is normalized to it
221
+ * before any beep is mixed in, so beeps stay at the same level in every clip.
222
+ */
223
+ loudness?: number;
163
224
  }
164
225
  /** Renders 1080x1920 reel segments with ffmpeg and joins them. */
165
226
  declare class Renderer {
@@ -177,6 +238,18 @@ declare class Renderer {
177
238
  textWidth(text: string, font: string, size: number): number;
178
239
  /** Largest size up to `size` at which the text fits in `maxWidth`. */
179
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[];
180
253
  /** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
181
254
  inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }: InkLayerOptions): string;
182
255
  /**
@@ -198,7 +271,7 @@ declare class Renderer {
198
271
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
199
272
  * and beeps. Returns the file name in `workDir`.
200
273
  */
201
- renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, workDir, name }: ClipOptions): Promise<string>;
274
+ renderClip({ clipPath, start, end, tag, hook, fill, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
202
275
  /**
203
276
  * 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
204
277
  * `start`/`end`, the whole file.
@@ -224,4 +297,4 @@ declare class Renderer {
224
297
  concat(segments: string[], workDir: string, outputPath: string): Promise<void>;
225
298
  }
226
299
 
227
- 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 {
@@ -79,6 +95,15 @@ interface PlacedWord {
79
95
  size: number;
80
96
  baseline: number;
81
97
  x: number;
98
+ /** While the word is spoken (clip-relative seconds), it is drawn in `color` instead. */
99
+ active?: {
100
+ start: number;
101
+ end: number;
102
+ color: string;
103
+ };
104
+ /** Without it, a 3 px outline in the renderer's ink at 85%. */
105
+ border?: TextBorder;
106
+ shadow?: TextShadow;
82
107
  }
83
108
  /** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
84
109
  interface SubtitleChunk {
@@ -92,12 +117,21 @@ interface SubtitleOptions {
92
117
  duration: number;
93
118
  font: string;
94
119
  color: string;
95
- /** Per-word font, color or text, such as a highlighted or blanked-out word. */
120
+ /**
121
+ * Per-word font, color or text, such as a highlighted or blanked-out word, and its own
122
+ * `activeColor` (`null` keeps the word's color while it is spoken).
123
+ */
96
124
  style?: (word: TimedWord) => {
97
125
  font?: string;
98
126
  color?: string;
99
127
  text?: string;
128
+ activeColor?: string | null;
100
129
  };
130
+ /**
131
+ * Color of each word while it is spoken, karaoke style: from its start to the next word's
132
+ * start, so early end times leave no gap, and to the line's end for its last word.
133
+ */
134
+ activeColor?: string;
101
135
  /** @default 56 */
102
136
  size?: number;
103
137
  /**
@@ -116,6 +150,18 @@ interface SubtitleOptions {
116
150
  hold?: number;
117
151
  /** @default TEXT_WIDTH */
118
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;
119
165
  }
120
166
  interface ClipOptions {
121
167
  clipPath: string;
@@ -142,16 +188,26 @@ interface ClipOptions {
142
188
  outSeconds?: number;
143
189
  fade?: boolean;
144
190
  };
145
- /** 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
+ */
146
195
  hook?: {
147
- band: {
196
+ band?: {
148
197
  y: number;
149
198
  h: number;
150
199
  opacity: number;
151
200
  };
152
201
  lines: TextLine[];
153
- outSeconds: number;
202
+ outSeconds?: number;
154
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;
155
211
  /**
156
212
  * Seconds the first frame is held, silent, before the clip plays. Subtitles and mute
157
213
  * windows move with it.
@@ -160,6 +216,11 @@ interface ClipOptions {
160
216
  subtitles?: SubtitleChunk[];
161
217
  /** [start, end] windows (clip-relative seconds) where the sound is replaced by a beep. */
162
218
  mute?: [number, number][];
219
+ /**
220
+ * Target integrated loudness in LUFS (e.g. -14). The clip's sound is normalized to it
221
+ * before any beep is mixed in, so beeps stay at the same level in every clip.
222
+ */
223
+ loudness?: number;
163
224
  }
164
225
  /** Renders 1080x1920 reel segments with ffmpeg and joins them. */
165
226
  declare class Renderer {
@@ -177,6 +238,18 @@ declare class Renderer {
177
238
  textWidth(text: string, font: string, size: number): number;
178
239
  /** Largest size up to `size` at which the text fits in `maxWidth`. */
179
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[];
180
253
  /** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
181
254
  inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }: InkLayerOptions): string;
182
255
  /**
@@ -198,7 +271,7 @@ declare class Renderer {
198
271
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
199
272
  * and beeps. Returns the file name in `workDir`.
200
273
  */
201
- renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, workDir, name }: ClipOptions): Promise<string>;
274
+ renderClip({ clipPath, start, end, tag, hook, fill, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
202
275
  /**
203
276
  * 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
204
277
  * `start`/`end`, the whole file.
@@ -224,4 +297,4 @@ declare class Renderer {
224
297
  concat(segments: string[], workDir: string, outputPath: string): Promise<void>;
225
298
  }
226
299
 
227
- 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, 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
  }
@@ -168,39 +229,57 @@ ${stderr.slice(-1500)}`);
168
229
  const next = groups[i + 1]?.[0];
169
230
  const holdEnd = Math.min(duration, last.end + hold);
170
231
  const end = next ? Math.min(next.start, holdEnd) : holdEnd;
232
+ const start = Math.max(0, first.start);
233
+ const lineEnd = Math.max(end, first.start + 0.1);
171
234
  let x = (WIDTH - width(group)) / 2;
172
- const placed = group.map((w) => {
173
- const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
235
+ const placed = group.map((w, j) => {
236
+ const word = { text: w.text, font: w.font, color: w.color, size, baseline, x, ...border ? { border } : {}, ...shadow ? { shadow } : {} };
237
+ const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
238
+ if (spokenColor) {
239
+ const from = Math.max(start, w.start);
240
+ const to = j < group.length - 1 ? group[j + 1].start : lineEnd;
241
+ word.active = { start: from, end: Math.max(from, to), color: spokenColor };
242
+ }
174
243
  x += this.textWidth(w.text, w.font, size) + space;
175
244
  return word;
176
245
  });
177
- return { start: Math.max(0, first.start), end: Math.max(end, first.start + 0.1), words: placed };
246
+ return { start, end: lineEnd, words: placed };
178
247
  });
179
248
  }
180
249
  // Each word is its own drawtext so a word can take another color.
181
250
  drawSubtitles(chunks, workDir, name) {
182
251
  return chunks.flatMap(
183
- (chunk, c) => chunk.words.map(
184
- (word, w) => this.drawtext(
185
- workDir,
186
- `${name}-sub${c}-${w}.txt`,
187
- word,
188
- `y=${word.baseline}-ascent:borderw=3:bordercolor=${this.ink}@0.85:enable='between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})'`
189
- )
190
- )
252
+ (chunk, c) => chunk.words.flatMap((word, w) => {
253
+ const file = `${name}-sub${c}-${w}.txt`;
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}`;
257
+ const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
258
+ if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
259
+ const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
260
+ return [
261
+ this.drawtext(workDir, file, word, `${look}:enable='${shown}*not(${spoken})'`),
262
+ this.drawtext(workDir, file, { ...word, color: word.active.color }, `${look}:enable='${spoken}'`)
263
+ ];
264
+ })
191
265
  );
192
266
  }
193
267
  /**
194
268
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
195
269
  * and beeps. Returns the file name in `workDir`.
196
270
  */
197
- async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], workDir, name }) {
271
+ async renderClip({ clipPath, start, end, tag, hook, fill = FILL_9_16, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
198
272
  const duration = end - start + hold;
199
- const subs = this.drawSubtitles(subtitles.map((c) => ({ ...c, start: c.start + hold, end: c.end + hold })), workDir, name);
273
+ const held = subtitles.map((c) => ({
274
+ start: c.start + hold,
275
+ end: c.end + hold,
276
+ words: c.words.map((w) => w.active ? { ...w, active: { ...w.active, start: w.active.start + hold, end: w.active.end + hold } } : w)
277
+ }));
278
+ const subs = this.drawSubtitles(held, workDir, name);
200
279
  const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
201
280
  const out = hook ? "[pre]" : "[v]";
202
- const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${FILL_9_16}`;
203
- const sound = `[0:a]aresample=${SAMPLE_RATE}${hold ? `,adelay=${Math.round(hold * 1e3)}:all=1` : ""}`;
281
+ const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${fill}`;
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` : ""}`;
204
283
  let video = `${withSubs(input)}${out}`;
205
284
  if (tag) {
206
285
  const { box, lines, delay = 0, outSeconds = 0, fade = true } = tag;
@@ -223,18 +302,25 @@ ${stderr.slice(-1500)}`);
223
302
  }
224
303
  if (hook) {
225
304
  const { band, lines, outSeconds } = hook;
226
- const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut: { start: duration - outSeconds, seconds: outSeconds } });
227
- const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds: outSeconds }, workDir, `${name}-hook`);
228
- 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
+ }
229
313
  }
230
314
  let audio = `${sound}[a]`;
231
315
  if (mute.length) {
232
316
  const muted = `gt(${mute.map(([a, b]) => `between(t,${(a + hold).toFixed(3)},${(b + hold).toFixed(3)})`).join("+")},0)`;
233
317
  audio = `${sound},aformat=channel_layouts=stereo,volume=volume=0:enable='${muted}'[dry];aevalsrc='${BEEP_LEVEL}*sin(2*PI*${BEEP_HZ}*t)*${muted}':c=stereo:s=${SAMPLE_RATE}:d=${duration.toFixed(3)}[beep];[dry][beep]amix=inputs=2:normalize=0:duration=first[a]`;
234
318
  }
319
+ const graph = `${name}.filter`;
320
+ writeFileSync(join(workDir, graph), `${video};${audio}`);
235
321
  const output = `${name}.mp4`;
236
322
  await this.run(
237
- ["-ss", String(start), "-to", String(end), "-i", resolve(clipPath), "-filter_complex", `${video};${audio}`, "-map", "[v]", "-map", "[a]", ...OUTPUT_ARGS, output],
323
+ ["-ss", String(start), "-to", String(end), "-i", resolve(clipPath), "-filter_complex_script", graph, "-map", "[v]", "-map", "[a]", ...OUTPUT_ARGS, output],
238
324
  workDir
239
325
  );
240
326
  return output;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keepitreel",
3
- "version": "0.1.0",
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": {