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 +5 -3
- package/dist/video/index.cjs +33 -15
- package/dist/video/index.d.cts +22 -2
- package/dist/video/index.d.ts +22 -2
- package/dist/video/index.js +33 -15
- package/package.json +1 -1
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
|
|
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` | |
|
package/dist/video/index.cjs
CHANGED
|
@@ -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
|
|
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.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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), "-
|
|
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;
|
package/dist/video/index.d.cts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/video/index.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/video/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
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), "-
|
|
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.
|
|
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": {
|