@postfetch/core 0.10.0 → 0.10.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 +12 -8
- package/dist/audio-slider.d.ts +11 -7
- package/dist/index.js +39 -7
- package/package.json +1 -1
- package/src/audio-slider.ts +48 -14
package/README.md
CHANGED
|
@@ -25,15 +25,19 @@ set, and throws if the complete result cannot be produced. The legacy
|
|
|
25
25
|
`(url, headers?, options?)` overload remains supported for non-remux downloads.
|
|
26
26
|
|
|
27
27
|
`buildAudioSliderVideo(items, { delay: 3000 })` returns an MP4 `Blob` from one or
|
|
28
|
-
more images/videos followed by exactly one audio item. `delay` is
|
|
29
|
-
per visual
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
more images/videos followed by exactly one audio item. `delay` is the minimum
|
|
29
|
+
milliseconds per visual. The actual slot is the larger of that minimum and
|
|
30
|
+
audio duration / visual count, rounded up to 30 fps (minimum two frames), so
|
|
31
|
+
the complete audio plays. Slots include a right-to-left transition lasting up
|
|
32
|
+
to 300 ms or half the slot. The last visual stays visible;
|
|
33
|
+
a single visual has no transition. Total duration is visual count × computed
|
|
34
|
+
slot duration. Videos loop or trim to fit and their own audio is discarded; the trailing
|
|
35
|
+
audio loops and trims only when the minimum makes the slideshow longer than the
|
|
36
|
+
track. Otherwise it plays once, padding the rounding remainder with silence.
|
|
37
|
+
The default 720×1280 canvas preserves
|
|
34
38
|
aspect ratio with black padding. `AudioSliderVideoOptions` supports `width` and
|
|
35
|
-
`height` (positive even integers), `fetch`, and `
|
|
36
|
-
FFmpeg with `libx264` and a runtime with filesystem/process access (Node, Bun,
|
|
39
|
+
`height` (positive even integers), `fetch`, `ffmpegPath`, and `ffprobePath`. Requires
|
|
40
|
+
local FFprobe, FFmpeg with `libx264` and a runtime with filesystem/process access (Node, Bun,
|
|
37
41
|
Deno). Downloads retain item headers and assemble HLS; temporary files are
|
|
38
42
|
cleaned up on success and failure.
|
|
39
43
|
|
package/dist/audio-slider.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type DownloadOptions } from "./download";
|
|
|
2
2
|
import { type MediaItem } from "./internal";
|
|
3
3
|
/** Options for {@link buildAudioSliderVideo}. Requires a local FFmpeg binary. */
|
|
4
4
|
export type AudioSliderVideoOptions = DownloadOptions & {
|
|
5
|
-
/**
|
|
5
|
+
/** Minimum time per visual item in milliseconds, including its outgoing transition. */
|
|
6
6
|
delay: number;
|
|
7
7
|
/** Output width in pixels; a positive even integer. Defaults to 720. */
|
|
8
8
|
width?: number;
|
|
@@ -10,21 +10,25 @@ export type AudioSliderVideoOptions = DownloadOptions & {
|
|
|
10
10
|
height?: number;
|
|
11
11
|
/** FFmpeg command or executable path. Defaults to `ffmpeg` from PATH. */
|
|
12
12
|
ffmpegPath?: string;
|
|
13
|
+
/** FFprobe command or executable path used to measure audio. Defaults to `ffprobe`. */
|
|
14
|
+
ffprobePath?: string;
|
|
13
15
|
};
|
|
14
16
|
/**
|
|
15
17
|
* Build an H.264/AAC MP4 Blob from one or more images/videos followed by exactly
|
|
16
18
|
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
17
19
|
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
18
20
|
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
19
|
-
* audio is discarded. The final audio item
|
|
21
|
+
* audio is discarded. The final audio item plays in full; when the minimum
|
|
22
|
+
* slideshow duration is longer, it loops and is trimmed to the result.
|
|
20
23
|
*
|
|
21
|
-
* Each visual gets `delay` ms
|
|
22
|
-
*
|
|
24
|
+
* Each visual gets at least `delay` ms or audio duration / visual count,
|
|
25
|
+
* whichever is longer (rounded up to 30 fps, minimum two frames). Adjacent
|
|
26
|
+
* visuals slide right to left over the last 300 ms or half the slot, whichever
|
|
23
27
|
* is shorter. The last visual stays on screen; a single visual has no transition.
|
|
24
|
-
* Total duration is the number of visuals times the
|
|
28
|
+
* Total duration is the number of visuals times the computed slot duration.
|
|
25
29
|
*
|
|
26
|
-
* Requires Node, Bun or Deno with filesystem/process access and FFmpeg
|
|
27
|
-
* libx264. Temporary files are removed on success and failure.
|
|
30
|
+
* Requires Node, Bun or Deno with filesystem/process access, FFprobe and FFmpeg
|
|
31
|
+
* with libx264. Temporary files are removed on success and failure.
|
|
28
32
|
*
|
|
29
33
|
* @example
|
|
30
34
|
* ```ts
|
package/dist/index.js
CHANGED
|
@@ -3331,13 +3331,10 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3331
3331
|
}
|
|
3332
3332
|
}
|
|
3333
3333
|
const visuals = items.slice(0, -1);
|
|
3334
|
-
const
|
|
3335
|
-
if (!Number.isSafeInteger(
|
|
3334
|
+
const minimumFrames = Math.max(2, Math.ceil(options.delay * fps / 1000));
|
|
3335
|
+
if (!Number.isSafeInteger(minimumFrames * visuals.length)) {
|
|
3336
3336
|
throw new PostfetchError(400, "delay produces an unsupported video duration");
|
|
3337
3337
|
}
|
|
3338
|
-
const seconds = frames / fps;
|
|
3339
|
-
const transition = Math.min(0.3, seconds / 2);
|
|
3340
|
-
const duration = seconds * visuals.length;
|
|
3341
3338
|
const io = await nodeRuntime();
|
|
3342
3339
|
const directory = await io.makeTempDir("postfetch-slider-");
|
|
3343
3340
|
try {
|
|
@@ -3348,12 +3345,37 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3348
3345
|
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
3349
3346
|
paths.push(path);
|
|
3350
3347
|
}
|
|
3348
|
+
const probe = await io.run(options.ffprobePath ?? "ffprobe", [
|
|
3349
|
+
"-v",
|
|
3350
|
+
"error",
|
|
3351
|
+
"-select_streams",
|
|
3352
|
+
"a:0",
|
|
3353
|
+
"-show_entries",
|
|
3354
|
+
"stream=duration:format=duration",
|
|
3355
|
+
"-of",
|
|
3356
|
+
"json",
|
|
3357
|
+
paths[visuals.length]
|
|
3358
|
+
]);
|
|
3359
|
+
if (!probe.success) {
|
|
3360
|
+
throw new PostfetchError(500, `Audio slider duration probe failed: ${probe.stderr}`);
|
|
3361
|
+
}
|
|
3362
|
+
const audioDuration = readAudioDuration(probe.stdout);
|
|
3363
|
+
const frames = Math.max(minimumFrames, Math.ceil(audioDuration * fps / visuals.length));
|
|
3364
|
+
if (!Number.isSafeInteger(frames * visuals.length)) {
|
|
3365
|
+
throw new PostfetchError(500, "Audio slider duration is too large");
|
|
3366
|
+
}
|
|
3367
|
+
const seconds = frames / fps;
|
|
3368
|
+
const transition = Math.min(0.3, seconds / 2);
|
|
3369
|
+
const duration = seconds * visuals.length;
|
|
3351
3370
|
const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
|
|
3352
3371
|
for (const [index, item] of visuals.entries()) {
|
|
3353
3372
|
args.push(...item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]);
|
|
3354
3373
|
args.push("-i", paths[index]);
|
|
3355
3374
|
}
|
|
3356
|
-
|
|
3375
|
+
if (audioDuration < minimumFrames * visuals.length / fps) {
|
|
3376
|
+
args.push("-stream_loop", "-1");
|
|
3377
|
+
}
|
|
3378
|
+
args.push("-i", paths[visuals.length]);
|
|
3357
3379
|
const filters = visuals.map((_, index) => `[${index}:v:0]setpts=PTS-STARTPTS,` + `scale=${width}:${height2}:force_original_aspect_ratio=decrease:force_divisible_by=2,` + `pad=${width}:${height2}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},` + `format=yuv444p,trim=duration=${seconds + (index > 0 ? transition : 0)},setpts=PTS-STARTPTS,fps=${fps},settb=AVTB[v${index}]`);
|
|
3358
3380
|
let current = "v0";
|
|
3359
3381
|
for (let index = 1;index < visuals.length; index += 1) {
|
|
@@ -3362,7 +3384,7 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3362
3384
|
current = next;
|
|
3363
3385
|
}
|
|
3364
3386
|
filters.push(`[${current}]format=yuv420p[outv]`);
|
|
3365
|
-
filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,atrim=duration=${duration}[outa]`);
|
|
3387
|
+
filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,apad,atrim=duration=${duration}[outa]`);
|
|
3366
3388
|
const output = `${directory}/slideshow.mp4`;
|
|
3367
3389
|
args.push("-filter_complex", filters.join(";"), "-map", "[outv]", "-map", "[outa]", "-t", String(duration), "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output);
|
|
3368
3390
|
const result = await io.run(options.ffmpegPath ?? "ffmpeg", args);
|
|
@@ -3380,6 +3402,16 @@ async function buildAudioSliderVideo(items, options) {
|
|
|
3380
3402
|
});
|
|
3381
3403
|
}
|
|
3382
3404
|
}
|
|
3405
|
+
function readAudioDuration(stdout) {
|
|
3406
|
+
try {
|
|
3407
|
+
const parsed = JSON.parse(stdout);
|
|
3408
|
+
const stream = parsed.streams?.[0];
|
|
3409
|
+
const duration = [stream?.duration, parsed.format?.duration].map(Number).find((value) => Number.isFinite(value) && value > 0);
|
|
3410
|
+
if (stream && duration !== undefined)
|
|
3411
|
+
return duration;
|
|
3412
|
+
} catch {}
|
|
3413
|
+
throw new PostfetchError(500, "Audio slider audio duration is unavailable");
|
|
3414
|
+
}
|
|
3383
3415
|
export {
|
|
3384
3416
|
toResponse,
|
|
3385
3417
|
postfetch,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Zero-dependency typed core that turns Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube post URLs into media files.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/audio-slider.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { nodeRuntime } from "./mp4-remux";
|
|
|
4
4
|
|
|
5
5
|
/** Options for {@link buildAudioSliderVideo}. Requires a local FFmpeg binary. */
|
|
6
6
|
export type AudioSliderVideoOptions = DownloadOptions & {
|
|
7
|
-
/**
|
|
7
|
+
/** Minimum time per visual item in milliseconds, including its outgoing transition. */
|
|
8
8
|
delay: number;
|
|
9
9
|
/** Output width in pixels; a positive even integer. Defaults to 720. */
|
|
10
10
|
width?: number;
|
|
@@ -12,6 +12,8 @@ export type AudioSliderVideoOptions = DownloadOptions & {
|
|
|
12
12
|
height?: number;
|
|
13
13
|
/** FFmpeg command or executable path. Defaults to `ffmpeg` from PATH. */
|
|
14
14
|
ffmpegPath?: string;
|
|
15
|
+
/** FFprobe command or executable path used to measure audio. Defaults to `ffprobe`. */
|
|
16
|
+
ffprobePath?: string;
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
const fps = 30;
|
|
@@ -21,15 +23,17 @@ const fps = 30;
|
|
|
21
23
|
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
22
24
|
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
23
25
|
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
24
|
-
* audio is discarded. The final audio item
|
|
26
|
+
* audio is discarded. The final audio item plays in full; when the minimum
|
|
27
|
+
* slideshow duration is longer, it loops and is trimmed to the result.
|
|
25
28
|
*
|
|
26
|
-
* Each visual gets `delay` ms
|
|
27
|
-
*
|
|
29
|
+
* Each visual gets at least `delay` ms or audio duration / visual count,
|
|
30
|
+
* whichever is longer (rounded up to 30 fps, minimum two frames). Adjacent
|
|
31
|
+
* visuals slide right to left over the last 300 ms or half the slot, whichever
|
|
28
32
|
* is shorter. The last visual stays on screen; a single visual has no transition.
|
|
29
|
-
* Total duration is the number of visuals times the
|
|
33
|
+
* Total duration is the number of visuals times the computed slot duration.
|
|
30
34
|
*
|
|
31
|
-
* Requires Node, Bun or Deno with filesystem/process access and FFmpeg
|
|
32
|
-
* libx264. Temporary files are removed on success and failure.
|
|
35
|
+
* Requires Node, Bun or Deno with filesystem/process access, FFprobe and FFmpeg
|
|
36
|
+
* with libx264. Temporary files are removed on success and failure.
|
|
33
37
|
*
|
|
34
38
|
* @example
|
|
35
39
|
* ```ts
|
|
@@ -59,13 +63,10 @@ export async function buildAudioSliderVideo(
|
|
|
59
63
|
}
|
|
60
64
|
}
|
|
61
65
|
const visuals = items.slice(0, -1);
|
|
62
|
-
const
|
|
63
|
-
if (!Number.isSafeInteger(
|
|
66
|
+
const minimumFrames = Math.max(2, Math.ceil(options.delay * fps / 1000));
|
|
67
|
+
if (!Number.isSafeInteger(minimumFrames * visuals.length)) {
|
|
64
68
|
throw new PostfetchError(400, "delay produces an unsupported video duration");
|
|
65
69
|
}
|
|
66
|
-
const seconds = frames / fps;
|
|
67
|
-
const transition = Math.min(0.3, seconds / 2);
|
|
68
|
-
const duration = seconds * visuals.length;
|
|
69
70
|
const io = await nodeRuntime();
|
|
70
71
|
const directory = await io.makeTempDir("postfetch-slider-");
|
|
71
72
|
try {
|
|
@@ -77,12 +78,32 @@ export async function buildAudioSliderVideo(
|
|
|
77
78
|
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
78
79
|
paths.push(path);
|
|
79
80
|
}
|
|
81
|
+
const probe = await io.run(options.ffprobePath ?? "ffprobe", [
|
|
82
|
+
"-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
83
|
+
"stream=duration:format=duration", "-of", "json", paths[visuals.length],
|
|
84
|
+
]);
|
|
85
|
+
if (!probe.success) {
|
|
86
|
+
throw new PostfetchError(500, `Audio slider duration probe failed: ${probe.stderr}`);
|
|
87
|
+
}
|
|
88
|
+
const audioDuration = readAudioDuration(probe.stdout);
|
|
89
|
+
const frames = Math.max(minimumFrames, Math.ceil(audioDuration * fps / visuals.length));
|
|
90
|
+
if (!Number.isSafeInteger(frames * visuals.length)) {
|
|
91
|
+
throw new PostfetchError(500, "Audio slider duration is too large");
|
|
92
|
+
}
|
|
93
|
+
const seconds = frames / fps;
|
|
94
|
+
const transition = Math.min(0.3, seconds / 2);
|
|
95
|
+
const duration = seconds * visuals.length;
|
|
80
96
|
const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
|
|
81
97
|
for (const [index, item] of visuals.entries()) {
|
|
82
98
|
args.push(...(item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]));
|
|
83
99
|
args.push("-i", paths[index]);
|
|
84
100
|
}
|
|
85
|
-
|
|
101
|
+
// If the track sets the duration, play it once and pad only the frame-rounding
|
|
102
|
+
// remainder; do not start the song again for a fraction of a second.
|
|
103
|
+
if (audioDuration < minimumFrames * visuals.length / fps) {
|
|
104
|
+
args.push("-stream_loop", "-1");
|
|
105
|
+
}
|
|
106
|
+
args.push("-i", paths[visuals.length]);
|
|
86
107
|
const filters = visuals.map((_, index) =>
|
|
87
108
|
`[${index}:v:0]setpts=PTS-STARTPTS,` +
|
|
88
109
|
`scale=${width}:${height}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
|
|
@@ -96,7 +117,7 @@ export async function buildAudioSliderVideo(
|
|
|
96
117
|
current = next;
|
|
97
118
|
}
|
|
98
119
|
filters.push(`[${current}]format=yuv420p[outv]`);
|
|
99
|
-
filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,atrim=duration=${duration}[outa]`);
|
|
120
|
+
filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,apad,atrim=duration=${duration}[outa]`);
|
|
100
121
|
const output = `${directory}/slideshow.mp4`;
|
|
101
122
|
args.push(
|
|
102
123
|
"-filter_complex", filters.join(";"), "-map", "[outv]", "-map", "[outa]",
|
|
@@ -116,3 +137,16 @@ export async function buildAudioSliderVideo(
|
|
|
116
137
|
await io.removeDir(directory).catch(() => undefined);
|
|
117
138
|
}
|
|
118
139
|
}
|
|
140
|
+
|
|
141
|
+
function readAudioDuration(stdout: string): number {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(stdout) as { streams?: Array<{ duration?: string }>; format?: { duration?: string } };
|
|
144
|
+
const stream = parsed.streams?.[0];
|
|
145
|
+
const duration = [stream?.duration, parsed.format?.duration]
|
|
146
|
+
.map(Number).find((value) => Number.isFinite(value) && value > 0);
|
|
147
|
+
if (stream && duration !== undefined) return duration;
|
|
148
|
+
} catch {
|
|
149
|
+
// Missing, malformed or non-finite metadata cannot guarantee a complete track.
|
|
150
|
+
}
|
|
151
|
+
throw new PostfetchError(500, "Audio slider audio duration is unavailable");
|
|
152
|
+
}
|