keepitreel 0.1.0 → 0.1.1

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,26 +196,28 @@ 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
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 |
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
204
  | `hold` | `number` | `0` | Seconds the first frame is held, silent, before the clip plays. Subtitles and mute windows move with it |
205
205
  | `mute` | `[number, number][]` | `[]` | Clip-relative windows where the sound is replaced by a beep |
206
+ | `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
207
 
207
208
  `lines` are [text lines](#text-lines).
208
209
 
209
210
  #### .subtitleChunks(options)
210
211
 
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`.
212
+ 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
213
 
213
214
  | Option | Type | Default | |
214
215
  |---|---|---|---|
215
216
  | `words` | `{ text, start, end }[]` | required | Clip-relative seconds |
216
217
  | `duration` | `number` | required | Length of the clip. No chunk stays up past it |
217
218
  | `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 |
219
+ | `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 |
220
+ | `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
221
  | `size` | `number` | `56` | |
220
222
  | `baseline` | `number` | `1445` | Below the picture of a 4:3 film and above the apps' bottom UI zone |
221
223
  | `maxWords` | `number` | `5` | |
@@ -135,7 +135,7 @@ ${stderr.slice(-1500)}`);
135
135
  * further while it is wider than `maxWidth`.
136
136
  */
137
137
  subtitleChunks(options) {
138
- const { words, duration, font, color, style, size = 56, baseline = 1445, maxWords = 5, hold = 0.6, maxWidth = TEXT_WIDTH } = options;
138
+ const { words, duration, font, color, style, activeColor, size = 56, baseline = 1445, maxWords = 5, hold = 0.6, maxWidth = TEXT_WIDTH } = options;
139
139
  const styled = words.map((w) => ({ ...w, font, color, ...style?.(w) }));
140
140
  const space = this.textWidth(" ", font, size);
141
141
  const width = (ws) => ws.reduce((sum, w) => sum + this.textWidth(w.text, w.font, size), 0) + (ws.length - 1) * space;
@@ -172,39 +172,55 @@ ${stderr.slice(-1500)}`);
172
172
  const next = groups[i + 1]?.[0];
173
173
  const holdEnd = Math.min(duration, last.end + hold);
174
174
  const end = next ? Math.min(next.start, holdEnd) : holdEnd;
175
+ const start = Math.max(0, first.start);
176
+ const lineEnd = Math.max(end, first.start + 0.1);
175
177
  let x = (WIDTH - width(group)) / 2;
176
- const placed = group.map((w) => {
178
+ const placed = group.map((w, j) => {
177
179
  const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
180
+ const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
181
+ if (spokenColor) {
182
+ const from = Math.max(start, w.start);
183
+ const to = j < group.length - 1 ? group[j + 1].start : lineEnd;
184
+ word.active = { start: from, end: Math.max(from, to), color: spokenColor };
185
+ }
178
186
  x += this.textWidth(w.text, w.font, size) + space;
179
187
  return word;
180
188
  });
181
- return { start: Math.max(0, first.start), end: Math.max(end, first.start + 0.1), words: placed };
189
+ return { start, end: lineEnd, words: placed };
182
190
  });
183
191
  }
184
192
  // Each word is its own drawtext so a word can take another color.
185
193
  drawSubtitles(chunks, workDir, name) {
186
194
  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
- )
195
+ (chunk, c) => chunk.words.flatMap((word, w) => {
196
+ const file = `${name}-sub${c}-${w}.txt`;
197
+ const look = `y=${word.baseline}-ascent:borderw=3:bordercolor=${this.ink}@0.85`;
198
+ const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
199
+ if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
200
+ const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
201
+ return [
202
+ this.drawtext(workDir, file, word, `${look}:enable='${shown}*not(${spoken})'`),
203
+ this.drawtext(workDir, file, { ...word, color: word.active.color }, `${look}:enable='${spoken}'`)
204
+ ];
205
+ })
195
206
  );
196
207
  }
197
208
  /**
198
209
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
199
210
  * and beeps. Returns the file name in `workDir`.
200
211
  */
201
- async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], workDir, name }) {
212
+ async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
202
213
  const duration = end - start + hold;
203
- const subs = this.drawSubtitles(subtitles.map((c) => ({ ...c, start: c.start + hold, end: c.end + hold })), workDir, name);
214
+ const held = subtitles.map((c) => ({
215
+ start: c.start + hold,
216
+ end: c.end + hold,
217
+ words: c.words.map((w) => w.active ? { ...w, active: { ...w.active, start: w.active.start + hold, end: w.active.end + hold } } : w)
218
+ }));
219
+ const subs = this.drawSubtitles(held, workDir, name);
204
220
  const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
205
221
  const out = hook ? "[pre]" : "[v]";
206
222
  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` : ""}`;
223
+ 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
224
  let video = `${withSubs(input)}${out}`;
209
225
  if (tag) {
210
226
  const { box, lines, delay = 0, outSeconds = 0, fade = true } = tag;
@@ -236,9 +252,11 @@ ${stderr.slice(-1500)}`);
236
252
  const muted = `gt(${mute.map(([a, b]) => `between(t,${(a + hold).toFixed(3)},${(b + hold).toFixed(3)})`).join("+")},0)`;
