@postfetch/core 0.9.3 → 0.10.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 +21 -1
- package/dist/audio-slider.d.ts +36 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +68 -0
- package/dist/mp4-remux.d.ts +1 -0
- package/package.json +1 -1
- package/src/audio-slider.ts +118 -0
- package/src/index.ts +1 -0
- package/src/mp4-remux.ts +1 -1
package/README.md
CHANGED
|
@@ -24,9 +24,29 @@ defaults to off, uses `ffmpeg` and `ffprobe` from `PATH` unless their paths are
|
|
|
24
24
|
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
|
+
`buildAudioSliderVideo(items, { delay: 3000 })` returns an MP4 `Blob` from one or
|
|
28
|
+
more images/videos followed by exactly one audio item. `delay` is milliseconds
|
|
29
|
+
per visual, rounded to 30 fps (minimum two frames), including a right-to-left
|
|
30
|
+
transition lasting up to 300 ms or half the delay. The last visual stays visible;
|
|
31
|
+
a single visual has no transition. Total duration is visual count × rounded
|
|
32
|
+
delay. Videos loop or trim to fit and their own audio is discarded; the trailing
|
|
33
|
+
audio loops and trims to the result. The default 720×1280 canvas preserves
|
|
34
|
+
aspect ratio with black padding. `AudioSliderVideoOptions` supports `width` and
|
|
35
|
+
`height` (positive even integers), `fetch`, and `ffmpegPath`. Requires local
|
|
36
|
+
FFmpeg with `libx264` and a runtime with filesystem/process access (Node, Bun,
|
|
37
|
+
Deno). Downloads retain item headers and assemble HLS; temporary files are
|
|
38
|
+
cleaned up on success and failure.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { buildAudioSliderVideo } from "@postfetch/core";
|
|
42
|
+
|
|
43
|
+
const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
|
|
44
|
+
form.append("video", blob, "slideshow.mp4");
|
|
45
|
+
```
|
|
46
|
+
|
|
27
47
|
- `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number; tryMaxBytes?: number }`. `tryMaxBytes` is a soft byte cap. X probes every MP4 variant and selects the highest-quality complete result that fits, falling back to its smallest variants when none do. Other platforms return a smaller available rendition when possible and otherwise keep the normal result. Inject `fetch` to unit-test resolvers offline.
|
|
28
48
|
- Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
|
|
29
49
|
- No runtime dependencies, no `yt-dlp`, no cookies. `ffmpeg` is only used when
|
|
30
|
-
|
|
50
|
+
`downloadBlob(..., { remux: true })` or `buildAudioSliderVideo` is requested.
|
|
31
51
|
|
|
32
52
|
See the [root README](https://github.com/chelokot/postfetch#readme) for the full API table, supported inputs and the fingerprint design.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type DownloadOptions } from "./download";
|
|
2
|
+
import { type MediaItem } from "./internal";
|
|
3
|
+
/** Options for {@link buildAudioSliderVideo}. Requires a local FFmpeg binary. */
|
|
4
|
+
export type AudioSliderVideoOptions = DownloadOptions & {
|
|
5
|
+
/** Time per visual item in milliseconds, including its outgoing transition. */
|
|
6
|
+
delay: number;
|
|
7
|
+
/** Output width in pixels; a positive even integer. Defaults to 720. */
|
|
8
|
+
width?: number;
|
|
9
|
+
/** Output height in pixels; a positive even integer. Defaults to 1280. */
|
|
10
|
+
height?: number;
|
|
11
|
+
/** FFmpeg command or executable path. Defaults to `ffmpeg` from PATH. */
|
|
12
|
+
ffmpegPath?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Build an H.264/AAC MP4 Blob from one or more images/videos followed by exactly
|
|
16
|
+
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
17
|
+
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
18
|
+
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
19
|
+
* audio is discarded. The final audio item loops and is trimmed to the result.
|
|
20
|
+
*
|
|
21
|
+
* Each visual gets `delay` ms (rounded to 30 fps, minimum two frames). Adjacent
|
|
22
|
+
* visuals slide right to left over the last 300 ms or half the delay, whichever
|
|
23
|
+
* is shorter. The last visual stays on screen; a single visual has no transition.
|
|
24
|
+
* Total duration is the number of visuals times the rounded delay.
|
|
25
|
+
*
|
|
26
|
+
* Requires Node, Bun or Deno with filesystem/process access and FFmpeg with
|
|
27
|
+
* libx264. Temporary files are removed on success and failure.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const result = await postfetch(photoUrl);
|
|
32
|
+
* const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
|
|
33
|
+
* form.append("video", blob, "slideshow.mp4");
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildAudioSliderVideo(items: readonly MediaItem[], options: AudioSliderVideoOptions): Promise<Blob>;
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* @module
|
|
21
21
|
*/
|
|
22
22
|
export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
23
|
+
export { buildAudioSliderVideo, type AudioSliderVideoOptions } from "./audio-slider";
|
|
23
24
|
export { download, downloadBlob, archive, toResponse, type Archive, type DownloadBlobOptions, type DownloadOptions, type RemuxedVideo, } from "./download";
|
|
24
25
|
export { PostfetchError } from "./internal";
|
|
25
26
|
export type { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostfetchReason, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, TwitterQuotedTweet, YoutubeExtra, } from "./internal";
|
package/dist/index.js
CHANGED
|
@@ -3313,12 +3313,80 @@ async function fetchBytes(net, url, headers) {
|
|
|
3313
3313
|
function toArrayBuffer(bytes) {
|
|
3314
3314
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
3315
3315
|
}
|
|
3316
|
+
|
|
3317
|
+
// src/audio-slider.ts
|
|
3318
|
+
var fps = 30;
|
|
3319
|
+
async function buildAudioSliderVideo(items, options) {
|
|
3320
|
+
if (!Array.isArray(items) || items.length < 2 || items.at(-1)?.kind !== "audio" || Array.from(items.slice(0, -1)).some((item) => item?.kind !== "image" && item?.kind !== "video")) {
|
|
3321
|
+
throw new PostfetchError(400, "Expected one or more image/video items followed by exactly one audio item");
|
|
3322
|
+
}
|
|
3323
|
+
if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
|
|
3324
|
+
throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
|
|
3325
|
+
}
|
|
3326
|
+
const width = options.width ?? 720;
|
|
3327
|
+
const height2 = options.height ?? 1280;
|
|
3328
|
+
for (const [name, value] of [["width", width], ["height", height2]]) {
|
|
3329
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value % 2 !== 0) {
|
|
3330
|
+
throw new PostfetchError(400, `${name} must be a positive even integer`);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
const visuals = items.slice(0, -1);
|
|
3334
|
+
const frames = Math.max(2, Math.round(options.delay * fps / 1000));
|
|
3335
|
+
if (!Number.isSafeInteger(frames * visuals.length)) {
|
|
3336
|
+
throw new PostfetchError(400, "delay produces an unsupported video duration");
|
|
3337
|
+
}
|
|
3338
|
+
const seconds = frames / fps;
|
|
3339
|
+
const transition = Math.min(0.3, seconds / 2);
|
|
3340
|
+
const duration = seconds * visuals.length;
|
|
3341
|
+
const io = await nodeRuntime();
|
|
3342
|
+
const directory = await io.makeTempDir("postfetch-slider-");
|
|
3343
|
+
try {
|
|
3344
|
+
const paths = [];
|
|
3345
|
+
for (const [index, item] of items.entries()) {
|
|
3346
|
+
const path = `${directory}/input-${index}`;
|
|
3347
|
+
const response = await download(item, options);
|
|
3348
|
+
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
3349
|
+
paths.push(path);
|
|
3350
|
+
}
|
|
3351
|
+
const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
|
|
3352
|
+
for (const [index, item] of visuals.entries()) {
|
|
3353
|
+
args.push(...item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]);
|
|
3354
|
+
args.push("-i", paths[index]);
|
|
3355
|
+
}
|
|
3356
|
+
args.push("-stream_loop", "-1", "-i", paths[visuals.length]);
|
|
3357
|
+
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
|
+
let current = "v0";
|
|
3359
|
+
for (let index = 1;index < visuals.length; index += 1) {
|
|
3360
|
+
const next = `slide${index}`;
|
|
3361
|
+
filters.push(`[${current}][v${index}]xfade=transition=slideleft:duration=${transition}:offset=${index * seconds - transition},fps=${fps},settb=AVTB[${next}]`);
|
|
3362
|
+
current = next;
|
|
3363
|
+
}
|
|
3364
|
+
filters.push(`[${current}]format=yuv420p[outv]`);
|
|
3365
|
+
filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,atrim=duration=${duration}[outa]`);
|
|
3366
|
+
const output = `${directory}/slideshow.mp4`;
|
|
3367
|
+
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
|
+
const result = await io.run(options.ffmpegPath ?? "ffmpeg", args);
|
|
3369
|
+
if (!result.success) {
|
|
3370
|
+
throw new PostfetchError(500, `Audio slider video encoding failed: ${result.stderr}`);
|
|
3371
|
+
}
|
|
3372
|
+
const bytes = await io.readFile(output);
|
|
3373
|
+
if (bytes.length === 0) {
|
|
3374
|
+
throw new PostfetchError(500, "Audio slider video encoding produced an empty file");
|
|
3375
|
+
}
|
|
3376
|
+
return new Blob([new Uint8Array(bytes).buffer], { type: "video/mp4" });
|
|
3377
|
+
} finally {
|
|
3378
|
+
await io.removeDir(directory).catch(() => {
|
|
3379
|
+
return;
|
|
3380
|
+
});
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3316
3383
|
export {
|
|
3317
3384
|
toResponse,
|
|
3318
3385
|
postfetch,
|
|
3319
3386
|
downloadBlob,
|
|
3320
3387
|
download,
|
|
3321
3388
|
detect,
|
|
3389
|
+
buildAudioSliderVideo,
|
|
3322
3390
|
archive,
|
|
3323
3391
|
PostfetchError
|
|
3324
3392
|
};
|
package/dist/mp4-remux.d.ts
CHANGED
|
@@ -23,4 +23,5 @@ export type Mp4RemuxRuntime = {
|
|
|
23
23
|
* FFmpeg only rebuilds the container and timing tables.
|
|
24
24
|
*/
|
|
25
25
|
export declare function prepareMp4(bytes: Uint8Array, ffmpegPath?: string, ffprobePath?: string, runtime?: Mp4RemuxRuntime): Promise<PreparedMp4 | null>;
|
|
26
|
+
export declare function nodeRuntime(): Promise<Mp4RemuxRuntime>;
|
|
26
27
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { download, type DownloadOptions } from "./download";
|
|
2
|
+
import { PostfetchError, type MediaItem } from "./internal";
|
|
3
|
+
import { nodeRuntime } from "./mp4-remux";
|
|
4
|
+
|
|
5
|
+
/** Options for {@link buildAudioSliderVideo}. Requires a local FFmpeg binary. */
|
|
6
|
+
export type AudioSliderVideoOptions = DownloadOptions & {
|
|
7
|
+
/** Time per visual item in milliseconds, including its outgoing transition. */
|
|
8
|
+
delay: number;
|
|
9
|
+
/** Output width in pixels; a positive even integer. Defaults to 720. */
|
|
10
|
+
width?: number;
|
|
11
|
+
/** Output height in pixels; a positive even integer. Defaults to 1280. */
|
|
12
|
+
height?: number;
|
|
13
|
+
/** FFmpeg command or executable path. Defaults to `ffmpeg` from PATH. */
|
|
14
|
+
ffmpegPath?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const fps = 30;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build an H.264/AAC MP4 Blob from one or more images/videos followed by exactly
|
|
21
|
+
* one audio item. Downloads preserve each item's headers and assemble HLS.
|
|
22
|
+
* Visuals retain their aspect ratio, padded with black to the output size.
|
|
23
|
+
* Video clips play from the beginning, looping or trimming to fit; their own
|
|
24
|
+
* audio is discarded. The final audio item loops and is trimmed to the result.
|
|
25
|
+
*
|
|
26
|
+
* Each visual gets `delay` ms (rounded to 30 fps, minimum two frames). Adjacent
|
|
27
|
+
* visuals slide right to left over the last 300 ms or half the delay, whichever
|
|
28
|
+
* is shorter. The last visual stays on screen; a single visual has no transition.
|
|
29
|
+
* Total duration is the number of visuals times the rounded delay.
|
|
30
|
+
*
|
|
31
|
+
* Requires Node, Bun or Deno with filesystem/process access and FFmpeg with
|
|
32
|
+
* libx264. Temporary files are removed on success and failure.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* const result = await postfetch(photoUrl);
|
|
37
|
+
* const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
|
|
38
|
+
* form.append("video", blob, "slideshow.mp4");
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export async function buildAudioSliderVideo(
|
|
42
|
+
items: readonly MediaItem[],
|
|
43
|
+
options: AudioSliderVideoOptions,
|
|
44
|
+
): Promise<Blob> {
|
|
45
|
+
if (
|
|
46
|
+
!Array.isArray(items) || items.length < 2 || items.at(-1)?.kind !== "audio" ||
|
|
47
|
+
Array.from(items.slice(0, -1)).some((item) => item?.kind !== "image" && item?.kind !== "video")
|
|
48
|
+
) {
|
|
49
|
+
throw new PostfetchError(400, "Expected one or more image/video items followed by exactly one audio item");
|
|
50
|
+
}
|
|
51
|
+
if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
|
|
52
|
+
throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
|
|
53
|
+
}
|
|
54
|
+
const width = options.width ?? 720;
|
|
55
|
+
const height = options.height ?? 1280;
|
|
56
|
+
for (const [name, value] of [["width", width], ["height", height]] as const) {
|
|
57
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value % 2 !== 0) {
|
|
58
|
+
throw new PostfetchError(400, `${name} must be a positive even integer`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const visuals = items.slice(0, -1);
|
|
62
|
+
const frames = Math.max(2, Math.round(options.delay * fps / 1000));
|
|
63
|
+
if (!Number.isSafeInteger(frames * visuals.length)) {
|
|
64
|
+
throw new PostfetchError(400, "delay produces an unsupported video duration");
|
|
65
|
+
}
|
|
66
|
+
const seconds = frames / fps;
|
|
67
|
+
const transition = Math.min(0.3, seconds / 2);
|
|
68
|
+
const duration = seconds * visuals.length;
|
|
69
|
+
const io = await nodeRuntime();
|
|
70
|
+
const directory = await io.makeTempDir("postfetch-slider-");
|
|
71
|
+
try {
|
|
72
|
+
// Numbered local paths keep remote filenames out of FFmpeg arguments.
|
|
73
|
+
const paths: string[] = [];
|
|
74
|
+
for (const [index, item] of items.entries()) {
|
|
75
|
+
const path = `${directory}/input-${index}`;
|
|
76
|
+
const response = await download(item, options);
|
|
77
|
+
await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
|
|
78
|
+
paths.push(path);
|
|
79
|
+
}
|
|
80
|
+
const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
|
|
81
|
+
for (const [index, item] of visuals.entries()) {
|
|
82
|
+
args.push(...(item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]));
|
|
83
|
+
args.push("-i", paths[index]);
|
|
84
|
+
}
|
|
85
|
+
args.push("-stream_loop", "-1", "-i", paths[visuals.length]);
|
|
86
|
+
const filters = visuals.map((_, index) =>
|
|
87
|
+
`[${index}:v:0]setpts=PTS-STARTPTS,` +
|
|
88
|
+
`scale=${width}:${height}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
|
|
89
|
+
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},` +
|
|
90
|
+
`format=yuv444p,trim=duration=${seconds + (index > 0 ? transition : 0)},setpts=PTS-STARTPTS,fps=${fps},settb=AVTB[v${index}]`,
|
|
91
|
+
);
|
|
92
|
+
let current = "v0";
|
|
93
|
+
for (let index = 1; index < visuals.length; index += 1) {
|
|
94
|
+
const next = `slide${index}`;
|
|
95
|
+
filters.push(`[${current}][v${index}]xfade=transition=slideleft:duration=${transition}:offset=${index * seconds - transition},fps=${fps},settb=AVTB[${next}]`);
|
|
96
|
+
current = next;
|
|
97
|
+
}
|
|
98
|
+
filters.push(`[${current}]format=yuv420p[outv]`);
|
|
99
|
+
filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,atrim=duration=${duration}[outa]`);
|
|
100
|
+
const output = `${directory}/slideshow.mp4`;
|
|
101
|
+
args.push(
|
|
102
|
+
"-filter_complex", filters.join(";"), "-map", "[outv]", "-map", "[outa]",
|
|
103
|
+
"-t", String(duration), "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
|
104
|
+
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output,
|
|
105
|
+
);
|
|
106
|
+
const result = await io.run(options.ffmpegPath ?? "ffmpeg", args);
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
throw new PostfetchError(500, `Audio slider video encoding failed: ${result.stderr}`);
|
|
109
|
+
}
|
|
110
|
+
const bytes = await io.readFile(output);
|
|
111
|
+
if (bytes.length === 0) {
|
|
112
|
+
throw new PostfetchError(500, "Audio slider video encoding produced an empty file");
|
|
113
|
+
}
|
|
114
|
+
return new Blob([new Uint8Array(bytes).buffer], { type: "video/mp4" });
|
|
115
|
+
} finally {
|
|
116
|
+
await io.removeDir(directory).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/index.ts
CHANGED
package/src/mp4-remux.ts
CHANGED
|
@@ -161,7 +161,7 @@ function parseVideoMetadata(value: string): Pick<PreparedMp4, "duration" | "heig
|
|
|
161
161
|
// Deno, Bun and Node all expose the Node compatibility modules used here. They
|
|
162
162
|
// stay behind dynamic imports so browser/edge consumers that leave remux off do
|
|
163
163
|
// not load or execute any process/filesystem code.
|
|
164
|
-
async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
|
|
164
|
+
export async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
|
|
165
165
|
const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
|
|
166
166
|
import("node:child_process"),
|
|
167
167
|
import("node:fs/promises"),
|