@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 +25 -1
- package/dist/audio-slider.d.ts +40 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +100 -0
- package/dist/mp4-remux.d.ts +1 -0
- package/package.json +1 -1
- package/src/audio-slider.ts +152 -0
- package/src/index.ts +1 -0
- package/src/mp4-remux.ts +1 -1
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
|
-
|
|
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
|
};
|
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.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
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"),
|