@postfetch/core 0.9.3 → 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 CHANGED
@@ -24,9 +24,33 @@ 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 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
38
+ aspect ratio with black padding. `AudioSliderVideoOptions` supports `width` and
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,
41
+ Deno). Downloads retain item headers and assemble HLS; temporary files are
42
+ cleaned up on success and failure.
43
+
44
+ ```ts
45
+ import { buildAudioSliderVideo } from "@postfetch/core";
46
+
47
+ const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
48
+ form.append("video", blob, "slideshow.mp4");
49
+ ```
50
+
27
51
  - `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
52
  - Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
29
53
  - No runtime dependencies, no `yt-dlp`, no cookies. `ffmpeg` is only used when
30
- the opt-in `downloadBlob(..., { remux: true })` path is requested.
54
+ `downloadBlob(..., { remux: true })` or `buildAudioSliderVideo` is requested.
31
55
 
32
56
  See the [root README](https://github.com/chelokot/postfetch#readme) for the full API table, supported inputs and the fingerprint design.
@@ -0,0 +1,40 @@
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
+ /** Minimum 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
+ /** FFprobe command or executable path used to measure audio. Defaults to `ffprobe`. */
14
+ ffprobePath?: string;
15
+ };
16
+ /**
17
+ * Build an H.264/AAC MP4 Blob from one or more images/videos followed by exactly
18
+ * one audio item. Downloads preserve each item's headers and assemble HLS.
19
+ * Visuals retain their aspect ratio, padded with black to the output size.
20
+ * Video clips play from the beginning, looping or trimming to fit; their own
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.
23
+ *
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
27
+ * is shorter. The last visual stays on screen; a single visual has no transition.
28
+ * Total duration is the number of visuals times the computed slot duration.
29
+ *
30
+ * Requires Node, Bun or Deno with filesystem/process access, FFprobe and FFmpeg
31
+ * with libx264. Temporary files are removed on success and failure.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * const result = await postfetch(photoUrl);
36
+ * const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
37
+ * form.append("video", blob, "slideshow.mp4");
38
+ * ```
39
+ */
40
+ 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,112 @@ 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 minimumFrames = Math.max(2, Math.ceil(options.delay * fps / 1000));
3335
+ if (!Number.isSafeInteger(minimumFrames * visuals.length)) {
3336
+ throw new PostfetchError(400, "delay produces an unsupported video duration");
3337
+ }
3338
+ const io = await nodeRuntime();
3339
+ const directory = await io.makeTempDir("postfetch-slider-");
3340
+ try {
3341
+ const paths = [];
3342
+ for (const [index, item] of items.entries()) {
3343
+ const path = `${directory}/input-${index}`;
3344
+ const response = await download(item, options);
3345
+ await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
3346
+ paths.push(path);
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;
3370
+ const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
3371
+ for (const [index, item] of visuals.entries()) {
3372
+ args.push(...item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]);
3373
+ args.push("-i", paths[index]);
3374
+ }
3375
+ if (audioDuration < minimumFrames * visuals.length / fps) {
3376
+ args.push("-stream_loop", "-1");
3377
+ }
3378
+ args.push("-i", paths[visuals.length]);
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}]`);
3380
+ let current = "v0";
3381
+ for (let index = 1;index < visuals.length; index += 1) {
3382
+ const next = `slide${index}`;
3383
+ filters.push(`[${current}][v${index}]xfade=transition=slideleft:duration=${transition}:offset=${index * seconds - transition},fps=${fps},settb=AVTB[${next}]`);
3384
+ current = next;
3385
+ }
3386
+ filters.push(`[${current}]format=yuv420p[outv]`);
3387
+ filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,apad,atrim=duration=${duration}[outa]`);
3388
+ const output = `${directory}/slideshow.mp4`;
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);
3390
+ const result = await io.run(options.ffmpegPath ?? "ffmpeg", args);
3391
+ if (!result.success) {
3392
+ throw new PostfetchError(500, `Audio slider video encoding failed: ${result.stderr}`);
3393
+ }
3394
+ const bytes = await io.readFile(output);
3395
+ if (bytes.length === 0) {
3396
+ throw new PostfetchError(500, "Audio slider video encoding produced an empty file");
3397
+ }
3398
+ return new Blob([new Uint8Array(bytes).buffer], { type: "video/mp4" });
3399
+ } finally {
3400
+ await io.removeDir(directory).catch(() => {
3401
+ return;
3402
+ });
3403
+ }
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
+ }
3316
3415
  export {
3317
3416
  toResponse,
3318
3417
  postfetch,
3319
3418
  downloadBlob,
3320
3419
  download,
3321
3420
  detect,
3421
+ buildAudioSliderVideo,
3322
3422
  archive,
3323
3423
  PostfetchError
3324
3424
  };
