@postfetch/core 0.8.0 → 0.9.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 +5 -4
- package/dist/download.d.ts +21 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +87 -8
- package/dist/mp4-remux.d.ts +12 -4
- package/package.json +1 -1
- package/src/download.ts +42 -9
- package/src/index.ts +1 -0
- package/src/mp4-remux.ts +106 -9
package/README.md
CHANGED
|
@@ -16,10 +16,11 @@ for (const item of result.items) {
|
|
|
16
16
|
`downloadBlob(url, options?)` materializes an already-resolved media URL for
|
|
17
17
|
clients that need an uploadable `Blob`. Pass the resolved item's headers for
|
|
18
18
|
protected CDN URLs: `downloadBlob(item.url, { headers: item.headers })`. Set
|
|
19
|
-
`remux: true` to
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
`remux: true` to return `{ blob, thumbnail, width, height, duration }`: a
|
|
20
|
+
normalized MP4, upload thumbnail and calculated presentation metadata. Remuxing
|
|
21
|
+
defaults to off, uses `ffmpeg` and `ffprobe` from `PATH` unless their paths are
|
|
22
|
+
set, and throws if the complete result cannot be produced. The legacy
|
|
23
|
+
`(url, headers?, options?)` overload remains supported for non-remux downloads.
|
|
23
24
|
|
|
24
25
|
- `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.
|
|
25
26
|
- Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
|
package/dist/download.d.ts
CHANGED
|
@@ -8,11 +8,21 @@ export type DownloadOptions = {
|
|
|
8
8
|
export type DownloadBlobOptions = DownloadOptions & {
|
|
9
9
|
/** Command used for opt-in MP4 remuxing. Defaults to `ffmpeg` from `PATH`. */
|
|
10
10
|
ffmpegPath?: string;
|
|
11
|
+
/** Command used to inspect remuxed MP4s. Defaults to `ffprobe` from `PATH`. */
|
|
12
|
+
ffprobePath?: string;
|
|
11
13
|
/** Headers required by the resolved media URL. */
|
|
12
14
|
headers?: HeadersInit;
|
|
13
15
|
/** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
|
|
14
16
|
remux?: boolean;
|
|
15
17
|
};
|
|
18
|
+
/** A normalized video and upload metadata produced by `remux: true`. */
|
|
19
|
+
export type RemuxedVideo = {
|
|
20
|
+
blob: Blob;
|
|
21
|
+
duration: number;
|
|
22
|
+
height: number;
|
|
23
|
+
thumbnail: Blob;
|
|
24
|
+
width: number;
|
|
25
|
+
};
|
|
16
26
|
/** A zip archive produced by {@link archive}. */
|
|
17
27
|
export type Archive = {
|
|
18
28
|
/** The zip bytes (store mode, built in-process). */
|
|
@@ -46,20 +56,26 @@ export declare function download(item: MediaItem, options?: DownloadOptions): Pr
|
|
|
46
56
|
* passing its URL to a third party. When the resolved {@link MediaItem} carries
|
|
47
57
|
* required CDN headers, pass them in the options object. Set `remux: true` to
|
|
48
58
|
* normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
|
|
49
|
-
* remux fails, the
|
|
59
|
+
* remux or metadata extraction fails, the operation throws.
|
|
50
60
|
*
|
|
51
61
|
* @param url A direct media URL returned by a resolver.
|
|
52
62
|
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
53
|
-
* @returns
|
|
63
|
+
* @returns A Blob, or with `remux: true`, the normalized video and its metadata.
|
|
54
64
|
*
|
|
55
65
|
* @example Upload a video with FormData
|
|
56
66
|
* ```ts
|
|
57
67
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
58
|
-
* const
|
|
59
|
-
* form.append("video", blob, media.filename);
|
|
68
|
+
* const video = await downloadBlob(media.url, { headers: media.headers, remux: true });
|
|
69
|
+
* form.append("video", video.blob, media.filename);
|
|
70
|
+
* form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
|
|
60
71
|
* ```
|
|
61
72
|
*/
|
|
62
|
-
export declare function downloadBlob(url: string, options
|
|
73
|
+
export declare function downloadBlob(url: string, options: DownloadBlobOptions & {
|
|
74
|
+
remux: true;
|
|
75
|
+
}): Promise<RemuxedVideo>;
|
|
76
|
+
export declare function downloadBlob(url: string, options?: DownloadBlobOptions & {
|
|
77
|
+
remux?: false;
|
|
78
|
+
}): Promise<Blob>;
|
|
63
79
|
/** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
|
|
64
80
|
export declare function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
|
|
65
81
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
* @module
|
|
21
21
|
*/
|
|
22
22
|
export { postfetch, detect, type PostfetchOptions } from "./postfetch";
|
|
23
|
-
export { download, downloadBlob, archive, toResponse, type Archive, type DownloadBlobOptions, type DownloadOptions, } from "./download";
|
|
23
|
+
export { download, downloadBlob, archive, toResponse, type Archive, type DownloadBlobOptions, type DownloadOptions, type RemuxedVideo, } from "./download";
|
|
24
24
|
export { PostfetchError } from "./internal";
|
|
25
25
|
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
|
@@ -2514,7 +2514,7 @@ function detect(url) {
|
|
|
2514
2514
|
throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
|
|
2515
2515
|
}
|
|
2516
2516
|
// src/mp4-remux.ts
|
|
2517
|
-
async function
|
|
2517
|
+
async function prepareMp4(bytes, ffmpegPath = "ffmpeg", ffprobePath = "ffprobe", runtime) {
|
|
2518
2518
|
let directory;
|
|
2519
2519
|
let io;
|
|
2520
2520
|
try {
|
|
@@ -2549,7 +2549,53 @@ async function remuxMp4(bytes, ffmpegPath = "ffmpeg", runtime) {
|
|
|
2549
2549
|
return null;
|
|
2550
2550
|
}
|
|
2551
2551
|
const output = await io.readFile(outputPath);
|
|
2552
|
-
|
|
2552
|
+
if (output.length === 0) {
|
|
2553
|
+
return null;
|
|
2554
|
+
}
|
|
2555
|
+
const thumbnailPath = `${directory}/thumbnail.jpg`;
|
|
2556
|
+
const thumbnailResult = await io.run(ffmpegPath, [
|
|
2557
|
+
"-nostdin",
|
|
2558
|
+
"-hide_banner",
|
|
2559
|
+
"-loglevel",
|
|
2560
|
+
"error",
|
|
2561
|
+
"-y",
|
|
2562
|
+
"-i",
|
|
2563
|
+
outputPath,
|
|
2564
|
+
"-map",
|
|
2565
|
+
"0:v:0",
|
|
2566
|
+
"-frames:v",
|
|
2567
|
+
"1",
|
|
2568
|
+
"-vf",
|
|
2569
|
+
"scale=320:320:force_original_aspect_ratio=decrease",
|
|
2570
|
+
"-c:v",
|
|
2571
|
+
"mjpeg",
|
|
2572
|
+
"-q:v",
|
|
2573
|
+
"5",
|
|
2574
|
+
thumbnailPath
|
|
2575
|
+
]);
|
|
2576
|
+
if (!thumbnailResult.success) {
|
|
2577
|
+
return null;
|
|
2578
|
+
}
|
|
2579
|
+
const thumbnail = await io.readFile(thumbnailPath);
|
|
2580
|
+
if (!validThumbnail(thumbnail)) {
|
|
2581
|
+
return null;
|
|
2582
|
+
}
|
|
2583
|
+
const probe = await io.run(ffprobePath, [
|
|
2584
|
+
"-v",
|
|
2585
|
+
"error",
|
|
2586
|
+
"-select_streams",
|
|
2587
|
+
"v:0",
|
|
2588
|
+
"-show_entries",
|
|
2589
|
+
"stream=width,height,duration:stream_side_data=rotation:format=duration",
|
|
2590
|
+
"-of",
|
|
2591
|
+
"json",
|
|
2592
|
+
outputPath
|
|
2593
|
+
]);
|
|
2594
|
+
if (!probe.success) {
|
|
2595
|
+
return null;
|
|
2596
|
+
}
|
|
2597
|
+
const metadata = parseVideoMetadata(probe.stdout);
|
|
2598
|
+
return metadata ? { bytes: output, thumbnail, ...metadata } : null;
|
|
2553
2599
|
} catch {
|
|
2554
2600
|
return null;
|
|
2555
2601
|
} finally {
|
|
@@ -2560,6 +2606,26 @@ async function remuxMp4(bytes, ffmpegPath = "ffmpeg", runtime) {
|
|
|
2560
2606
|
}
|
|
2561
2607
|
}
|
|
2562
2608
|
}
|
|
2609
|
+
function validThumbnail(bytes) {
|
|
2610
|
+
return bytes.length >= 4 && bytes.length < 200000 && bytes[0] === 255 && bytes[1] === 216 && bytes[bytes.length - 2] === 255 && bytes[bytes.length - 1] === 217;
|
|
2611
|
+
}
|
|
2612
|
+
function parseVideoMetadata(value) {
|
|
2613
|
+
const parsed = JSON.parse(value);
|
|
2614
|
+
const stream = parsed.streams?.[0];
|
|
2615
|
+
const duration = Number(parsed.format?.duration ?? stream?.duration);
|
|
2616
|
+
const width = stream?.width ?? 0;
|
|
2617
|
+
const height2 = stream?.height ?? 0;
|
|
2618
|
+
if (!stream || !(duration > 0 && Number.isFinite(duration) && width > 0 && height2 > 0)) {
|
|
2619
|
+
return null;
|
|
2620
|
+
}
|
|
2621
|
+
const rotation = stream.side_data_list?.find((item) => item.rotation !== undefined)?.rotation ?? 0;
|
|
2622
|
+
const sideways = Math.abs(rotation) % 180 === 90;
|
|
2623
|
+
return {
|
|
2624
|
+
duration: Math.ceil(duration),
|
|
2625
|
+
height: sideways ? width : height2,
|
|
2626
|
+
width: sideways ? height2 : width
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2563
2629
|
async function nodeRuntime() {
|
|
2564
2630
|
const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
|
|
2565
2631
|
import("node:child_process"),
|
|
@@ -2574,15 +2640,19 @@ async function nodeRuntime() {
|
|
|
2574
2640
|
await rm(path, { force: true, recursive: true });
|
|
2575
2641
|
},
|
|
2576
2642
|
run: (command, args) => new Promise((resolve2) => {
|
|
2577
|
-
const child = spawn(command, args, { stdio: ["ignore", "
|
|
2643
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2578
2644
|
let stderr = "";
|
|
2645
|
+
let stdout = "";
|
|
2646
|
+
child.stdout?.on("data", (chunk) => {
|
|
2647
|
+
stdout += new TextDecoder().decode(chunk);
|
|
2648
|
+
});
|
|
2579
2649
|
child.stderr?.on("data", (chunk) => {
|
|
2580
2650
|
if (stderr.length < 4096) {
|
|
2581
2651
|
stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
|
|
2582
2652
|
}
|
|
2583
2653
|
});
|
|
2584
|
-
child.once("error", (error) => resolve2({ stderr: error.message, success: false }));
|
|
2585
|
-
child.once("close", (code) => resolve2({ stderr, success: code === 0 }));
|
|
2654
|
+
child.once("error", (error) => resolve2({ stderr: error.message, stdout, success: false }));
|
|
2655
|
+
child.once("close", (code) => resolve2({ stderr, stdout, success: code === 0 }));
|
|
2586
2656
|
}),
|
|
2587
2657
|
writeFile: async (path, value) => {
|
|
2588
2658
|
await writeFile(path, value);
|
|
@@ -2838,15 +2908,24 @@ async function downloadBlob(url, optionsOrHeaders = {}, legacyOptions = {}) {
|
|
|
2838
2908
|
if (!modern || optionsOrHeaders.remux !== true) {
|
|
2839
2909
|
return blob;
|
|
2840
2910
|
}
|
|
2841
|
-
const
|
|
2842
|
-
|
|
2911
|
+
const prepared = await prepareMp4(new Uint8Array(await blob.arrayBuffer()), optionsOrHeaders.ffmpegPath, optionsOrHeaders.ffprobePath);
|
|
2912
|
+
if (!prepared) {
|
|
2913
|
+
throw new PostfetchError(500, "MP4 remux or metadata extraction failed");
|
|
2914
|
+
}
|
|
2915
|
+
return {
|
|
2916
|
+
blob: new Blob([toArrayBuffer(prepared.bytes)], { type: blob.type || "video/mp4" }),
|
|
2917
|
+
duration: prepared.duration,
|
|
2918
|
+
height: prepared.height,
|
|
2919
|
+
thumbnail: new Blob([toArrayBuffer(prepared.thumbnail)], { type: "image/jpeg" }),
|
|
2920
|
+
width: prepared.width
|
|
2921
|
+
};
|
|
2843
2922
|
}
|
|
2844
2923
|
function isDownloadBlobOptions(value) {
|
|
2845
2924
|
if (value instanceof Headers || Array.isArray(value)) {
|
|
2846
2925
|
return false;
|
|
2847
2926
|
}
|
|
2848
2927
|
const candidate = value;
|
|
2849
|
-
return "headers" in candidate && typeof candidate.headers !== "string" || typeof candidate.remux === "boolean" || typeof candidate.ffmpegPath === "string" || typeof candidate.fetch === "function";
|
|
2928
|
+
return "headers" in candidate && typeof candidate.headers !== "string" || typeof candidate.remux === "boolean" || typeof candidate.ffmpegPath === "string" || typeof candidate.ffprobePath === "string" || typeof candidate.fetch === "function";
|
|
2850
2929
|
}
|
|
2851
2930
|
async function archive(result, options = {}) {
|
|
2852
2931
|
const net = createNet(options.fetch ?? globalThis.fetch);
|
package/dist/mp4-remux.d.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
type CommandResult = {
|
|
2
2
|
stderr: string;
|
|
3
|
+
stdout: string;
|
|
3
4
|
success: boolean;
|
|
4
5
|
};
|
|
6
|
+
export type PreparedMp4 = {
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
duration: number;
|
|
9
|
+
height: number;
|
|
10
|
+
thumbnail: Uint8Array;
|
|
11
|
+
width: number;
|
|
12
|
+
};
|
|
5
13
|
export type Mp4RemuxRuntime = {
|
|
6
14
|
makeTempDir(prefix: string): Promise<string>;
|
|
7
15
|
readFile(path: string): Promise<Uint8Array>;
|
|
@@ -10,9 +18,9 @@ export type Mp4RemuxRuntime = {
|
|
|
10
18
|
writeFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
11
19
|
};
|
|
12
20
|
/**
|
|
13
|
-
* Normalize an MP4
|
|
14
|
-
*
|
|
15
|
-
*
|
|
21
|
+
* Normalize an MP4, extract a Telegram-compatible JPEG thumbnail, and calculate
|
|
22
|
+
* the presentation dimensions and duration. Encoded media is left untouched;
|
|
23
|
+
* FFmpeg only rebuilds the container and timing tables.
|
|
16
24
|
*/
|
|
17
|
-
export declare function
|
|
25
|
+
export declare function prepareMp4(bytes: Uint8Array, ffmpegPath?: string, ffprobePath?: string, runtime?: Mp4RemuxRuntime): Promise<PreparedMp4 | null>;
|
|
18
26
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postfetch/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
package/src/download.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { assembleHls } from "./hls";
|
|
2
2
|
import { createNet, PostfetchError, type MediaItem, type Net, type PostfetchResult } from "./internal";
|
|
3
|
-
import {
|
|
3
|
+
import { prepareMp4 } from "./mp4-remux";
|
|
4
4
|
import { mergeAudioVideo } from "./remux";
|
|
5
5
|
import { zip } from "./zip";
|
|
6
6
|
|
|
@@ -20,12 +20,23 @@ export type DownloadOptions = {
|
|
|
20
20
|
export type DownloadBlobOptions = DownloadOptions & {
|
|
21
21
|
/** Command used for opt-in MP4 remuxing. Defaults to `ffmpeg` from `PATH`. */
|
|
22
22
|
ffmpegPath?: string;
|
|
23
|
+
/** Command used to inspect remuxed MP4s. Defaults to `ffprobe` from `PATH`. */
|
|
24
|
+
ffprobePath?: string;
|
|
23
25
|
/** Headers required by the resolved media URL. */
|
|
24
26
|
headers?: HeadersInit;
|
|
25
27
|
/** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
|
|
26
28
|
remux?: boolean;
|
|
27
29
|
};
|
|
28
30
|
|
|
31
|
+
/** A normalized video and upload metadata produced by `remux: true`. */
|
|
32
|
+
export type RemuxedVideo = {
|
|
33
|
+
blob: Blob;
|
|
34
|
+
duration: number;
|
|
35
|
+
height: number;
|
|
36
|
+
thumbnail: Blob;
|
|
37
|
+
width: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
29
40
|
/** A zip archive produced by {@link archive}. */
|
|
30
41
|
export type Archive = {
|
|
31
42
|
/** The zip bytes (store mode, built in-process). */
|
|
@@ -72,27 +83,35 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
|
|
|
72
83
|
* passing its URL to a third party. When the resolved {@link MediaItem} carries
|
|
73
84
|
* required CDN headers, pass them in the options object. Set `remux: true` to
|
|
74
85
|
* normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
|
|
75
|
-
* remux fails, the
|
|
86
|
+
* remux or metadata extraction fails, the operation throws.
|
|
76
87
|
*
|
|
77
88
|
* @param url A direct media URL returned by a resolver.
|
|
78
89
|
* @param options Headers, fetch implementation and opt-in remux behavior.
|
|
79
|
-
* @returns
|
|
90
|
+
* @returns A Blob, or with `remux: true`, the normalized video and its metadata.
|
|
80
91
|
*
|
|
81
92
|
* @example Upload a video with FormData
|
|
82
93
|
* ```ts
|
|
83
94
|
* const [media] = (await postfetch(sourceUrl)).items;
|
|
84
|
-
* const
|
|
85
|
-
* form.append("video", blob, media.filename);
|
|
95
|
+
* const video = await downloadBlob(media.url, { headers: media.headers, remux: true });
|
|
96
|
+
* form.append("video", video.blob, media.filename);
|
|
97
|
+
* form.append("thumbnail", video.thumbnail, "thumbnail.jpg");
|
|
86
98
|
* ```
|
|
87
99
|
*/
|
|
88
|
-
export async function downloadBlob(
|
|
100
|
+
export async function downloadBlob(
|
|
101
|
+
url: string,
|
|
102
|
+
options: DownloadBlobOptions & { remux: true },
|
|
103
|
+
): Promise<RemuxedVideo>;
|
|
104
|
+
export async function downloadBlob(
|
|
105
|
+
url: string,
|
|
106
|
+
options?: DownloadBlobOptions & { remux?: false },
|
|
107
|
+
): Promise<Blob>;
|
|
89
108
|
/** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
|
|
90
109
|
export async function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
|
|
91
110
|
export async function downloadBlob(
|
|
92
111
|
url: string,
|
|
93
112
|
optionsOrHeaders: DownloadBlobOptions | HeadersInit = {},
|
|
94
113
|
legacyOptions: DownloadOptions = {},
|
|
95
|
-
): Promise<Blob> {
|
|
114
|
+
): Promise<Blob | RemuxedVideo> {
|
|
96
115
|
const modern = isDownloadBlobOptions(optionsOrHeaders);
|
|
97
116
|
const options = modern ? optionsOrHeaders : legacyOptions;
|
|
98
117
|
const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
|
|
@@ -105,8 +124,21 @@ export async function downloadBlob(
|
|
|
105
124
|
if (!modern || optionsOrHeaders.remux !== true) {
|
|
106
125
|
return blob;
|
|
107
126
|
}
|
|
108
|
-
const
|
|
109
|
-
|
|
127
|
+
const prepared = await prepareMp4(
|
|
128
|
+
new Uint8Array(await blob.arrayBuffer()),
|
|
129
|
+
optionsOrHeaders.ffmpegPath,
|
|
130
|
+
optionsOrHeaders.ffprobePath,
|
|
131
|
+
);
|
|
132
|
+
if (!prepared) {
|
|
133
|
+
throw new PostfetchError(500, "MP4 remux or metadata extraction failed");
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
blob: new Blob([toArrayBuffer(prepared.bytes)], { type: blob.type || "video/mp4" }),
|
|
137
|
+
duration: prepared.duration,
|
|
138
|
+
height: prepared.height,
|
|
139
|
+
thumbnail: new Blob([toArrayBuffer(prepared.thumbnail)], { type: "image/jpeg" }),
|
|
140
|
+
width: prepared.width,
|
|
141
|
+
};
|
|
110
142
|
}
|
|
111
143
|
|
|
112
144
|
function isDownloadBlobOptions(value: DownloadBlobOptions | HeadersInit): value is DownloadBlobOptions {
|
|
@@ -118,6 +150,7 @@ function isDownloadBlobOptions(value: DownloadBlobOptions | HeadersInit): value
|
|
|
118
150
|
("headers" in candidate && typeof candidate.headers !== "string") ||
|
|
119
151
|
typeof candidate.remux === "boolean" ||
|
|
120
152
|
typeof candidate.ffmpegPath === "string" ||
|
|
153
|
+
typeof candidate.ffprobePath === "string" ||
|
|
121
154
|
typeof candidate.fetch === "function"
|
|
122
155
|
);
|
|
123
156
|
}
|
package/src/index.ts
CHANGED
package/src/mp4-remux.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
type CommandResult = {
|
|
2
2
|
stderr: string;
|
|
3
|
+
stdout: string;
|
|
3
4
|
success: boolean;
|
|
4
5
|
};
|
|
5
6
|
|
|
7
|
+
export type PreparedMp4 = {
|
|
8
|
+
bytes: Uint8Array;
|
|
9
|
+
duration: number;
|
|
10
|
+
height: number;
|
|
11
|
+
thumbnail: Uint8Array;
|
|
12
|
+
width: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
6
15
|
export type Mp4RemuxRuntime = {
|
|
7
16
|
makeTempDir(prefix: string): Promise<string>;
|
|
8
17
|
readFile(path: string): Promise<Uint8Array>;
|
|
@@ -12,15 +21,16 @@ export type Mp4RemuxRuntime = {
|
|
|
12
21
|
};
|
|
13
22
|
|
|
14
23
|
/**
|
|
15
|
-
* Normalize an MP4
|
|
16
|
-
*
|
|
17
|
-
*
|
|
24
|
+
* Normalize an MP4, extract a Telegram-compatible JPEG thumbnail, and calculate
|
|
25
|
+
* the presentation dimensions and duration. Encoded media is left untouched;
|
|
26
|
+
* FFmpeg only rebuilds the container and timing tables.
|
|
18
27
|
*/
|
|
19
|
-
export async function
|
|
28
|
+
export async function prepareMp4(
|
|
20
29
|
bytes: Uint8Array,
|
|
21
30
|
ffmpegPath = "ffmpeg",
|
|
31
|
+
ffprobePath = "ffprobe",
|
|
22
32
|
runtime?: Mp4RemuxRuntime,
|
|
23
|
-
): Promise<
|
|
33
|
+
): Promise<PreparedMp4 | null> {
|
|
24
34
|
let directory: string | undefined;
|
|
25
35
|
let io: Mp4RemuxRuntime | undefined;
|
|
26
36
|
try {
|
|
@@ -55,7 +65,53 @@ export async function remuxMp4(
|
|
|
55
65
|
return null;
|
|
56
66
|
}
|
|
57
67
|
const output = await io.readFile(outputPath);
|
|
58
|
-
|
|
68
|
+
if (output.length === 0) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const thumbnailPath = `${directory}/thumbnail.jpg`;
|
|
72
|
+
const thumbnailResult = await io.run(ffmpegPath, [
|
|
73
|
+
"-nostdin",
|
|
74
|
+
"-hide_banner",
|
|
75
|
+
"-loglevel",
|
|
76
|
+
"error",
|
|
77
|
+
"-y",
|
|
78
|
+
"-i",
|
|
79
|
+
outputPath,
|
|
80
|
+
"-map",
|
|
81
|
+
"0:v:0",
|
|
82
|
+
"-frames:v",
|
|
83
|
+
"1",
|
|
84
|
+
"-vf",
|
|
85
|
+
"scale=320:320:force_original_aspect_ratio=decrease",
|
|
86
|
+
"-c:v",
|
|
87
|
+
"mjpeg",
|
|
88
|
+
"-q:v",
|
|
89
|
+
"5",
|
|
90
|
+
thumbnailPath,
|
|
91
|
+
]);
|
|
92
|
+
if (!thumbnailResult.success) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const thumbnail = await io.readFile(thumbnailPath);
|
|
96
|
+
if (!validThumbnail(thumbnail)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const probe = await io.run(ffprobePath, [
|
|
100
|
+
"-v",
|
|
101
|
+
"error",
|
|
102
|
+
"-select_streams",
|
|
103
|
+
"v:0",
|
|
104
|
+
"-show_entries",
|
|
105
|
+
"stream=width,height,duration:stream_side_data=rotation:format=duration",
|
|
106
|
+
"-of",
|
|
107
|
+
"json",
|
|
108
|
+
outputPath,
|
|
109
|
+
]);
|
|
110
|
+
if (!probe.success) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const metadata = parseVideoMetadata(probe.stdout);
|
|
114
|
+
return metadata ? { bytes: output, thumbnail, ...metadata } : null;
|
|
59
115
|
} catch {
|
|
60
116
|
return null;
|
|
61
117
|
} finally {
|
|
@@ -65,6 +121,43 @@ export async function remuxMp4(
|
|
|
65
121
|
}
|
|
66
122
|
}
|
|
67
123
|
|
|
124
|
+
function validThumbnail(bytes: Uint8Array): boolean {
|
|
125
|
+
return (
|
|
126
|
+
bytes.length >= 4 &&
|
|
127
|
+
bytes.length < 200_000 &&
|
|
128
|
+
bytes[0] === 0xff &&
|
|
129
|
+
bytes[1] === 0xd8 &&
|
|
130
|
+
bytes[bytes.length - 2] === 0xff &&
|
|
131
|
+
bytes[bytes.length - 1] === 0xd9
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseVideoMetadata(value: string): Pick<PreparedMp4, "duration" | "height" | "width"> | null {
|
|
136
|
+
const parsed = JSON.parse(value) as {
|
|
137
|
+
format?: { duration?: string };
|
|
138
|
+
streams?: Array<{
|
|
139
|
+
duration?: string;
|
|
140
|
+
height?: number;
|
|
141
|
+
side_data_list?: Array<{ rotation?: number }>;
|
|
142
|
+
width?: number;
|
|
143
|
+
}>;
|
|
144
|
+
};
|
|
145
|
+
const stream = parsed.streams?.[0];
|
|
146
|
+
const duration = Number(parsed.format?.duration ?? stream?.duration);
|
|
147
|
+
const width = stream?.width ?? 0;
|
|
148
|
+
const height = stream?.height ?? 0;
|
|
149
|
+
if (!stream || !(duration > 0 && Number.isFinite(duration) && width > 0 && height > 0)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const rotation = stream.side_data_list?.find((item) => item.rotation !== undefined)?.rotation ?? 0;
|
|
153
|
+
const sideways = Math.abs(rotation) % 180 === 90;
|
|
154
|
+
return {
|
|
155
|
+
duration: Math.ceil(duration),
|
|
156
|
+
height: sideways ? width : height,
|
|
157
|
+
width: sideways ? height : width,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
68
161
|
// Deno, Bun and Node all expose the Node compatibility modules used here. They
|
|
69
162
|
// stay behind dynamic imports so browser/edge consumers that leave remux off do
|
|
70
163
|
// not load or execute any process/filesystem code.
|
|
@@ -83,15 +176,19 @@ async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
|
|
|
83
176
|
},
|
|
84
177
|
run: (command, args) =>
|
|
85
178
|
new Promise((resolve) => {
|
|
86
|
-
const child = spawn(command, args, { stdio: ["ignore", "
|
|
179
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
87
180
|
let stderr = "";
|
|
181
|
+
let stdout = "";
|
|
182
|
+
child.stdout?.on("data", (chunk: Uint8Array) => {
|
|
183
|
+
stdout += new TextDecoder().decode(chunk);
|
|
184
|
+
});
|
|
88
185
|
child.stderr?.on("data", (chunk: Uint8Array) => {
|
|
89
186
|
if (stderr.length < 4096) {
|
|
90
187
|
stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
|
|
91
188
|
}
|
|
92
189
|
});
|
|
93
|
-
child.once("error", (error) => resolve({ stderr: error.message, success: false }));
|
|
94
|
-
child.once("close", (code) => resolve({ stderr, success: code === 0 }));
|
|
190
|
+
child.once("error", (error) => resolve({ stderr: error.message, stdout, success: false }));
|
|
191
|
+
child.once("close", (code) => resolve({ stderr, stdout, success: code === 0 }));
|
|
95
192
|
}),
|
|
96
193
|
writeFile: async (path, value) => {
|
|
97
194
|
await writeFile(path, value);
|