@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 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
- the opt-in `downloadBlob(..., { remux: true })` path is requested.
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
  };
@@ -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.9.3",
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
@@ -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 {
24
25
  download,
25
26
  downloadBlob,
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"),