keepitreel 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/dist/video/index.cjs +80 -12
- package/dist/video/index.d.cts +58 -5
- package/dist/video/index.d.ts +58 -5
- package/dist/video/index.js +80 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -199,8 +199,9 @@ Renders `start` to `end` of a clip as a 9:16 segment: the landscape picture sits
|
|
|
199
199
|
| `workDir` | `string` | required | Where the output and the files ffmpeg reads go: the text, and the filter graph (`<name>.filter`), which keeps clips with hundreds of subtitle words within Windows' command-line limit |
|
|
200
200
|
| `name` | `string` | required | The output's base name |
|
|
201
201
|
| `subtitles` | `SubtitleChunk[]` | `[]` | From [`.subtitleChunks()`](#subtitlechunksoptions) |
|
|
202
|
-
| `hook` | `{ band
|
|
202
|
+
| `hook` | `{ band?: { y, h, opacity }, lines, outSeconds? }` | | Text up from the first frame, on a full-width ink band when `band` is given, fading out `outSeconds` before the end. Without `outSeconds` it stays to the end |
|
|
203
203
|
| `tag` | `{ box: { x, y, w, h, opacity }, lines, delay?, outSeconds?, fade? }` | | An ink box with text that fades in after `delay` and out before the end. With `fade: false` it is up for the whole clip |
|
|
204
|
+
| `fill` | `string` | `FILL_9_16` | The filter chain that turns the source picture into the 1080x1920 frame, for a layout of your own (for example the picture cropped and placed higher, over a darker blur) |
|
|
204
205
|
| `hold` | `number` | `0` | Seconds the first frame is held, silent, before the clip plays. Subtitles and mute windows move with it |
|
|
205
206
|
| `mute` | `[number, number][]` | `[]` | Clip-relative windows where the sound is replaced by a beep |
|
|
206
207
|
| `loudness` | `number` | | Target loudness in LUFS, e.g. `-14`. The clip's sound is normalized to it before beeps are mixed in. Without it the sound is left as it is |
|
|
@@ -223,6 +224,10 @@ Splits clip-relative words into one-line, centered chunks, breaking at sentence
|
|
|
223
224
|
| `maxWords` | `number` | `5` | |
|
|
224
225
|
| `hold` | `number` | `0.6` | Seconds a chunk stays up after its last word when the next one is not right after |
|
|
225
226
|
| `maxWidth` | `number` | `TEXT_WIDTH` | |
|
|
227
|
+
| `breakAfter` | `RegExp` | `/[.?!]["']?$/` | Words that end a piece, as sentence ends do. `/[.,?!]["']?$/` also breaks after commas |
|
|
228
|
+
| `pause` | `number` | | A silence longer than this many seconds between two words also ends a piece |
|
|
229
|
+
| `border` | `{ width, color }` | 3 px, the renderer's ink at 85% | The outline around each word |
|
|
230
|
+
| `shadow` | `{ x, y, color }` | | A shadow under each word, offset by `x` and `y` px |
|
|
226
231
|
|
|
227
232
|
#### .concat(segments, workDir, outputPath)
|
|
228
233
|
|
|
@@ -252,6 +257,18 @@ Returns the width in pixels of `text` in the font with key `font` at `size`.
|
|
|
252
257
|
|
|
253
258
|
Returns the largest size up to `size` at which `text` fits in `maxWidth` (default `TEXT_WIDTH`).
|
|
254
259
|
|
|
260
|
+
#### .wrapText(text, font, size, options?)
|
|
261
|
+
|
|
262
|
+
Returns `text` wrapped into lines no wider than `maxWidth` (default `TEXT_WIDTH`). With `balance: true` the lines are as even as the fewest lines allow, instead of full lines and a short last one.
|
|
263
|
+
|
|
264
|
+
#### .printable(text, font)
|
|
265
|
+
|
|
266
|
+
Returns `text` without the characters the font has no glyph for, such as emoji, with spaces collapsed. ffmpeg draws those as empty boxes.
|
|
267
|
+
|
|
268
|
+
#### .capHeight(font, size)
|
|
269
|
+
|
|
270
|
+
Returns the height of the font's capital letters at `size`. `baseline = top + capHeight` places text by the top of its capitals.
|
|
271
|
+
|
|
255
272
|
#### .drawTexts(lines, out, workDir, name)
|
|
256
273
|
|
|
257
274
|
Returns ffmpeg `drawtext` filters for [text lines](#text-lines), for filter graphs of your own. Each line fades in, rising by `rise` pixels, from its `delay`. With `out` (`{ end, seconds }`) it fades away over the last `seconds` before `end`. The text goes through files in `workDir`, named after `name`, so it needs no escaping.
|
|
@@ -282,6 +299,7 @@ The `lines` of a hook or tag, and the input of `.drawTexts()`:
|
|
|
282
299
|
| `delay` | `number` | `0` | Seconds before the line fades in. `-0.25` has it fully in at the first frame |
|
|
283
300
|
| `rise` | `number` | `18` | Pixels the line rises while it fades in |
|
|
284
301
|
| `window` | `[number, number]` | | Seconds the line is shown, from the first up to, not including, the second |
|
|
302
|
+
| `box` | `{ color, border }` | | A box behind the text, `border` px around it, or `[vertical, horizontal]` |
|
|
285
303
|
|
|
286
304
|
In a hook or tag, `.renderClip()` sets `delay` and `rise` itself: the lines follow the band's or box's fade and do not rise.
|
|
287
305
|
|
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
|
}
|
|
@@ -176,7 +237,7 @@ ${stderr.slice(-1500)}`);
|
|
|
176
237
|
const lineEnd = Math.max(end, first.start + 0.1);
|
|
177
238
|
let x = (WIDTH - width(group)) / 2;
|
|
178
239
|
const placed = group.map((w, j) => {
|
|
179
|
-
const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
|
|
240
|
+
const word = { text: w.text, font: w.font, color: w.color, size, baseline, x, ...border ? { border } : {}, ...shadow ? { shadow } : {} };
|
|
180
241
|
const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
|
|
181
242
|
if (spokenColor) {
|
|
182
243
|
const from = Math.max(start, w.start);
|
|
@@ -194,7 +255,9 @@ ${stderr.slice(-1500)}`);
|
|
|
194
255
|
return chunks.flatMap(
|
|
195
256
|
(chunk, c) => chunk.words.flatMap((word, w) => {
|
|
196
257
|
const file = `${name}-sub${c}-${w}.txt`;
|
|
197
|
-
const
|
|
258
|
+
const { width, color } = word.border ?? { width: 3, color: `${this.ink}@0.85` };
|
|
259
|
+
const shadow = word.shadow ? `:shadowx=${word.shadow.x}:shadowy=${word.shadow.y}:shadowcolor=${word.shadow.color}` : "";
|
|
260
|
+
const look = `y=${word.baseline}-ascent:borderw=${width}:bordercolor=${color}${shadow}`;
|
|
198
261
|
const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
|
|
199
262
|
if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
|
|
200
263
|
const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
|
|
@@ -209,7 +272,7 @@ ${stderr.slice(-1500)}`);
|
|
|
209
272
|
* Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
|
|
210
273
|
* and beeps. Returns the file name in `workDir`.
|
|
211
274
|
*/
|
|
212
|
-
async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
|
|
275
|
+
async renderClip({ clipPath, start, end, tag, hook, fill = FILL_9_16, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
|
|
213
276
|
const duration = end - start + hold;
|
|
214
277
|
const held = subtitles.map((c) => ({
|
|
215
278
|
start: c.start + hold,
|
|
@@ -219,7 +282,7 @@ ${stderr.slice(-1500)}`);
|
|
|
219
282
|
const subs = this.drawSubtitles(held, workDir, name);
|
|
220
283
|
const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
|
|
221
284
|
const out = hook ? "[pre]" : "[v]";
|
|
222
|
-
const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${
|
|
285
|
+
const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${fill}`;
|
|
223
286
|
const sound = `[0:a]${loudness == null ? "" : `loudnorm=I=${loudness}:TP=-1.5:LRA=11,`}aresample=${SAMPLE_RATE}${hold ? `,adelay=${Math.round(hold * 1e3)}:all=1` : ""}`;
|
|
224
287
|
let video = `${withSubs(input)}${out}`;
|
|
225
288
|
if (tag) {
|
|
@@ -243,9 +306,14 @@ ${stderr.slice(-1500)}`);
|
|
|
243
306
|
}
|
|
244
307
|
if (hook) {
|
|
245
308
|
const { band, lines, outSeconds } = hook;
|
|
246
|
-
const
|
|
247
|
-
const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds:
|
|
248
|
-
|
|
309
|
+
const fadeOut = outSeconds ? { start: duration - outSeconds, seconds: outSeconds } : null;
|
|
310
|
+
const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), fadeOut && { end: duration, seconds: fadeOut.seconds }, workDir, `${name}-hook`);
|
|
311
|
+
if (band) {
|
|
312
|
+
const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut });
|
|
313
|
+
video += `;${ink}[band];[pre][band]overlay=0:${band.y}:format=auto,${texts.join(",")}[v]`;
|
|
314
|
+
} else {
|
|
315
|
+
video += `;[pre]${texts.join(",") || "null"}[v]`;
|
|
316
|
+
}
|
|
249
317
|
}
|
|
250
318
|
let audio = `${sound}[a]`;
|
|
251
319
|
if (mute.length) {
|
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 {
|
|
@@ -85,6 +101,9 @@ interface PlacedWord {
|
|
|
85
101
|
end: number;
|
|
86
102
|
color: string;
|
|
87
103
|
};
|
|
104
|
+
/** Without it, a 3 px outline in the renderer's ink at 85%. */
|
|
105
|
+
border?: TextBorder;
|
|
106
|
+
shadow?: TextShadow;
|
|
88
107
|
}
|
|
89
108
|
/** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
|
|
90
109
|
interface SubtitleChunk {
|
|
@@ -131,6 +150,18 @@ interface SubtitleOptions {
|
|
|
131
150
|
hold?: number;
|
|
132
151
|
/** @default TEXT_WIDTH */
|
|
133
152
|
maxWidth?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Words that end a piece, like sentence ends do. Pass `/[.,?!]["']?$/` to also break after
|
|
155
|
+
* commas.
|
|
156
|
+
*
|
|
157
|
+
* @default /[.?!]["']?$/
|
|
158
|
+
*/
|
|
159
|
+
breakAfter?: RegExp;
|
|
160
|
+
/** A silence longer than this many seconds between two words also ends a piece. */
|
|
161
|
+
pause?: number;
|
|
162
|
+
/** @default a 3 px outline in the renderer's ink at 85% */
|
|
163
|
+
border?: TextBorder;
|
|
164
|
+
shadow?: TextShadow;
|
|
134
165
|
}
|
|
135
166
|
interface ClipOptions {
|
|
136
167
|
clipPath: string;
|
|
@@ -157,16 +188,26 @@ interface ClipOptions {
|
|
|
157
188
|
outSeconds?: number;
|
|
158
189
|
fade?: boolean;
|
|
159
190
|
};
|
|
160
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Text up from the first frame, on a full-width ink band when `band` is given, fading out
|
|
193
|
+
* `outSeconds` before the clip ends. Without `outSeconds` it stays to the end.
|
|
194
|
+
*/
|
|
161
195
|
hook?: {
|
|
162
|
-
band
|
|
196
|
+
band?: {
|
|
163
197
|
y: number;
|
|
164
198
|
h: number;
|
|
165
199
|
opacity: number;
|
|
166
200
|
};
|
|
167
201
|
lines: TextLine[];
|
|
168
|
-
outSeconds
|
|
202
|
+
outSeconds?: number;
|
|
169
203
|
};
|
|
204
|
+
/**
|
|
205
|
+
* Filter chain that turns the source picture into the 1080x1920 frame, for a layout of your
|
|
206
|
+
* own: it reads the source picture and outputs the frame.
|
|
207
|
+
*
|
|
208
|
+
* @default FILL_9_16
|
|
209
|
+
*/
|
|
210
|
+
fill?: string;
|
|
170
211
|
/**
|
|
171
212
|
* Seconds the first frame is held, silent, before the clip plays. Subtitles and mute
|
|
172
213
|
* windows move with it.
|
|
@@ -197,6 +238,18 @@ declare class Renderer {
|
|
|
197
238
|
textWidth(text: string, font: string, size: number): number;
|
|
198
239
|
/** Largest size up to `size` at which the text fits in `maxWidth`. */
|
|
199
240
|
fitSize(text: string, font: string, size: number, maxWidth?: number): number;
|
|
241
|
+
/** Height of the font's capital letters at `size`, for placing text by its top. */
|
|
242
|
+
capHeight(font: string, size: number): number;
|
|
243
|
+
/** `text` without the characters `font` has no glyph for, such as emoji, with spaces collapsed. */
|
|
244
|
+
printable(text: string, font: string): string;
|
|
245
|
+
/**
|
|
246
|
+
* `text` wrapped into lines of at most `maxWidth`. With `balance`, the lines are as even as
|
|
247
|
+
* the fewest lines allow, instead of full lines and a short last one.
|
|
248
|
+
*/
|
|
249
|
+
wrapText(text: string, font: string, size: number, { maxWidth, balance }?: {
|
|
250
|
+
maxWidth?: number | undefined;
|
|
251
|
+
balance?: boolean | undefined;
|
|
252
|
+
}): string[];
|
|
200
253
|
/** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
|
|
201
254
|
inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }: InkLayerOptions): string;
|
|
202
255
|
/**
|
|
@@ -218,7 +271,7 @@ declare class Renderer {
|
|
|
218
271
|
* Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
|
|
219
272
|
* and beeps. Returns the file name in `workDir`.
|
|
220
273
|
*/
|
|
221
|
-
renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
|
|
274
|
+
renderClip({ clipPath, start, end, tag, hook, fill, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
|
|
222
275
|
/**
|
|
223
276
|
* 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
|
|
224
277
|
* `start`/`end`, the whole file.
|
|
@@ -244,4 +297,4 @@ declare class Renderer {
|
|
|
244
297
|
concat(segments: string[], workDir: string, outputPath: string): Promise<void>;
|
|
245
298
|
}
|
|
246
299
|
|
|
247
|
-
export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextLine, type TimedWord, WIDTH };
|
|
300
|
+
export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextBorder, type TextLine, type TextShadow, type TimedWord, WIDTH };
|
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 {
|
|
@@ -85,6 +101,9 @@ interface PlacedWord {
|
|
|
85
101
|
end: number;
|
|
86
102
|
color: string;
|
|
87
103
|
};
|
|
104
|
+
/** Without it, a 3 px outline in the renderer's ink at 85%. */
|
|
105
|
+
border?: TextBorder;
|
|
106
|
+
shadow?: TextShadow;
|
|
88
107
|
}
|
|
89
108
|
/** One line of subtitles, shown from `start` to `end` (clip-relative seconds). */
|
|
90
109
|
interface SubtitleChunk {
|
|
@@ -131,6 +150,18 @@ interface SubtitleOptions {
|
|
|
131
150
|
hold?: number;
|
|
132
151
|
/** @default TEXT_WIDTH */
|
|
133
152
|
maxWidth?: number;
|
|
153
|
+
/**
|
|
154
|
+
* Words that end a piece, like sentence ends do. Pass `/[.,?!]["']?$/` to also break after
|
|
155
|
+
* commas.
|
|
156
|
+
*
|
|
157
|
+
* @default /[.?!]["']?$/
|
|
158
|
+
*/
|
|
159
|
+
breakAfter?: RegExp;
|
|
160
|
+
/** A silence longer than this many seconds between two words also ends a piece. */
|
|
161
|
+
pause?: number;
|
|
162
|
+
/** @default a 3 px outline in the renderer's ink at 85% */
|
|
163
|
+
border?: TextBorder;
|
|
164
|
+
shadow?: TextShadow;
|
|
134
165
|
}
|
|
135
166
|
interface ClipOptions {
|
|
136
167
|
clipPath: string;
|
|
@@ -157,16 +188,26 @@ interface ClipOptions {
|
|
|
157
188
|
outSeconds?: number;
|
|
158
189
|
fade?: boolean;
|
|
159
190
|
};
|
|
160
|
-
/**
|
|
191
|
+
/**
|
|
192
|
+
* Text up from the first frame, on a full-width ink band when `band` is given, fading out
|
|
193
|
+
* `outSeconds` before the clip ends. Without `outSeconds` it stays to the end.
|
|
194
|
+
*/
|
|
161
195
|
hook?: {
|
|
162
|
-
band
|
|
196
|
+
band?: {
|
|
163
197
|
y: number;
|
|
164
198
|
h: number;
|
|
165
199
|
opacity: number;
|
|
166
200
|
};
|
|
167
201
|
lines: TextLine[];
|
|
168
|
-
outSeconds
|
|
202
|
+
outSeconds?: number;
|
|
169
203
|
};
|
|
204
|
+
/**
|
|
205
|
+
* Filter chain that turns the source picture into the 1080x1920 frame, for a layout of your
|
|
206
|
+
* own: it reads the source picture and outputs the frame.
|
|
207
|
+
*
|
|
208
|
+
* @default FILL_9_16
|
|
209
|
+
*/
|
|
210
|
+
fill?: string;
|
|
170
211
|
/**
|
|
171
212
|
* Seconds the first frame is held, silent, before the clip plays. Subtitles and mute
|
|
172
213
|
* windows move with it.
|
|
@@ -197,6 +238,18 @@ declare class Renderer {
|
|
|
197
238
|
textWidth(text: string, font: string, size: number): number;
|
|
198
239
|
/** Largest size up to `size` at which the text fits in `maxWidth`. */
|
|
199
240
|
fitSize(text: string, font: string, size: number, maxWidth?: number): number;
|
|
241
|
+
/** Height of the font's capital letters at `size`, for placing text by its top. */
|
|
242
|
+
capHeight(font: string, size: number): number;
|
|
243
|
+
/** `text` without the characters `font` has no glyph for, such as emoji, with spaces collapsed. */
|
|
244
|
+
printable(text: string, font: string): string;
|
|
245
|
+
/**
|
|
246
|
+
* `text` wrapped into lines of at most `maxWidth`. With `balance`, the lines are as even as
|
|
247
|
+
* the fewest lines allow, instead of full lines and a short last one.
|
|
248
|
+
*/
|
|
249
|
+
wrapText(text: string, font: string, size: number, { maxWidth, balance }?: {
|
|
250
|
+
maxWidth?: number | undefined;
|
|
251
|
+
balance?: boolean | undefined;
|
|
252
|
+
}): string[];
|
|
200
253
|
/** A solid ink layer at `opacity`, as a filter source with optional alpha fades. */
|
|
201
254
|
inkLayer({ w, h, opacity, duration, fadeIn, fadeOut }: InkLayerOptions): string;
|
|
202
255
|
/**
|
|
@@ -218,7 +271,7 @@ declare class Renderer {
|
|
|
218
271
|
* Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
|
|
219
272
|
* and beeps. Returns the file name in `workDir`.
|
|
220
273
|
*/
|
|
221
|
-
renderClip({ clipPath, start, end, tag, hook, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
|
|
274
|
+
renderClip({ clipPath, start, end, tag, hook, fill, hold, subtitles, mute, loudness, workDir, name }: ClipOptions): Promise<string>;
|
|
222
275
|
/**
|
|
223
276
|
* 16 kHz mono 16-bit WAV of `input`, the format keepitreel/transcribe reads. Without
|
|
224
277
|
* `start`/`end`, the whole file.
|
|
@@ -244,4 +297,4 @@ declare class Renderer {
|
|
|
244
297
|
concat(segments: string[], workDir: string, outputPath: string): Promise<void>;
|
|
245
298
|
}
|
|
246
299
|
|
|
247
|
-
export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextLine, type TimedWord, WIDTH };
|
|
300
|
+
export { type ClipOptions, FILL_9_16, FPS, type FadeOut, HEIGHT, type InkLayerOptions, MARGIN, OUTPUT_ARGS, type PlacedWord, Renderer, type RendererOptions, type SubtitleChunk, type SubtitleOptions, TEXT_WIDTH, type TextBorder, type TextLine, type TextShadow, type TimedWord, WIDTH };
|
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
|
}
|
|
@@ -172,7 +233,7 @@ ${stderr.slice(-1500)}`);
|
|
|
172
233
|
const lineEnd = Math.max(end, first.start + 0.1);
|
|
173
234
|
let x = (WIDTH - width(group)) / 2;
|
|
174
235
|
const placed = group.map((w, j) => {
|
|
175
|
-
const word = { text: w.text, font: w.font, color: w.color, size, baseline, x };
|
|
236
|
+
const word = { text: w.text, font: w.font, color: w.color, size, baseline, x, ...border ? { border } : {}, ...shadow ? { shadow } : {} };
|
|
176
237
|
const spokenColor = w.activeColor === void 0 ? activeColor : w.activeColor;
|
|
177
238
|
if (spokenColor) {
|
|
178
239
|
const from = Math.max(start, w.start);
|
|
@@ -190,7 +251,9 @@ ${stderr.slice(-1500)}`);
|
|
|
190
251
|
return chunks.flatMap(
|
|
191
252
|
(chunk, c) => chunk.words.flatMap((word, w) => {
|
|
192
253
|
const file = `${name}-sub${c}-${w}.txt`;
|
|
193
|
-
const
|
|
254
|
+
const { width, color } = word.border ?? { width: 3, color: `${this.ink}@0.85` };
|
|
255
|
+
const shadow = word.shadow ? `:shadowx=${word.shadow.x}:shadowy=${word.shadow.y}:shadowcolor=${word.shadow.color}` : "";
|
|
256
|
+
const look = `y=${word.baseline}-ascent:borderw=${width}:bordercolor=${color}${shadow}`;
|
|
194
257
|
const shown = `between(t,${chunk.start.toFixed(3)},${chunk.end.toFixed(3)})`;
|
|
195
258
|
if (!word.active) return [this.drawtext(workDir, file, word, `${look}:enable='${shown}'`)];
|
|
196
259
|
const spoken = `gte(t,${word.active.start.toFixed(3)})*lt(t,${word.active.end.toFixed(3)})`;
|
|
@@ -205,7 +268,7 @@ ${stderr.slice(-1500)}`);
|
|
|
205
268
|
* Renders `start` to `end` of a clip as a 9:16 segment, with optional tag, hook, subtitles
|
|
206
269
|
* and beeps. Returns the file name in `workDir`.
|
|
207
270
|
*/
|
|
208
|
-
async renderClip({ clipPath, start, end, tag, hook, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
|
|
271
|
+
async renderClip({ clipPath, start, end, tag, hook, fill = FILL_9_16, hold = 0, subtitles = [], mute = [], loudness, workDir, name }) {
|
|
209
272
|
const duration = end - start + hold;
|
|
210
273
|
const held = subtitles.map((c) => ({
|
|
211
274
|
start: c.start + hold,
|
|
@@ -215,7 +278,7 @@ ${stderr.slice(-1500)}`);
|
|
|
215
278
|
const subs = this.drawSubtitles(held, workDir, name);
|
|
216
279
|
const withSubs = (chain) => subs.length ? `${chain},${subs.join(",")}` : chain;
|
|
217
280
|
const out = hook ? "[pre]" : "[v]";
|
|
218
|
-
const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${
|
|
281
|
+
const input = `[0:v]${hold ? `tpad=start_duration=${hold.toFixed(3)}:start_mode=clone,` : ""}${fill}`;
|
|
219
282
|
const sound = `[0:a]${loudness == null ? "" : `loudnorm=I=${loudness}:TP=-1.5:LRA=11,`}aresample=${SAMPLE_RATE}${hold ? `,adelay=${Math.round(hold * 1e3)}:all=1` : ""}`;
|
|
220
283
|
let video = `${withSubs(input)}${out}`;
|
|
221
284
|
if (tag) {
|
|
@@ -239,9 +302,14 @@ ${stderr.slice(-1500)}`);
|
|
|
239
302
|
}
|
|
240
303
|
if (hook) {
|
|
241
304
|
const { band, lines, outSeconds } = hook;
|
|
242
|
-
const
|
|
243
|
-
const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), { end: duration, seconds:
|
|
244
|
-
|
|
305
|
+
const fadeOut = outSeconds ? { start: duration - outSeconds, seconds: outSeconds } : null;
|
|
306
|
+
const texts = this.drawTexts(lines.map((l) => ({ ...l, delay: -TEXT_IN_SECONDS, rise: 0 })), fadeOut && { end: duration, seconds: fadeOut.seconds }, workDir, `${name}-hook`);
|
|
307
|
+
if (band) {
|
|
308
|
+
const ink = this.inkLayer({ w: WIDTH, h: band.h, opacity: band.opacity, duration, fadeOut });
|
|
309
|
+
video += `;${ink}[band];[pre][band]overlay=0:${band.y}:format=auto,${texts.join(",")}[v]`;
|
|
310
|
+
} else {
|
|
311
|
+
video += `;[pre]${texts.join(",") || "null"}[v]`;
|
|
312
|
+
}
|
|
245
313
|
}
|
|
246
314
|
let audio = `${sound}[a]`;
|
|
247
315
|
if (mute.length) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keepitreel",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|