@@ -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.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",
@@ -0,0 +1,152 @@
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
+ /** Minimum 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
+ /** FFprobe command or executable path used to measure audio. Defaults to `ffprobe`. */
16
+ ffprobePath?: string;
17
+ };
18
+
19
+ const fps = 30;
20
+
21
+ /**
22
+ * Build an H.264/AAC MP4 Blob from one or more images/videos followed by exactly
23
+ * one audio item. Downloads preserve each item's headers and assemble HLS.
24
+ * Visuals retain their aspect ratio, padded with black to the output size.
25
+ * Video clips play from the beginning, looping or trimming to fit; their own
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.
28
+ *
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
32
+ * is shorter. The last visual stays on screen; a single visual has no transition.
33
+ * Total duration is the number of visuals times the computed slot duration.
34
+ *
35
+ * Requires Node, Bun or Deno with filesystem/process access, FFprobe and FFmpeg
36
+ * with libx264. Temporary files are removed on success and failure.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const result = await postfetch(photoUrl);
41
+ * const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
42
+ * form.append("video", blob, "slideshow.mp4");
43
+ * ```
44
+ */
45
+ export async function buildAudioSliderVideo(
46
+ items: readonly MediaItem[],
47
+ options: AudioSliderVideoOptions,
48
+ ): Promise<Blob> {
49
+ if (
50
+ !Array.isArray(items) || items.length < 2 || items.at(-1)?.kind !== "audio" ||
51
+ Array.from(items.slice(0, -1)).some((item) => item?.kind !== "image" && item?.kind !== "video")
52
+ ) {
53
+ throw new PostfetchError(400, "Expected one or more image/video items followed by exactly one audio item");
54
+ }
55
+ if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
56
+ throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
57
+ }
58
+ const width = options.width ?? 720;
59
+ const height = options.height ?? 1280;
60
+ for (const [name, value] of [["width", width], ["height", height]] as const) {
61
+ if (!Number.isSafeInteger(value) || value <= 0 || value % 2 !== 0) {
62
+ throw new PostfetchError(400, `${name} must be a positive even integer`);
63
+ }
64
+ }
65
+ const visuals = items.slice(0, -1);
66
+ const minimumFrames = Math.max(2, Math.ceil(options.delay * fps / 1000));
67
+ if (!Number.isSafeInteger(minimumFrames * visuals.length)) {
68
+ throw new PostfetchError(400, "delay produces an unsupported video duration");
69
+ }
70
+ const io = await nodeRuntime();
71
+ const directory = await io.makeTempDir("postfetch-slider-");
72
+ try {
73
+ // Numbered local paths keep remote filenames out of FFmpeg arguments.
74
+ const paths: string[] = [];
75
+ for (const [index, item] of items.entries()) {
76
+ const path = `${directory}/input-${index}`;
77
+ const response = await download(item, options);
78
+ await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
79
+ paths.push(path);
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;
96
+ const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
97
+ for (const [index, item] of visuals.entries()) {
98
+ args.push(...(item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]));
99
+ args.push("-i", paths[index]);
100
+ }
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]);
107
+ const filters = visuals.map((_, index) =>
108
+ `[${index}:v:0]setpts=PTS-STARTPTS,` +
109
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
110
+ `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},` +
111
+ `format=yuv444p,trim=duration=${seconds + (index > 0 ? transition : 0)},setpts=PTS-STARTPTS,fps=${fps},settb=AVTB[v${index}]`,
112
+ );
113
+ let current = "v0";
114
+ for (let index = 1; index < visuals.length; index += 1) {
115
+ const next = `slide${index}`;
116
+ filters.push(`[${current}][v${index}]xfade=transition=slideleft:duration=${transition}:offset=${index * seconds - transition},fps=${fps},settb=AVTB[${next}]`);
117
+ current = next;
118
+ }
119
+ filters.push(`[${current}]format=yuv420p[outv]`);
120
+ filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,apad,atrim=duration=${duration}[outa]`);
121
+ const output = `${directory}/slideshow.mp4`;
122
+ args.push(
123
+ "-filter_complex", filters.join(";"), "-map", "[outv]", "-map", "[outa]",
124
+ "-t", String(duration), "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
125
+ "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output,
126
+ );
127
+ const result = await io.run(options.ffmpegPath ?? "ffmpeg", args);
128
+ if (!result.success) {
129
+ throw new PostfetchError(500, `Audio slider video encoding failed: ${result.stderr}`);
130
+ }
131
+ const bytes = await io.readFile(output);
132
+ if (bytes.length === 0) {
133
+ throw new PostfetchError(500, "Audio slider video encoding produced an empty file");
134
+ }
135
+ return new Blob([new Uint8Array(bytes).buffer], { type: "video/mp4" });
136
+ } finally {
137
+ await io.removeDir(directory).catch(() => undefined);
138
+ }
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
+ }
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"),