@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 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 normalize an MP4 with an FFmpeg stream copy (fast-start,
20
- non-negative timestamps, no edit list). Remuxing defaults to off, uses `ffmpeg`
21
- from `PATH` unless `ffmpegPath` is set, and falls back to the original Blob on
22
- failure. The legacy `(url, headers?, options?)` overload remains supported.
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.
@@ -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 original Blob is returned.
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 The downloaded media as a Blob.
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 blob = await downloadBlob(media.url, { headers: media.headers, remux: true });
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?: DownloadBlobOptions): Promise<Blob>;
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 remuxMp4(bytes, ffmpegPath = "ffmpeg", runtime) {
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
- return output.length > 0 ? output : null;
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", "ignore", "pipe"] });
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 remuxed = await remuxMp4(new Uint8Array(await blob.arrayBuffer()), optionsOrHeaders.ffmpegPath);
2842
- return remuxed ? new Blob([toArrayBuffer(remuxed)], { type: blob.type || "video/mp4" }) : blob;
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);
@@ -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 with the same stream-copy remux used by UMMR. Encoded media
14
- * is left untouched; FFmpeg only rebuilds the container and timing tables.
15
- * Returns `null` when FFmpeg is unavailable or the input cannot be remuxed.
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 remuxMp4(bytes: Uint8Array, ffmpegPath?: string, runtime?: Mp4RemuxRuntime): Promise<Uint8Array | null>;
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.8.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 { remuxMp4 } from "./mp4-remux";
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 original Blob is returned.
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 The downloaded media as a Blob.
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 blob = await downloadBlob(media.url, { headers: media.headers, remux: true });
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(url: string, options?: DownloadBlobOptions): Promise<Blob>;
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 remuxed = await remuxMp4(new Uint8Array(await blob.arrayBuffer()), optionsOrHeaders.ffmpegPath);
109
- return remuxed ? new Blob([toArrayBuffer(remuxed)], { type: blob.type || "video/mp4" }) : blob;
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
@@ -28,6 +28,7 @@ export {
28
28
  type Archive,
29
29
  type DownloadBlobOptions,
30
30
  type DownloadOptions,
31
+ type RemuxedVideo,
31
32
  } from "./download";
32
33
  export { PostfetchError } from "./internal";
33
34
  export type {
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 with the same stream-copy remux used by UMMR. Encoded media
16
- * is left untouched; FFmpeg only rebuilds the container and timing tables.
17
- * Returns `null` when FFmpeg is unavailable or the input cannot be remuxed.
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 remuxMp4(
28
+ export async function prepareMp4(
20
29
  bytes: Uint8Array,
21
30
  ffmpegPath = "ffmpeg",
31
+ ffprobePath = "ffprobe",
22
32
  runtime?: Mp4RemuxRuntime,
23
- ): Promise<Uint8Array | null> {
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
- return output.length > 0 ? output : null;
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", "ignore", "pipe"] });
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);