237
253
  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
254
  }
255
+ const graph = `${name}.filter`;
256
+ fs.writeFileSync(path.join(workDir, graph), `${video};${audio}`);
239
257
  const output = `${name}.mp4`;
240
258
  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],
259
+ ["-ss", String(start), "-to", String(end), "-i", path.resolve(clipPath), "-filter_complex_script", graph, "-map", "[v]", "-map", "[a]", ...OUTPUT_ARGS, output],
242
260
  workDir
243
261
  );
244
262
  return output;
@@ -79,6 +79,12 @@ interface PlacedWord {
79
79
  size: number;
80
80
  baseline: number;
81
81
  x: number;
82
+ /** While the word is spoken (clip-relative seconds), it is drawn in `color` instead. */
83
+ active?: {
84
+ start: number;
85
+ end: number;
86
+ color: string;
87
+ };
82
88
  }
83
89
  /** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
84
90
  interface SubtitleChunk {
@@ -92,12 +98,21 @@ interface SubtitleOptions {
92
98
  duration: number;
93
99
  font: string;
94
100
  color: string;
95
- /** Per-word font, color or text, such as a highlighted or blanked-out word. */
101
+ /**
102
+ * Per-word font, color or text, such as a highlighted or blanked-out word, and its own
103
+ * `activeColor` (`null` keeps the word's color while it is spoken).
104
+ */
96
105
  style?: (word: TimedWord) => {
97
106
  font?: string;
98
107
  color?: string;
99
108
  text?: string;
109
+ activeColor?: string | null;
100
110
  };
111
+ /**
112
+ * Color of each word while it is spoken, karaoke style: from its start to the next word's
113
+ * start, so early end times leave no gap, and to the line's end for its last word.
114
+ */
115
+ activeColor?: string;
101
116
  /** @default 56 */
102
117
  size?: number;
103
118
  /**
@@ -160,6 +175,11 @@ interface ClipOptions {
160
175
  subtitles?: SubtitleChunk[];
161
176
  /** [start, end] windows (clip-relative seconds) where the sound is replaced by a beep. */
162
177
  mute?: [number, number][];
178
+ /**
179
+ * Target integrated loudness in LUFS (e.g. -14). The clip's sound is normalized to it
180
+ * before any beep is mixed in, so beeps stay at the same level in every clip.
181
+ */
182
+ loudness?: number;
163
183
  }
164
184
  /** Renders 1080x1920 reel segments with ffmpeg and joins them. */
165
185
  declare class Renderer {
@@ -198,7 +218,7 @@ declare class Renderer {
198
218
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
199
219
  * and beeps. Returns the file name in `workDir`.
200
220
  */
201
- renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, workDir, name }: ClipOptions): Promise<string>;
221
+ renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
202
222
  /**
203
223
  * 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
204
224
  * `start`/`end`, the whole file.
@@ -79,6 +79,12 @@ interface PlacedWord {
79
79
  size: number;
80
80
  baseline: number;
81
81
  x: number;
82
+ /** While the word is spoken (clip-relative seconds), it is drawn in `color` instead. */
83
+ active?: {
84
+ start: number;
85
+ end: number;
86
+ color: string;
87
+ };
82
88
  }
83
89
  /** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
84
90
  interface SubtitleChunk {
@@ -92,12 +98,21 @@ interface SubtitleOptions {
92
98
  duration: number;
93
99
  font: string;
94
100
  color: string;
95
- /** Per-word font, color or text, such as a highlighted or blanked-out word. */
101
+ /**
102
+ * Per-word font, color or text, such as a highlighted or blanked-out word, and its own
103
+ * `activeColor` (`null` keeps the word's color while it is spoken).
104
+ */
96
105
  style?: (word: TimedWord) => {
97
106
  font?: string;
98
107
  color?: string;
99
108
  text?: string;
109
+ activeColor?: string | null;
100
110
  };
111
+ /**
112
+ * Color of each word while it is spoken, karaoke style: from its start to the next word's
113
+ * start, so early end times leave no gap, and to the line's end for its last word.
114
+ */
115
+ activeColor?: string;
101
116
  /** @default 56 */
102
117
  size?: number;
103
118
  /**
@@ -160,6 +175,11 @@ interface ClipOptions {
160
175
  subtitles?: SubtitleChunk[];
161
176
  /** [start, end] windows (clip-relative seconds) where the sound is replaced by a beep. */
162
177
  mute?: [number, number][];
178
+ /**
179
+ * Target integrated loudness in LUFS (e.g. -14). The clip's sound is normalized to it
180
+ * before any beep is mixed in, so beeps stay at the same level in every clip.
181
+ */
182
+ loudness?: number;
163
183
  }
164
184
  /** Renders 1080x1920 reel segments with ffmpeg and joins them. */
