@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 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 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
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 `ffmpegPath`. Requires local
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
 
@@ -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
- /** Time per visual item in milliseconds, including its outgoing transition. */
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 loops and is trimmed to the result.
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 (rounded to 30 fps, minimum two frames). Adjacent
22
- * visuals slide right to left over the last 300 ms or half the delay, whichever
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 rounded delay.
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 with
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 frames = Math.max(2, Math.round(options.delay * fps / 1000));
3335
- if (!Number.isSafeInteger(frames * visuals.length)) {
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
- args.push("-stream_loop", "-1", "-i", paths[visuals.length]);
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.0",
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",
@@ -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
- /** Time per visual item in milliseconds, including its outgoing transition. */
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 loops and is trimmed to the result.
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 (rounded to 30 fps, minimum two frames). Adjacent
27
- * visuals slide right to left over the last 300 ms or half the delay, whichever
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 rounded delay.
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 with
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 frames = Math.max(2, Math.round(options.delay * fps / 1000));
63
- if (!Number.isSafeInteger(frames * visuals.length)) {
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
- args.push("-stream_loop", "-1", "-i", paths[visuals.length]);
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
+ }