shorts-maker 0.3.0
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 +166 -0
- package/bunfig.toml +3 -0
- package/dist/chunk-27wzf6kd.js +240 -0
- package/dist/chunk-wt9saz7q.css +1 -0
- package/dist/index.html +18 -0
- package/hashtags.txt +40 -0
- package/package.json +59 -0
- package/src/build.ts +30 -0
- package/src/cli.ts +70 -0
- package/src/files.ts +24 -0
- package/src/jobs/store.ts +390 -0
- package/src/paths.ts +23 -0
- package/src/pipeline/download.ts +201 -0
- package/src/pipeline/media.ts +312 -0
- package/src/pipeline/overlay.ts +138 -0
- package/src/pipeline/parts.ts +78 -0
- package/src/pipeline/pipeline.ts +217 -0
- package/src/server.ts +645 -0
- package/src/social/config.ts +72 -0
- package/src/social/facebook.ts +232 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// ffmpeg/ffprobe helpers: probing, pause detection, and clip cutting.
|
|
2
|
+
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
5
|
+
import { BADGE_SECONDS, overlayFilter, type Overlay } from "./overlay.ts";
|
|
6
|
+
|
|
7
|
+
export interface VideoInfo {
|
|
8
|
+
duration: number; // seconds
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
isVertical: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class MediaError extends Error {}
|
|
15
|
+
|
|
16
|
+
function require_(tool: string): void {
|
|
17
|
+
if (!Bun.which(tool)) {
|
|
18
|
+
throw new MediaError(
|
|
19
|
+
`\`${tool}\` was not found on PATH. Install ffmpeg (e.g. \`brew install ffmpeg\`).`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function run(
|
|
25
|
+
cmd: string[],
|
|
26
|
+
signal?: AbortSignal,
|
|
27
|
+
): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
28
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
29
|
+
const onAbort = () => proc.kill(); // deleting the job kills the ffmpeg/ffprobe child
|
|
30
|
+
if (signal?.aborted) proc.kill();
|
|
31
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
32
|
+
try {
|
|
33
|
+
const [stdout, stderr] = await Promise.all([
|
|
34
|
+
new Response(proc.stdout).text(),
|
|
35
|
+
new Response(proc.stderr).text(),
|
|
36
|
+
]);
|
|
37
|
+
const code = await proc.exited;
|
|
38
|
+
return { code, stdout, stderr };
|
|
39
|
+
} finally {
|
|
40
|
+
signal?.removeEventListener("abort", onAbort);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function probe(video: string, signal?: AbortSignal): Promise<VideoInfo> {
|
|
45
|
+
require_("ffprobe");
|
|
46
|
+
const { code, stdout, stderr } = await run([
|
|
47
|
+
"ffprobe", "-v", "error", "-print_format", "json",
|
|
48
|
+
"-show_format", "-show_streams", video,
|
|
49
|
+
], signal);
|
|
50
|
+
if (code !== 0) throw new MediaError(`ffprobe failed for ${video}:\n${stderr.trim()}`);
|
|
51
|
+
|
|
52
|
+
const data = JSON.parse(stdout);
|
|
53
|
+
let duration = Number(data.format?.duration ?? 0);
|
|
54
|
+
let width = 0;
|
|
55
|
+
let height = 0;
|
|
56
|
+
for (const stream of data.streams ?? []) {
|
|
57
|
+
if (stream.codec_type === "video") {
|
|
58
|
+
width = Number(stream.width ?? 0);
|
|
59
|
+
height = Number(stream.height ?? 0);
|
|
60
|
+
if (!duration) duration = Number(stream.duration ?? 0);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!(duration > 0)) throw new MediaError(`Could not determine duration of ${video}.`);
|
|
65
|
+
return { duration, width, height, isVertical: height >= width };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Times (seconds) of natural pauses, for cutting where speech drops out.
|
|
70
|
+
* Uses ffmpeg's silencedetect on the audio only; each pause's midpoint is a cut candidate.
|
|
71
|
+
*/
|
|
72
|
+
export async function silenceCutPoints(
|
|
73
|
+
video: string,
|
|
74
|
+
signal?: AbortSignal,
|
|
75
|
+
noiseDb = -30,
|
|
76
|
+
minSilence = 0.25,
|
|
77
|
+
): Promise<number[]> {
|
|
78
|
+
require_("ffmpeg");
|
|
79
|
+
const { stderr } = await run([
|
|
80
|
+
"ffmpeg", "-i", video, "-map", "0:a:0",
|
|
81
|
+
"-af", `silencedetect=noise=${noiseDb}dB:d=${minSilence}`,
|
|
82
|
+
"-f", "null", "-",
|
|
83
|
+
], signal);
|
|
84
|
+
const nums = (re: RegExp) => [...stderr.matchAll(re)].map((m) => Number(m[1]));
|
|
85
|
+
const starts = nums(/silence_start:\s*([0-9.]+)/g);
|
|
86
|
+
const ends = nums(/silence_end:\s*([0-9.]+)/g);
|
|
87
|
+
const mids: number[] = [];
|
|
88
|
+
for (let i = 0; i < Math.min(starts.length, ends.length); i++) {
|
|
89
|
+
mids.push((starts[i]! + ends[i]!) / 2);
|
|
90
|
+
}
|
|
91
|
+
return mids.sort((a, b) => a - b);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let hwEncoder: boolean | undefined;
|
|
95
|
+
|
|
96
|
+
/** Whether this ffmpeg can use Apple's hardware H.264 encoder — far faster than libx264. */
|
|
97
|
+
function hasVideoToolbox(): boolean {
|
|
98
|
+
if (hwEncoder === undefined) {
|
|
99
|
+
hwEncoder = false;
|
|
100
|
+
if (process.platform === "darwin") {
|
|
101
|
+
const proc = Bun.spawnSync(["ffmpeg", "-hide_banner", "-encoders"]);
|
|
102
|
+
hwEncoder = proc.stdout.toString().includes("h264_videotoolbox");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return hwEncoder;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const H264_VIDEOTOOLBOX = ["-c:v", "h264_videotoolbox", "-q:v", "60"];
|
|
109
|
+
const LIBX264 = ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20"];
|
|
110
|
+
|
|
111
|
+
/** One timescale for every piece we mux — concatenating mismatched ones scrambles timestamps. */
|
|
112
|
+
const TIMESCALE = "90000";
|
|
113
|
+
|
|
114
|
+
/** Cut [start, end] by stream-copy. Instant, and bit-for-bit the source's quality. */
|
|
115
|
+
async function copyCut(
|
|
116
|
+
video: string,
|
|
117
|
+
outFile: string,
|
|
118
|
+
start: number,
|
|
119
|
+
end: number,
|
|
120
|
+
signal?: AbortSignal,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const { code, stderr } = await run([
|
|
123
|
+
"ffmpeg", "-y", "-ss", start.toFixed(3), "-i", video, "-t", (end - start).toFixed(3),
|
|
124
|
+
"-c", "copy", "-avoid_negative_ts", "make_zero", "-movflags", "+faststart", outFile,
|
|
125
|
+
], signal);
|
|
126
|
+
if (code !== 0) throw new MediaError(`Clip cut failed for ${outFile}:\n${stderr.trim()}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function ffprobeText(args: string[], signal?: AbortSignal): Promise<string> {
|
|
130
|
+
require_("ffprobe");
|
|
131
|
+
const { code, stdout, stderr } = await run(["ffprobe", "-v", "error", ...args], signal);
|
|
132
|
+
if (code !== 0) throw new MediaError(`ffprobe failed:\n${stderr.trim()}`);
|
|
133
|
+
return stdout.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const videoPacketPts = (file: string, extra: string[] = []) =>
|
|
137
|
+
["-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0", ...extra, file];
|
|
138
|
+
|
|
139
|
+
/** Presentation time of the clip's first video frame — often nonzero, thanks to B-frames. */
|
|
140
|
+
async function firstVideoPts(file: string, signal?: AbortSignal): Promise<number> {
|
|
141
|
+
// `%+#1` reads a single packet, so this stays instant on a long clip.
|
|
142
|
+
const out = await ffprobeText(videoPacketPts(file, ["-read_intervals", "%+#1"]), signal);
|
|
143
|
+
return Number.parseFloat(out.split("\n")[0] ?? "0") || 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function lastVideoPts(file: string, signal?: AbortSignal): Promise<number> {
|
|
147
|
+
const out = await ffprobeText(videoPacketPts(file), signal);
|
|
148
|
+
const times = out.split("\n").map((l) => Number.parseFloat(l)).filter((n) => Number.isFinite(n));
|
|
149
|
+
if (!times.length) throw new MediaError(`No video frames in ${file}.`);
|
|
150
|
+
return Math.max(...times);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function frameDuration(file: string, signal?: AbortSignal): Promise<number> {
|
|
154
|
+
const out = await ffprobeText(
|
|
155
|
+
["-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "csv=p=0", file],
|
|
156
|
+
signal,
|
|
157
|
+
);
|
|
158
|
+
const [num, den] = out.split("/").map(Number);
|
|
159
|
+
if (!num || !den) throw new MediaError(`Could not read the frame rate of ${file}.`);
|
|
160
|
+
return den / num;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function videoCodec(file: string, signal?: AbortSignal): Promise<string> {
|
|
164
|
+
return ffprobeText(
|
|
165
|
+
["-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "csv=p=0", file],
|
|
166
|
+
signal,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Escape a path for a single-quoted `file` line in a concat list. */
|
|
171
|
+
const concatPath = (p: string) => p.replace(/'/g, "'\\''");
|
|
172
|
+
|
|
173
|
+
/** Re-encode the whole clip with the badge drawn on it. Correct for any codec, but slow. */
|
|
174
|
+
async function encodeWithBadge(
|
|
175
|
+
input: string,
|
|
176
|
+
outFile: string,
|
|
177
|
+
overlay: Overlay,
|
|
178
|
+
extraOutput: string[],
|
|
179
|
+
signal?: AbortSignal,
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
const encode = (encoder: string[]) =>
|
|
182
|
+
run([
|
|
183
|
+
"ffmpeg", "-y", "-i", input,
|
|
184
|
+
// One video + (if present) one audio track; the source container may also carry
|
|
185
|
+
// subtitles or data streams, which an .mp4 clip has no use for.
|
|
186
|
+
"-map", "0:v:0", "-sn", "-dn",
|
|
187
|
+
"-vf", overlayFilter(overlay),
|
|
188
|
+
...encoder, "-pix_fmt", "yuv420p", "-video_track_timescale", TIMESCALE,
|
|
189
|
+
...extraOutput, outFile,
|
|
190
|
+
], signal);
|
|
191
|
+
|
|
192
|
+
const hw = hasVideoToolbox();
|
|
193
|
+
let { code, stderr } = await encode(hw ? H264_VIDEOTOOLBOX : LIBX264);
|
|
194
|
+
// VideoToolbox can refuse a source it can't take (unusual dimensions, no GPU session
|
|
195
|
+
// available); libx264 always works, so a hardware failure is retried in software.
|
|
196
|
+
if (code !== 0 && hw && !signal?.aborted) ({ code, stderr } = await encode(LIBX264));
|
|
197
|
+
if (code !== 0) throw new MediaError(`Badge encode failed for ${outFile}:\n${stderr.trim()}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Draw the badge onto `part` without re-encoding the whole thing.
|
|
202
|
+
*
|
|
203
|
+
* The badge only exists in the first `BADGE_SECONDS`, so only that much video needs to be
|
|
204
|
+
* re-encoded. The clip's video is split at the first keyframe past the badge, the short
|
|
205
|
+
* head is re-encoded, and the (much longer) tail is copied untouched and concatenated back
|
|
206
|
+
* on. On a 2-minute part this is ~10x faster than re-encoding all of it.
|
|
207
|
+
*
|
|
208
|
+
* Only the *video* is split. Audio is copied whole from `part` at the end, because an
|
|
209
|
+
* audio packet almost never lands exactly on the video keyframe, and splicing it there
|
|
210
|
+
* shifts every later frame out of sync with the picture.
|
|
211
|
+
*/
|
|
212
|
+
async function badgeByReencodingHead(
|
|
213
|
+
part: string,
|
|
214
|
+
outFile: string,
|
|
215
|
+
overlay: Overlay,
|
|
216
|
+
tmp: string,
|
|
217
|
+
signal?: AbortSignal,
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const segments = join(tmp, "seg%d.mp4");
|
|
220
|
+
const { code, stderr } = await run([
|
|
221
|
+
"ffmpeg", "-y", "-i", part, "-map", "0:v:0", "-c", "copy",
|
|
222
|
+
"-f", "segment", "-segment_format", "mp4", "-segment_times", String(BADGE_SECONDS),
|
|
223
|
+
"-reset_timestamps", "1", segments,
|
|
224
|
+
], signal);
|
|
225
|
+
if (code !== 0) throw new MediaError(`Could not split ${part} for the badge:\n${stderr.trim()}`);
|
|
226
|
+
|
|
227
|
+
const head = join(tmp, "seg0.mp4");
|
|
228
|
+
const tail = join(tmp, "seg1.mp4");
|
|
229
|
+
const encodedHead = join(tmp, "head.mp4");
|
|
230
|
+
await encodeWithBadge(head, encodedHead, overlay, ["-an"], signal);
|
|
231
|
+
|
|
232
|
+
// Re-encoding rebases the head to start at 0; the source's own first frame usually sits a
|
|
233
|
+
// little later. Shifting the joined video back by that much keeps it in step with the
|
|
234
|
+
// audio, which is copied from `part` with its original timing.
|
|
235
|
+
const offset = await firstVideoPts(part, signal);
|
|
236
|
+
|
|
237
|
+
const videoInput: string[] = [];
|
|
238
|
+
if (await Bun.file(tail).exists()) {
|
|
239
|
+
// Every piece must share a timescale, or the concat demuxer rescales them into nonsense.
|
|
240
|
+
const retimed = join(tmp, "tail.mp4");
|
|
241
|
+
const remux = await run(
|
|
242
|
+
["ffmpeg", "-y", "-i", tail, "-c", "copy", "-video_track_timescale", TIMESCALE, retimed],
|
|
243
|
+
signal,
|
|
244
|
+
);
|
|
245
|
+
if (remux.code !== 0) throw new MediaError(`Could not remux the clip tail:\n${remux.stderr.trim()}`);
|
|
246
|
+
|
|
247
|
+
// The tail is placed one frame after the *encoded* head's last frame. Stating the
|
|
248
|
+
// duration explicitly matters: left to itself, concat offsets by the head's container
|
|
249
|
+
// duration, which rounds and opens a visible gap at the join.
|
|
250
|
+
const headEnd = (await lastVideoPts(encodedHead, signal)) + (await frameDuration(encodedHead, signal));
|
|
251
|
+
const list = join(tmp, "concat.txt");
|
|
252
|
+
await Bun.write(
|
|
253
|
+
list,
|
|
254
|
+
`ffconcat version 1.0\nfile '${concatPath(encodedHead)}'\nduration ${headEnd.toFixed(6)}\nfile '${concatPath(retimed)}'\n`,
|
|
255
|
+
);
|
|
256
|
+
videoInput.push("-f", "concat", "-safe", "0", "-i", list);
|
|
257
|
+
} else {
|
|
258
|
+
// No keyframe past the badge — the split produced only a head, which is the whole clip.
|
|
259
|
+
videoInput.push("-i", encodedHead);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const mux = await run([
|
|
263
|
+
"ffmpeg", "-y", "-itsoffset", offset.toFixed(6), ...videoInput, "-i", part,
|
|
264
|
+
"-map", "0:v:0", "-map", "1:a:0?", "-c", "copy", "-movflags", "+faststart", outFile,
|
|
265
|
+
], signal);
|
|
266
|
+
if (mux.code !== 0) throw new MediaError(`Could not assemble ${outFile}:\n${mux.stderr.trim()}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Cut [start, end] out of `video`.
|
|
271
|
+
*
|
|
272
|
+
* Without an overlay this is a pure stream-copy. With one, the badge is burned in — see
|
|
273
|
+
* `badgeByReencodingHead` for how that avoids re-encoding the whole clip.
|
|
274
|
+
*/
|
|
275
|
+
export async function cutClip(
|
|
276
|
+
video: string,
|
|
277
|
+
outFile: string,
|
|
278
|
+
start: number,
|
|
279
|
+
end: number,
|
|
280
|
+
signal?: AbortSignal,
|
|
281
|
+
overlay?: Overlay,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
require_("ffmpeg");
|
|
284
|
+
const stop = end < start + 0.1 ? start + 0.1 : end;
|
|
285
|
+
|
|
286
|
+
if (!overlay) return copyCut(video, outFile, start, stop, signal);
|
|
287
|
+
|
|
288
|
+
// Staged next to the clip, not in the system temp dir: these intermediates are as big as
|
|
289
|
+
// the part itself, and /tmp is often a smaller (or different) volume than the output.
|
|
290
|
+
const tmp = await mkdtemp(join(dirname(outFile), ".badge-"));
|
|
291
|
+
try {
|
|
292
|
+
// Cut first, then draw on the cut. The badge's fade is timed from the clip's own start,
|
|
293
|
+
// and a copy-cut is near-free, so this costs nothing over cutting and encoding at once.
|
|
294
|
+
const part = join(tmp, "part.mp4");
|
|
295
|
+
await copyCut(video, part, start, stop, signal);
|
|
296
|
+
|
|
297
|
+
// Splicing a re-encoded head onto a copied tail only works when both are the same codec.
|
|
298
|
+
if ((await videoCodec(part, signal)) === "h264") {
|
|
299
|
+
try {
|
|
300
|
+
await badgeByReencodingHead(part, outFile, overlay, tmp, signal);
|
|
301
|
+
return;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
if (signal?.aborted) throw err;
|
|
304
|
+
// Any surprise in the splice (odd keyframes, VFR, an exotic profile) falls back to
|
|
305
|
+
// the slow path, which handles anything ffmpeg can decode.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
await encodeWithBadge(part, outFile, overlay, ["-map", "0:a:0?", "-c:a", "copy", "-movflags", "+faststart"], signal);
|
|
309
|
+
} finally {
|
|
310
|
+
await rm(tmp, { recursive: true, force: true }).catch(() => {});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The burned-in "PART 2" badge: where it can sit, and the ffmpeg filter that draws it.
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
/** Fixed spots the badge can be pinned to — picked per job in the New split form. */
|
|
6
|
+
export const OVERLAY_POSITIONS = [
|
|
7
|
+
"top-left",
|
|
8
|
+
"top-center",
|
|
9
|
+
"top-right",
|
|
10
|
+
"center",
|
|
11
|
+
"bottom-left",
|
|
12
|
+
"bottom-center",
|
|
13
|
+
"bottom-right",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type OverlayPosition = (typeof OVERLAY_POSITIONS)[number];
|
|
17
|
+
|
|
18
|
+
/** Facebook Reels and Shorts both cover the bottom and right edge with their own UI. */
|
|
19
|
+
export const DEFAULT_OVERLAY_POSITION: OverlayPosition = "top-center";
|
|
20
|
+
|
|
21
|
+
export function isOverlayPosition(value: unknown): value is OverlayPosition {
|
|
22
|
+
return typeof value === "string" && (OVERLAY_POSITIONS as readonly string[]).includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Overlay {
|
|
26
|
+
/** Badge label, e.g. "PART 2" or "FINAL PART". */
|
|
27
|
+
text: string;
|
|
28
|
+
position: OverlayPosition;
|
|
29
|
+
/** Source dimensions — the badge is sized and inset relative to the frame. */
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Seconds the badge stays up, and how long it takes to fade out at the end of that.
|
|
36
|
+
* `BADGE_SECONDS` also decides where a clip is split for re-encoding — see `cutClip`.
|
|
37
|
+
*/
|
|
38
|
+
export const BADGE_SECONDS = 4;
|
|
39
|
+
const SHOW_FOR = BADGE_SECONDS;
|
|
40
|
+
const FADE = 0.75;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How far down a vertical frame the top row of badges starts. The Dynamic Island's bottom
|
|
44
|
+
* edge lands at ~7.1% of frame height on the worst case (iPhone 16 Pro), so this clears it.
|
|
45
|
+
*/
|
|
46
|
+
const TOP_SAFE_FRACTION = 0.12;
|
|
47
|
+
|
|
48
|
+
const FONT_CANDIDATES = [
|
|
49
|
+
"/System/Library/Fonts/Supplemental/Arial Bold.ttf", // macOS
|
|
50
|
+
"/System/Library/Fonts/Helvetica.ttc",
|
|
51
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", // Debian / Ubuntu
|
|
52
|
+
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", // Fedora / Arch
|
|
53
|
+
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
|
54
|
+
"C:/Windows/Fonts/arialbd.ttf", // Windows
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
let fontCache: string | null | undefined;
|
|
58
|
+
|
|
59
|
+
/** A concrete bold font file, or null to let drawtext resolve "Sans" via fontconfig. */
|
|
60
|
+
function fontFile(): string | null {
|
|
61
|
+
if (fontCache === undefined) {
|
|
62
|
+
// A path with a quote or backslash can't be escaped reliably inside a filtergraph.
|
|
63
|
+
fontCache = FONT_CANDIDATES.find((p) => !/['\\]/.test(p) && existsSync(p)) ?? null;
|
|
64
|
+
}
|
|
65
|
+
return fontCache;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Make `text` safe to sit inside a single-quoted drawtext option. A literal `'` cannot be
|
|
70
|
+
* escaped there without closing the quote, so it becomes a typographic apostrophe; `%` and
|
|
71
|
+
* `\` would otherwise start an expansion or an escape.
|
|
72
|
+
*/
|
|
73
|
+
function escapeText(text: string): string {
|
|
74
|
+
return text.replace(/'/g, "’").replace(/\\/g, "").replace(/%/g, "%");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The `-vf` chain that burns `overlay` into a clip.
|
|
81
|
+
*
|
|
82
|
+
* `setpts=PTS-STARTPTS` rebases time to the clip, so the badge's 4-second window is
|
|
83
|
+
* measured from where the part begins rather than from the source's timeline.
|
|
84
|
+
*
|
|
85
|
+
* drawtext's `alpha` scales the glyph, border, *and* box colors together, so a single
|
|
86
|
+
* expression fades the whole badge out as one.
|
|
87
|
+
*/
|
|
88
|
+
export function overlayFilter(overlay: Overlay): string {
|
|
89
|
+
const { width, height, position } = overlay;
|
|
90
|
+
const short = Math.min(width, height) || 1080;
|
|
91
|
+
|
|
92
|
+
const fontsize = clamp(Math.round(short * 0.055), 22, 96);
|
|
93
|
+
const pad = Math.round(fontsize * 0.45); // box padding around the glyphs
|
|
94
|
+
const margin = Math.round(short * 0.045); // gap between the box and the frame edge
|
|
95
|
+
|
|
96
|
+
// The box extends `pad` beyond the text on every side, so the text is inset by
|
|
97
|
+
// `margin + pad` to leave the *box* sitting `margin` from the edge.
|
|
98
|
+
const inset = margin + pad;
|
|
99
|
+
|
|
100
|
+
// A vertical clip plays edge-to-edge on an iPhone, putting the top of the frame under the
|
|
101
|
+
// Dynamic Island — which reaches ~7% of the way down. Top-row badges drop below it. A
|
|
102
|
+
// horizontal clip is letterboxed well clear of the island, so it keeps the plain margin.
|
|
103
|
+
const topInset = height > width ? Math.round(height * TOP_SAFE_FRACTION) + pad : inset;
|
|
104
|
+
|
|
105
|
+
const [vert, horiz] = position === "center" ? ["center", "center"] : position.split("-");
|
|
106
|
+
|
|
107
|
+
const x =
|
|
108
|
+
horiz === "left" ? `${inset}` : horiz === "right" ? `w-text_w-${inset}` : "(w-text_w)/2";
|
|
109
|
+
const y =
|
|
110
|
+
vert === "top" ? `${topInset}` : vert === "bottom" ? `h-text_h-${inset}` : "(h-text_h)/2";
|
|
111
|
+
|
|
112
|
+
const solidUntil = SHOW_FOR - FADE;
|
|
113
|
+
const alpha = `if(lt(t,${solidUntil}),1,if(lt(t,${SHOW_FOR}),(${SHOW_FOR}-t)/${FADE},0))`;
|
|
114
|
+
|
|
115
|
+
const font = fontFile();
|
|
116
|
+
const opts = [
|
|
117
|
+
font ? `fontfile='${font}'` : "font='Sans'",
|
|
118
|
+
`text='${escapeText(overlay.text)}'`,
|
|
119
|
+
`fontsize=${fontsize}`,
|
|
120
|
+
"fontcolor=white",
|
|
121
|
+
`borderw=${Math.max(2, Math.round(fontsize * 0.04))}`,
|
|
122
|
+
"bordercolor=black@0.85",
|
|
123
|
+
"box=1",
|
|
124
|
+
"boxcolor=black@0.5",
|
|
125
|
+
`boxborderw=${pad}`,
|
|
126
|
+
`x=${x}`,
|
|
127
|
+
`y=${y}`,
|
|
128
|
+
`alpha='${alpha}'`,
|
|
129
|
+
// Skip the filter entirely once the badge is gone, rather than drawing it invisibly.
|
|
130
|
+
`enable='lt(t,${SHOW_FOR})'`,
|
|
131
|
+
];
|
|
132
|
+
return `setpts=PTS-STARTPTS,drawtext=${opts.join(":")}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The badge text for a part: "PART 3", or "FINAL PART" for the last one. */
|
|
136
|
+
export function overlayText(index: number, total: number): string {
|
|
137
|
+
return index === total && total > 1 ? "FINAL PART" : `PART ${index}`;
|
|
138
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Split the whole video into contiguous parts, cutting on pauses.
|
|
2
|
+
|
|
3
|
+
export interface Part {
|
|
4
|
+
index: number;
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
title: string;
|
|
8
|
+
hashtags: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function duration(p: Part): number {
|
|
12
|
+
return p.end - p.start;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toParts(spans: [number, number][]): Part[] {
|
|
16
|
+
return spans.map(([start, end], i) => ({ index: i + 1, start, end, title: "", hashtags: [] }));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Chunk the video into contiguous parts, snapping each cut to the nearest pause.
|
|
21
|
+
*
|
|
22
|
+
* `boundaries` is a list of candidate cut times (pause midpoints). Parts are contiguous
|
|
23
|
+
* and cover 0 → `totalDuration` entirely, cut on pauses so none ends mid-sentence.
|
|
24
|
+
*
|
|
25
|
+
* `targetLen` is a **cap**, not a quota: the video is divided into the fewest parts that
|
|
26
|
+
* keeps each one around or under it (`ceil(total / targetLen)`), then split *evenly* at
|
|
27
|
+
* that count. So a 96-minute video with a 15-minute target becomes 7 parts of ~13.8 min —
|
|
28
|
+
* not 6 full 15-min parts plus a 6-min stub. Snapping a cut to a real pause can still
|
|
29
|
+
* carry a part a little past the cap when pauses are sparse.
|
|
30
|
+
*
|
|
31
|
+
* If even parts would come out shorter than `minLast`, one part is dropped and the rest
|
|
32
|
+
* stretch to absorb it (e.g. 46 min at a 15-min target → 3 × 15.3 min, not 4 × 11.5 min).
|
|
33
|
+
*/
|
|
34
|
+
export function buildParts(
|
|
35
|
+
boundaries: number[],
|
|
36
|
+
totalDuration: number,
|
|
37
|
+
targetLen = 900,
|
|
38
|
+
minLast?: number,
|
|
39
|
+
): Part[] {
|
|
40
|
+
const minTail = minLast ?? targetLen * 0.8; // e.g. 12 min for a 15-min target
|
|
41
|
+
const bounds = boundaries.filter((b) => b > 0 && b < totalDuration).sort((a, b) => a - b);
|
|
42
|
+
|
|
43
|
+
if (totalDuration <= targetLen) return toParts([[0, totalDuration]]);
|
|
44
|
+
|
|
45
|
+
const nMax = Math.ceil(totalDuration / targetLen);
|
|
46
|
+
for (let n = nMax; n >= 1; n--) {
|
|
47
|
+
const spans = splitInto(bounds, totalDuration, n);
|
|
48
|
+
const last = spans[spans.length - 1]!;
|
|
49
|
+
if (n <= 1 || last[1] - last[0] >= minTail) return toParts(spans);
|
|
50
|
+
}
|
|
51
|
+
return toParts([[0, totalDuration]]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build up to `n` contiguous spans of ~`total / n` each, cut on the nearest pause.
|
|
56
|
+
*
|
|
57
|
+
* Cut i aims at the *absolute* mark `i * total / n` rather than `previousCut + length`, so
|
|
58
|
+
* a cut that lands late doesn't push every later cut late too (that drift used to shave
|
|
59
|
+
* minutes off the final part).
|
|
60
|
+
*/
|
|
61
|
+
function splitInto(bounds: number[], total: number, n: number): [number, number][] {
|
|
62
|
+
const spans: [number, number][] = [];
|
|
63
|
+
let cut = 0;
|
|
64
|
+
for (let i = 1; i < n; i++) {
|
|
65
|
+
const targetT = (total * i) / n;
|
|
66
|
+
// Nearest pause that still moves us forward and leaves room for a final part.
|
|
67
|
+
let best: number | undefined;
|
|
68
|
+
for (const b of bounds) {
|
|
69
|
+
if (b <= cut + 1 || b >= total - 0.5) continue;
|
|
70
|
+
if (best === undefined || Math.abs(b - targetT) < Math.abs(best - targetT)) best = b;
|
|
71
|
+
}
|
|
72
|
+
if (best === undefined) break;
|
|
73
|
+
spans.push([cut, best]);
|
|
74
|
+
cut = best;
|
|
75
|
+
}
|
|
76
|
+
spans.push([cut, total]); // final part takes the rest
|
|
77
|
+
return spans;
|
|
78
|
+
}
|