165
185
  declare class Renderer {
@@ -198,7 +218,7 @@ declare class Renderer {
198
218
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
199
219
  * and beeps. Returns the file name in `workDir`.
200
220
  */
201
- renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, workDir, name }: ClipOptions): Promise<string>;
221
+ renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
202
222
  /**
203
223
  * 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
204
224
  * `start`/`end`, the whole file.
@@ -131,7 +131,7 @@ ${stderr.slice(-1500)}`);
131
131
  * further while it is wider than `maxWidth`.
132
132
  */
133
133
  subtitleChunks(options) {
134
- const { words, duration, font, color, style, size = 56, baseline = 1445, maxWords = 5, hold = 0.6, maxWidth = TEXT_WIDTH } = options;
134
+ const { words, duration, font, color, style, activeColor, size = 56, baseline = 1445, maxWords = 5, hold = 0.6, maxWidth = TEXT_WIDTH } = options;
135
135
  const styled = words.map((w) => ({ ...w, font, color, ...style?.(w) }));
136
136
  const space = this.textWidth(" ", font, size);
137
137
  const width = (ws) => ws.reduce((sum, w) => sum + this.textWidth(w.text, w.font, size), 0) + (ws.length - 1) * space;
@@ -168,39 +168,55 @@ ${stderr.slice(-1500)}`);
168
168
  const next = groups[i + 1]?.[0];
169
169
  const holdEnd = Math.min(duration, last.end + hold);
170
170
  const end = next ? Math.min(next.start, holdEnd) : holdEnd;
171
+ const start = Math.max(0, first.start);
172
+ const lineEnd = Math.max(end, first.start + 0.1);
171
173
  let x = (WIDTH - width(group)) / 2;
172
- const placed = group.map((w) => {
174
+ const placed = group.map((w, j) => {
173
175
  const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
176
+ const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
177
+ if (spokenColor) {
178
+ const from = Math.max(start, w.start);
179
+ const to = j < group.length - 1 ? group[j + 1].start : lineEnd;
180
+ word.active = { start: from, end: Math.max(from, to), color: spokenColor };
181
+ }
174
182
  x += this.textWidth(w.text, w.font, size) + space;
175
183
  return word;
176
184
  });
177
- return { start: Math.max(0, first.start), end: Math.max(end, first.start + 0.1), words: placed };
185
+ return { start, end: lineEnd, words: placed };
178
186
  });
179
187
  }
180
188
  // Each word is its own drawtext so a word can take another color.
181
189
  drawSubtitles(chunks, workDir, name) {
182
190
  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
- )
191
+ (chunk, c) => chunk.words.flatMap((word, w) => {
192
+ const file = `${name}-sub${c}-${w}.txt`;
193
+ const look = `y=${word.baseline}-ascent:borderw=3:bordercolor=${this.ink}@0.85`;
194
+ const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
195
+ if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
196
+ const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
197
+ return [
198
+ this.drawtext(workDir, file, word, `${look}:enable='${shown}*not(${spoken})'`),
199
+ this.drawtext(workDir, file, { ...word, color: word.active.color }, `${look}:enable='${spoken}'`)
200
+ ];
201
+ })
191
202
  );
192
203
  }
193
204
  /**
194
205
  * Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
195
206
  * and beeps. Returns the file name in `workDir`.
196
207
  */
197
- async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], workDir, name }) {
208
+ async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
198
209
  const duration = end - start + hold;
199
- const subs = this.drawSubtitles(subtitles.map((c) => ({ ...c, start: c.start + hold, end: c.end + hold })), workDir, name);
210
+ const held = subtitles.map((c) => ({
211
+ start: c.start + hold,
212
+ end: c.end + hold,
213
+ words: c.words.map((w) => w.active ? { ...w, active: { ...w.active, start: w.active.start + hold, end: w.active.end + hold } } : w)
214
+ }));
215
+ const subs = this.drawSubtitles(held, workDir, name);
200
216
  const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
201
217
  const out = hook ? "[pre]" : "[v]";
202
218
  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` : ""}`;
219
+ 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
220
  let video = `${withSubs(input)}${out}`;
205
221
  if (tag) {
206
222
  const { box, lines, delay = 0, outSeconds = 0, fade = true } = tag;
@@ -232,9 +248,11 @@ ${stderr.slice(-1500)}`);
232
248
  const muted = `gt(${mute.map(([a, b]) => `between(t,${(a + hold).toFixed(3)},${(b + hold).toFixed(3)})`).join("+")},0)`;
233
249
  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
250
  }
251
+ const graph = `${name}.filter`;
252
+ writeFileSync(join(workDir, graph), `${video};${audio}`);
235
253
  const output = `${name}.mp4`;
236
254
  await this.run(
237
- ["-ss", String(start), "-to", String(end), "-i", resolve(clipPath), "-filter_complex", `${video};${audio}`, "-map", "[v]", "-map", "[a]", ...OUTPUT_ARGS, output],
255
+ ["-ss", String(start), "-to", String(end), "-i", resolve(clipPath), "-filter_complex_script", graph, "-map", "[v]", "-map", "[a]", ...OUTPUT_ARGS, output],
238
256
  workDir
239
257
  );
240
258
  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.1",
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": {