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 +24 -4
- package/dist/video/index.cjs +110 -24
- package/dist/video/index.d.cts +79 -6
- package/dist/video/index.d.ts +79 -6
- package/dist/video/index.js +110 -24
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
package/dist/video/index.cjs
CHANGED
|
@@ -42,7 +42,7 @@ function defaultFfmpeg() {
|
|
|
42
42
|
return "ffmpeg";
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
var
|
|
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 {
|
|
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 (
|
|
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
|
|
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.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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,` : ""}${
|
|
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
|
|
231
|
-
const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds:
|
|
232
|
-
|
|
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), "-
|
|
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;
|
package/dist/video/index.d.cts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 };
|
package/dist/video/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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 };
|
package/dist/video/index.js
CHANGED
|
@@ -38,7 +38,7 @@ function defaultFfmpeg() {
|
|
|
38
38
|
return "ffmpeg";
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
var
|
|
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 {
|
|
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 (
|
|
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
|
|
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.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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,` : ""}${
|
|
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
|
|
227
|
-
const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds:
|
|
228
|
-
|
|
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), "-
|
|
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.
|
|
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": {
|