@postfetch/core 0.7.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
@@ -13,12 +13,18 @@ for (const item of result.items) {
13
13
 
14
14
  `postfetch(url, options?)` detects the platform and resolves its media into a typed `PostfetchResult` (URLs plus the headers needed to fetch them). It performs no side effects beyond the lookup — `download`, `downloadBlob`, `archive` and `toResponse` materialize the result.
15
15
 
16
- `downloadBlob(url, headers?, options?)` materializes an already-resolved media URL for
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
- protected CDN URLs: `downloadBlob(item.url, item.headers)`.
18
+ protected CDN URLs: `downloadBlob(item.url, { headers: item.headers })`. Set
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.
19
24
 
20
- - `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number; tryMaxBytes?: number }`. `tryMaxBytes` is a soft byte cap: when the normal media is larger, a smaller available rendition is returned; if its size cannot be discovered or no smaller rendition is available, the normal result is kept. Inject `fetch` to unit-test resolvers offline.
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.
21
26
  - Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
22
- - No runtime dependencies, no `ffmpeg` / `yt-dlp`, no cookies.
27
+ - No runtime dependencies, no `yt-dlp`, no cookies. `ffmpeg` is only used when
28
+ the opt-in `downloadBlob(..., { remux: true })` path is requested.
23
29
 
24
30
  See the [root README](https://github.com/chelokot/postfetch#readme) for the full API table, supported inputs and the fingerprint design.
@@ -4,6 +4,25 @@ export type DownloadOptions = {
4
4
  /** Custom `fetch` implementation. Defaults to the global `fetch`. */
5
5
  fetch?: typeof fetch;
6
6
  };
7
+ /** Options for {@link downloadBlob}. */
8
+ export type DownloadBlobOptions = DownloadOptions & {
9
+ /** Command used for opt-in MP4 remuxing. Defaults to `ffmpeg` from `PATH`. */
10
+ ffmpegPath?: string;
11
+ /** Command used to inspect remuxed MP4s. Defaults to `ffprobe` from `PATH`. */
12
+ ffprobePath?: string;
13
+ /** Headers required by the resolved media URL. */
14
+ headers?: HeadersInit;
15
+ /** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
16
+ remux?: boolean;
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
+ };
7
26
  /** A zip archive produced by {@link archive}. */
8
27
  export type Archive = {
9
28
  /** The zip bytes (store mode, built in-process). */
@@ -35,20 +54,29 @@ export declare function download(item: MediaItem, options?: DownloadOptions): Pr
35
54
  *
36
55
  * This is useful for consumers that need to upload media themselves instead of
37
56
  * passing its URL to a third party. When the resolved {@link MediaItem} carries
38
- * required CDN headers, pass them as the second argument.
57
+ * required CDN headers, pass them in the options object. Set `remux: true` to
58
+ * normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
59
+ * remux or metadata extraction fails, the operation throws.
39
60
  *
40
61
  * @param url A direct media URL returned by a resolver.
41
- * @param headers Headers required by the resolved media URL.
42
- * @param options Fetch options.
43
- * @returns The downloaded media as a Blob.
62
+ * @param options Headers, fetch implementation and opt-in remux behavior.
63
+ * @returns A Blob, or with `remux: true`, the normalized video and its metadata.
44
64
  *
45
65
  * @example Upload a video with FormData
46
66
  * ```ts
47
67
  * const [media] = (await postfetch(sourceUrl)).items;
48
- * const blob = await downloadBlob(media.url, media.headers);
49
- * 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");
50
71
  * ```
51
72
  */
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>;
79
+ /** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
52
80
  export declare function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
53
81
  /**
54
82
  * Download every item of a result and zip them in-process (store mode).
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 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
@@ -1,3 +1,6 @@
1
+ import { createRequire } from "node:module";
2
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
3
+
1
4
  // src/internal.ts
2
5
  class PostfetchError extends Error {
3
6
  status;
@@ -162,12 +165,17 @@ function instagramAppUserAgent() {
162
165
 
163
166
  // src/facebook.ts
164
167
  async function resolveFacebook(input) {
165
- const canonical = await canonicalUrl(input.net, normalizedInputUrl(input.url));
168
+ const page = await canonicalPage(input.net, normalizedInputUrl(input.url));
169
+ const canonical = page.url;
166
170
  const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
171
+ const pageMetadata = facebookMetadata(page.html);
167
172
  const embed = await embedVideo(input.net, canonical, input.preferredWidth);
168
173
  const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
169
174
  const url = embed ?? watch?.url ?? null;
170
175
  if (!url) {
176
+ if (pageMetadata?.text) {
177
+ return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [], metadata: pageMetadata, platform: "facebook" };
178
+ }
171
179
  throw new PostfetchError(404, "Facebook video not found", "notFound");
172
180
  }
173
181
  const item = {
@@ -179,7 +187,13 @@ async function resolveFacebook(input) {
179
187
  platform: "facebook",
180
188
  url
181
189
  };
182
- return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
190
+ return {
191
+ archiveFilename: filename(`facebook_${id}.zip`),
192
+ id,
193
+ items: [item],
194
+ metadata: mergeMetadata(pageMetadata, watch?.metadata),
195
+ platform: "facebook"
196
+ };
183
197
  }
184
198
  function normalizedInputUrl(input) {
185
199
  const url = asUrl(input);
@@ -193,10 +207,13 @@ function navigationHeaders2() {
193
207
  "user-agent": browserUserAgent()
194
208
  };
195
209
  }
196
- async function canonicalUrl(net, input) {
210
+ async function canonicalPage(net, input) {
197
211
  const response = await net(input, { headers: navigationHeaders2() });
198
- const resolved = asUrl(response.url);
199
- return `${resolved.origin}${resolved.pathname}`;
212
+ const resolved = asUrl(response.url || input);
213
+ return {
214
+ html: response.ok ? await response.text() : "",
215
+ url: `${resolved.origin}${resolved.pathname}`
216
+ };
200
217
  }
201
218
  async function embedVideo(net, canonical, preferredWidth) {
202
219
  const embed = `https://www.facebook.com/plugins/video.php?href=${encodeURIComponent(canonical)}`;
@@ -231,6 +248,63 @@ function watchMetadata(html) {
231
248
  viewCount: count(html.match(/"video_view_count":(\d+)/)?.[1])
232
249
  };
233
250
  }
251
+ function facebookMetadata(html) {
252
+ const canonical = metaContent(html, "og:url");
253
+ if (!canonical || !facebookPostUrl(canonical)) {
254
+ return;
255
+ }
256
+ const text = metaContent(html, "og:description") ?? metaContent(html, "description");
257
+ const name = metaContent(html, "og:title");
258
+ if (!text && !name) {
259
+ return;
260
+ }
261
+ return {
262
+ text: text ?? undefined,
263
+ author: name ? { name } : undefined
264
+ };
265
+ }
266
+ function facebookPostUrl(input) {
267
+ try {
268
+ const url = new URL(input);
269
+ return /\/posts\/(?:[^/]+\/)?[A-Za-z0-9]+\/?$/.test(url.pathname) || /\/share\/p\/[A-Za-z0-9]+\/?$/.test(url.pathname) || (url.pathname === "/permalink.php" || url.pathname === "/story.php") && Boolean(url.searchParams.get("story_fbid"));
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+ function metaContent(html, key) {
275
+ for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
276
+ const tag = match[0];
277
+ if ((attribute(tag, "property") ?? attribute(tag, "name")) !== key) {
278
+ continue;
279
+ }
280
+ const content = attribute(tag, "content");
281
+ return content ? decodeHtml(content) : null;
282
+ }
283
+ return null;
284
+ }
285
+ function attribute(tag, name) {
286
+ const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, "is"));
287
+ return match?.[2] ?? null;
288
+ }
289
+ function decodeHtml(input) {
290
+ const named = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: '"' };
291
+ return input.replace(/&(#x[\da-f]+|#\d+|amp|apos|gt|lt|nbsp|quot);/gi, (entity, code) => {
292
+ if (code[0] !== "#") {
293
+ return named[code.toLowerCase()] ?? entity;
294
+ }
295
+ const value = code[1]?.toLowerCase() === "x" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
296
+ return Number.isSafeInteger(value) && value <= 1114111 ? String.fromCodePoint(value) : entity;
297
+ });
298
+ }
299
+ function mergeMetadata(page, watch) {
300
+ if (!page) {
301
+ return watch;
302
+ }
303
+ if (!watch) {
304
+ return page;
305
+ }
306
+ return { ...page, ...watch, author: watch.author ?? page.author };
307
+ }
234
308
  function source(html, key) {
235
309
  const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
236
310
  if (!match) {
@@ -809,7 +883,7 @@ function parseJson(value) {
809
883
  return JSON.parse(value);
810
884
  } catch {
811
885
  try {
812
- return JSON.parse(decodeHtml(value));
886
+ return JSON.parse(decodeHtml2(value));
813
887
  } catch {
814
888
  return null;
815
889
  }
@@ -844,7 +918,7 @@ function bestVideoUrl(html) {
844
918
  }
845
919
  let parsed;
846
920
  try {
847
- parsed = JSON.parse(decodeHtml(encoded));
921
+ parsed = JSON.parse(decodeHtml2(encoded));
848
922
  } catch {
849
923
  return null;
850
924
  }
@@ -881,7 +955,7 @@ function linkedinHandle(value) {
881
955
  return null;
882
956
  }
883
957
  }
884
- function decodeHtml(value) {
958
+ function decodeHtml2(value) {
885
959
  return value.replace(/&(quot|amp|lt|gt|#39|#x27|#(\d+)|#x([0-9a-f]+));/gi, (_entity, name, decimal, hex) => {
886
960
  switch (name.toLowerCase()) {
887
961
  case "quot":
@@ -1541,7 +1615,7 @@ function extensionOf(mime) {
1541
1615
 
1542
1616
  // src/soundcloud.ts
1543
1617
  async function resolveSoundcloud(input) {
1544
- const url = await canonicalUrl2(input.net, input.url);
1618
+ const url = await canonicalUrl(input.net, input.url);
1545
1619
  const clientId = await appClientId(input.net);
1546
1620
  const track = await resolveTrack(input.net, url, clientId);
1547
1621
  const id = trackId(track);
@@ -1567,7 +1641,7 @@ function soundcloudMetadata(track) {
1567
1641
  }
1568
1642
  };
1569
1643
  }
1570
- async function canonicalUrl2(net, input) {
1644
+ async function canonicalUrl(net, input) {
1571
1645
  const host = asUrl(input).hostname;
1572
1646
  if (host !== "on.soundcloud.com" && host !== "snd.sc") {
1573
1647
  return input;
@@ -1935,13 +2009,14 @@ async function resolveTwitter(input) {
1935
2009
  if (string(tweet.__typename) === "TweetTombstone") {
1936
2010
  throw new Error("Tweet is unavailable or age-restricted");
1937
2011
  }
1938
- const items = twitterTweets(tweet, id).flatMap(({ id: postId2, tweet: post }) => {
2012
+ const groups = twitterTweets(tweet, id).flatMap(({ id: postId2, tweet: post }) => {
1939
2013
  const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
1940
- return media.flatMap((entry, index) => twitterItem(entry, postId2, index + 1));
2014
+ return media.flatMap((entry, index) => {
2015
+ const candidates = twitterCandidates(entry, postId2, index + 1);
2016
+ return candidates.length > 0 ? [candidates] : [];
2017
+ });
1941
2018
  });
1942
- if (items.length === 0) {
1943
- throw new Error("Twitter media not found");
1944
- }
2019
+ const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
1945
2020
  return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
1946
2021
  }
1947
2022
  function twitterMetadata(tweet) {
@@ -2004,25 +2079,92 @@ async function syndication(net, id) {
2004
2079
  function syndicationToken(id) {
2005
2080
  return (Number(id) / 1000000000000000 * Math.PI).toString(36).replace(/(0+|\.)/g, "");
2006
2081
  }
2007
- function twitterItem(entry, id, index) {
2082
+ function twitterCandidates(entry, id, index) {
2008
2083
  const headers = { "user-agent": browserUserAgent() };
2009
2084
  const type = string(entry.type);
2010
2085
  if (type === "video" || type === "animated_gif") {
2011
- const url = bestVariant(entry);
2012
- return url ? [{ filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url }] : [];
2086
+ return videoVariants(entry).map(({ bitrate, url }) => ({
2087
+ bitrate,
2088
+ item: { filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url }
2089
+ }));
2013
2090
  }
2014
2091
  const photo = string(entry.media_url_https);
2015
- return photo ? [{ filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` }] : [];
2092
+ return photo ? [{ bitrate: 0, item: { filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` } }] : [];
2016
2093
  }
2017
- function bestVariant(entry) {
2094
+ function videoVariants(entry) {
2018
2095
  const info = object(entry.video_info) ? entry.video_info : null;
2019
2096
  const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
2020
- const best = variants.filter((variant) => string(variant.content_type) === "video/mp4").reduce((current, variant) => {
2021
- const bitrate = number(variant.bitrate) ?? 0;
2022
- const currentBitrate = current ? number(current.bitrate) ?? 0 : -1;
2023
- return bitrate > currentBitrate ? variant : current;
2024
- }, null);
2025
- return best ? string(best.url) : null;
2097
+ const seen = new Set;
2098
+ return variants.flatMap((variant) => {
2099
+ const url = string(variant.url);
2100
+ if (string(variant.content_type) !== "video/mp4" || !url || seen.has(url)) {
2101
+ return [];
2102
+ }
2103
+ seen.add(url);
2104
+ return [{ bitrate: number(variant.bitrate) ?? 0, url }];
2105
+ }).sort((left, right) => right.bitrate - left.bitrate);
2106
+ }
2107
+ async function sizeLimitedItems(input, groups) {
2108
+ if (groups.length === 0 || input.tryMaxBytes === undefined) {
2109
+ return groups.map((group) => group[0].item);
2110
+ }
2111
+ const sizes = new Map;
2112
+ const size = (candidate) => {
2113
+ const existing = sizes.get(candidate.item.url);
2114
+ if (existing) {
2115
+ return existing;
2116
+ }
2117
+ const pending = contentLength(input.net, candidate.item);
2118
+ sizes.set(candidate.item.url, pending);
2119
+ return pending;
2120
+ };
2121
+ const highest = groups.map((group) => group[0]);
2122
+ const highestSizes = await Promise.all(highest.map(size));
2123
+ const highestTotal = knownTotal(highestSizes);
2124
+ if (highestTotal !== null && highestTotal <= input.tryMaxBytes) {
2125
+ return highest.map(({ item }) => item);
2126
+ }
2127
+ const sized = await Promise.all(groups.map(async (group) => Promise.all(group.map(async (candidate) => ({ ...candidate, bytes: await size(candidate) })))));
2128
+ const fitting = bestFitting(sized, input.tryMaxBytes);
2129
+ return (fitting ?? groups.map((group) => group.at(-1))).map(({ item }) => item);
2130
+ }
2131
+ function knownTotal(sizes) {
2132
+ return sizes.some((size) => size === null) ? null : sizes.reduce((total, size) => total + (size ?? 0), 0);
2133
+ }
2134
+ function bestFitting(groups, maxBytes) {
2135
+ let selections = [
2136
+ { bytes: 0, candidates: [], quality: 0 }
2137
+ ];
2138
+ for (const group of groups) {
2139
+ selections = selections.flatMap((selection) => group.flatMap((candidate) => {
2140
+ if (candidate.bytes === null || selection.bytes + candidate.bytes > maxBytes) {
2141
+ return [];
2142
+ }
2143
+ return [{
2144
+ bytes: selection.bytes + candidate.bytes,
2145
+ candidates: [...selection.candidates, candidate],
2146
+ quality: selection.quality + candidate.bitrate
2147
+ }];
2148
+ }));
2149
+ if (selections.length === 0) {
2150
+ return null;
2151
+ }
2152
+ }
2153
+ const best = selections.reduce((current, selection) => selection.quality > current.quality ? selection : current);
2154
+ return best.candidates;
2155
+ }
2156
+ async function contentLength(net, item) {
2157
+ try {
2158
+ const response = await net(item.url, { headers: item.headers, method: "HEAD" }, 1);
2159
+ const raw = response.ok ? response.headers.get("content-length") : null;
2160
+ if (!raw || !/^\d+$/.test(raw)) {
2161
+ return null;
2162
+ }
2163
+ const bytes = Number(raw);
2164
+ return Number.isSafeInteger(bytes) ? bytes : null;
2165
+ } catch {
2166
+ return null;
2167
+ }
2026
2168
  }
2027
2169
  function tweetId(input) {
2028
2170
  return asUrl(input).pathname.match(/\/status(?:es)?\/(\d+)/)?.[1] ?? null;
@@ -2260,10 +2402,11 @@ async function postfetch(url, options = {}) {
2260
2402
  const context = {
2261
2403
  net: createNet(options.fetch ?? globalThis.fetch),
2262
2404
  preferredWidth: options.preferredWidth ?? 720,
2405
+ tryMaxBytes: options.tryMaxBytes,
2263
2406
  url: trimmed
2264
2407
  };
2265
2408
  const result = await resolve(context);
2266
- return options.tryMaxBytes === undefined ? result : trySmaller(context, result, options.tryMaxBytes);
2409
+ return options.tryMaxBytes === undefined || result.platform === "twitter" ? result : trySmaller(context, result, options.tryMaxBytes);
2267
2410
  }
2268
2411
  async function resolve(context) {
2269
2412
  switch (detect(context.url)) {
@@ -2315,12 +2458,12 @@ async function itemBytes(context, item) {
2315
2458
  return null;
2316
2459
  }
2317
2460
  const [video, audio] = await Promise.all([
2318
- contentLength(context, item.url, item.headers),
2319
- item.audio ? contentLength(context, item.audio.url, item.audio.headers) : Promise.resolve(0)
2461
+ contentLength2(context, item.url, item.headers),
2462
+ item.audio ? contentLength2(context, item.audio.url, item.audio.headers) : Promise.resolve(0)
2320
2463
  ]);
2321
2464
  return video === null || audio === null ? null : video + audio;
2322
2465
  }
2323
- async function contentLength(context, url, headers) {
2466
+ async function contentLength2(context, url, headers) {
2324
2467
  try {
2325
2468
  const response = await context.net(url, { headers, method: "HEAD" }, 1);
2326
2469
  const raw = response.ok ? response.headers.get("content-length") : null;
@@ -2370,6 +2513,153 @@ function detect(url) {
2370
2513
  }
2371
2514
  throw new PostfetchError(400, "only Facebook, Instagram, LinkedIn, Pinterest, Reddit, SoundCloud, TikTok, X and YouTube URLs are supported");
2372
2515
  }
2516
+ // src/mp4-remux.ts
2517
+ async function prepareMp4(bytes, ffmpegPath = "ffmpeg", ffprobePath = "ffprobe", runtime) {
2518
+ let directory;
2519
+ let io;
2520
+ try {
2521
+ io = runtime ?? await nodeRuntime();
2522
+ directory = await io.makeTempDir("postfetch-remux-");
2523
+ const inputPath = `${directory}/input.mp4`;
2524
+ const outputPath = `${directory}/output.mp4`;
2525
+ await io.writeFile(inputPath, bytes);
2526
+ const result = await io.run(ffmpegPath, [
2527
+ "-nostdin",
2528
+ "-hide_banner",
2529
+ "-loglevel",
2530
+ "error",
2531
+ "-y",
2532
+ "-i",
2533
+ inputPath,
2534
+ "-map",
2535
+ "0:v:0",
2536
+ "-map",
2537
+ "0:a:0?",
2538
+ "-c",
2539
+ "copy",
2540
+ "-movflags",
2541
+ "+faststart",
2542
+ "-avoid_negative_ts",
2543
+ "make_zero",
2544
+ "-use_editlist",
2545
+ "0",
2546
+ outputPath
2547
+ ]);
2548
+ if (!result.success) {
2549
+ return null;
2550
+ }
2551
+ const output = await io.readFile(outputPath);
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;
2599
+ } catch {
2600
+ return null;
2601
+ } finally {
2602
+ if (directory && io) {
2603
+ await io.removeDir(directory).catch(() => {
2604
+ return;
2605
+ });
2606
+ }
2607
+ }
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
+ }
2629
+ async function nodeRuntime() {
2630
+ const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
2631
+ import("node:child_process"),
2632
+ import("node:fs/promises"),
2633
+ import("node:os"),
2634
+ import("node:path")
2635
+ ]);
2636
+ return {
2637
+ makeTempDir: (prefix) => mkdtemp(join(tmpdir(), prefix)),
2638
+ readFile: async (path) => new Uint8Array(await readFile(path)),
2639
+ removeDir: async (path) => {
2640
+ await rm(path, { force: true, recursive: true });
2641
+ },
2642
+ run: (command, args) => new Promise((resolve2) => {
2643
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
2644
+ let stderr = "";
2645
+ let stdout = "";
2646
+ child.stdout?.on("data", (chunk) => {
2647
+ stdout += new TextDecoder().decode(chunk);
2648
+ });
2649
+ child.stderr?.on("data", (chunk) => {
2650
+ if (stderr.length < 4096) {
2651
+ stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
2652
+ }
2653
+ });
2654
+ child.once("error", (error) => resolve2({ stderr: error.message, stdout, success: false }));
2655
+ child.once("close", (code) => resolve2({ stderr, stdout, success: code === 0 }));
2656
+ }),
2657
+ writeFile: async (path, value) => {
2658
+ await writeFile(path, value);
2659
+ }
2660
+ };
2661
+ }
2662
+
2373
2663
  // src/remux.ts
2374
2664
  function mergeAudioVideo(video, audio) {
2375
2665
  const videoSource = analyze(video);
@@ -2605,13 +2895,37 @@ async function download(item, options = {}) {
2605
2895
  }
2606
2896
  return response;
2607
2897
  }
2608
- async function downloadBlob(url, headers = {}, options = {}) {
2898
+ async function downloadBlob(url, optionsOrHeaders = {}, legacyOptions = {}) {
2899
+ const modern = isDownloadBlobOptions(optionsOrHeaders);
2900
+ const options = modern ? optionsOrHeaders : legacyOptions;
2901
+ const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
2609
2902
  const net = createNet(options.fetch ?? globalThis.fetch);
2610
2903
  const response = await net(url, { headers });
2611
2904
  if (!response.ok || !response.body) {
2612
2905
  throw new PostfetchError(502, `download failed: ${response.status}`);
2613
2906
  }
2614
- return response.blob();
2907
+ const blob = await response.blob();
2908
+ if (!modern || optionsOrHeaders.remux !== true) {
2909
+ return blob;
2910
+ }
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
+ };
2922
+ }
2923
+ function isDownloadBlobOptions(value) {
2924
+ if (value instanceof Headers || Array.isArray(value)) {
2925
+ return false;
2926
+ }
2927
+ const candidate = value;
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";
2615
2929
  }
2616
2930
  async function archive(result, options = {}) {
2617
2931
  const net = createNet(options.fetch ?? globalThis.fetch);
@@ -169,6 +169,8 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
169
169
  export type ResolveContext = {
170
170
  net: Net;
171
171
  preferredWidth: number;
172
+ /** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
173
+ tryMaxBytes?: number;
172
174
  url: string;
173
175
  };
174
176
  /**
@@ -0,0 +1,26 @@
1
+ type CommandResult = {
2
+ stderr: string;
3
+ stdout: string;
4
+ success: boolean;
5
+ };
6
+ export type PreparedMp4 = {
7
+ bytes: Uint8Array;
8
+ duration: number;
9
+ height: number;
10
+ thumbnail: Uint8Array;
11
+ width: number;
12
+ };
13
+ export type Mp4RemuxRuntime = {
14
+ makeTempDir(prefix: string): Promise<string>;
15
+ readFile(path: string): Promise<Uint8Array>;
16
+ removeDir(path: string): Promise<void>;
17
+ run(command: string, args: string[]): Promise<CommandResult>;
18
+ writeFile(path: string, bytes: Uint8Array): Promise<void>;
19
+ };
20
+ /**
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.
24
+ */
25
+ export declare function prepareMp4(bytes: Uint8Array, ffmpegPath?: string, ffprobePath?: string, runtime?: Mp4RemuxRuntime): Promise<PreparedMp4 | null>;
26
+ export {};
@@ -8,8 +8,10 @@ export type PostfetchOptions = {
8
8
  /**
9
9
  * Soft file-size cap in bytes. The normal rendition is resolved and probed
10
10
  * with HEAD; when it exceeds the cap, a smaller available rendition is
11
- * returned. If size discovery or the smaller rendition is unavailable, the
12
- * normal result is returned unchanged.
11
+ * returned. X probes every available MP4 variant and picks the highest-quality
12
+ * complete result under the cap, or its smallest variants when none fit. For
13
+ * other platforms, if size discovery or a smaller rendition is unavailable,
14
+ * the normal result is returned unchanged.
13
15
  */
14
16
  tryMaxBytes?: number;
15
17
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.7.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,5 +1,6 @@
1
1
  import { assembleHls } from "./hls";
2
2
  import { createNet, PostfetchError, type MediaItem, type Net, type PostfetchResult } from "./internal";
3
+ import { prepareMp4 } from "./mp4-remux";
3
4
  import { mergeAudioVideo } from "./remux";
4
5
  import { zip } from "./zip";
5
6
 
@@ -15,6 +16,27 @@ export type DownloadOptions = {
15
16
  fetch?: typeof fetch;
16
17
  };
17
18
 
19
+ /** Options for {@link downloadBlob}. */
20
+ export type DownloadBlobOptions = DownloadOptions & {
21
+ /** Command used for opt-in MP4 remuxing. Defaults to `ffmpeg` from `PATH`. */
22
+ ffmpegPath?: string;
23
+ /** Command used to inspect remuxed MP4s. Defaults to `ffprobe` from `PATH`. */
24
+ ffprobePath?: string;
25
+ /** Headers required by the resolved media URL. */
26
+ headers?: HeadersInit;
27
+ /** Normalize an MP4 container with FFmpeg. Defaults to `false`. */
28
+ remux?: boolean;
29
+ };
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
+
18
40
  /** A zip archive produced by {@link archive}. */
19
41
  export type Archive = {
20
42
  /** The zip bytes (store mode, built in-process). */
@@ -59,31 +81,78 @@ export async function download(item: MediaItem, options: DownloadOptions = {}):
59
81
  *
60
82
  * This is useful for consumers that need to upload media themselves instead of
61
83
  * passing its URL to a third party. When the resolved {@link MediaItem} carries
62
- * required CDN headers, pass them as the second argument.
84
+ * required CDN headers, pass them in the options object. Set `remux: true` to
85
+ * normalize an MP4 with an FFmpeg stream copy; if FFmpeg is unavailable or the
86
+ * remux or metadata extraction fails, the operation throws.
63
87
  *
64
88
  * @param url A direct media URL returned by a resolver.
65
- * @param headers Headers required by the resolved media URL.
66
- * @param options Fetch options.
67
- * @returns The downloaded media as a Blob.
89
+ * @param options Headers, fetch implementation and opt-in remux behavior.
90
+ * @returns A Blob, or with `remux: true`, the normalized video and its metadata.
68
91
  *
69
92
  * @example Upload a video with FormData
70
93
  * ```ts
71
94
  * const [media] = (await postfetch(sourceUrl)).items;
72
- * const blob = await downloadBlob(media.url, media.headers);
73
- * 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");
74
98
  * ```
75
99
  */
76
100
  export async function downloadBlob(
77
101
  url: string,
78
- headers: HeadersInit = {},
79
- options: DownloadOptions = {},
80
- ): Promise<Blob> {
102
+ options: DownloadBlobOptions & { remux: true },
103
+ ): Promise<RemuxedVideo>;
104
+ export async function downloadBlob(
105
+ url: string,
106
+ options?: DownloadBlobOptions & { remux?: false },
107
+ ): Promise<Blob>;
108
+ /** @deprecated Pass headers and fetch in a single {@link DownloadBlobOptions} object. */
109
+ export async function downloadBlob(url: string, headers?: HeadersInit, options?: DownloadOptions): Promise<Blob>;
110
+ export async function downloadBlob(
111
+ url: string,
112
+ optionsOrHeaders: DownloadBlobOptions | HeadersInit = {},
113
+ legacyOptions: DownloadOptions = {},
114
+ ): Promise<Blob | RemuxedVideo> {
115
+ const modern = isDownloadBlobOptions(optionsOrHeaders);
116
+ const options = modern ? optionsOrHeaders : legacyOptions;
117
+ const headers = modern ? optionsOrHeaders.headers ?? {} : optionsOrHeaders;
81
118
  const net = createNet(options.fetch ?? globalThis.fetch);
82
119
  const response = await net(url, { headers });
83
120
  if (!response.ok || !response.body) {
84
121
  throw new PostfetchError(502, `download failed: ${response.status}`);
85
122
  }
86
- return response.blob();
123
+ const blob = await response.blob();
124
+ if (!modern || optionsOrHeaders.remux !== true) {
125
+ return blob;
126
+ }
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
+ };
142
+ }
143
+
144
+ function isDownloadBlobOptions(value: DownloadBlobOptions | HeadersInit): value is DownloadBlobOptions {
145
+ if (value instanceof Headers || Array.isArray(value)) {
146
+ return false;
147
+ }
148
+ const candidate = value as Record<string, unknown>;
149
+ return (
150
+ ("headers" in candidate && typeof candidate.headers !== "string") ||
151
+ typeof candidate.remux === "boolean" ||
152
+ typeof candidate.ffmpegPath === "string" ||
153
+ typeof candidate.ffprobePath === "string" ||
154
+ typeof candidate.fetch === "function"
155
+ );
87
156
  }
88
157
 
89
158
  /**
package/src/facebook.ts CHANGED
@@ -2,14 +2,22 @@ import { asUrl, count, filename, isoFromEpochSeconds, PostfetchError, type PostM
2
2
  import { browserUserAgent } from "./fingerprint";
3
3
 
4
4
  export async function resolveFacebook(input: ResolveContext): Promise<PostfetchResult> {
5
- const canonical = await canonicalUrl(input.net, normalizedInputUrl(input.url));
5
+ const page = await canonicalPage(input.net, normalizedInputUrl(input.url));
6
+ const canonical = page.url;
6
7
  const id = facebookId(canonical) ?? facebookId(input.url) ?? "video";
8
+ const pageMetadata = facebookMetadata(page.html);
7
9
  // The embed player only exposes /share/v/ videos; reels (and anything it
8
10
  // misses) come from the watch page, which the &_rdr flag serves logged-out.
9
11
  const embed = await embedVideo(input.net, canonical, input.preferredWidth);
10
12
  const watch = !embed && /^\d+$/.test(id) ? await watchVideo(input.net, id, input.preferredWidth) : null;
11
13
  const url = embed ?? watch?.url ?? null;
12
14
  if (!url) {
15
+ // As with Reddit and X, a public text post is a valid result even when it
16
+ // has no downloadable media. Require post metadata from the canonical page
17
+ // so a missing or login-walled video does not become a false success.
18
+ if (pageMetadata?.text) {
19
+ return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [], metadata: pageMetadata, platform: "facebook" };
20
+ }
13
21
  throw new PostfetchError(404, "Facebook video not found", "notFound");
14
22
  }
15
23
  const item: MediaItem = {
@@ -21,7 +29,13 @@ export async function resolveFacebook(input: ResolveContext): Promise<PostfetchR
21
29
  platform: "facebook",
22
30
  url,
23
31
  };
24
- return { archiveFilename: filename(`facebook_${id}.zip`), id, items: [item], metadata: watch?.metadata, platform: "facebook" };
32
+ return {
33
+ archiveFilename: filename(`facebook_${id}.zip`),
34
+ id,
35
+ items: [item],
36
+ metadata: mergeMetadata(pageMetadata, watch?.metadata),
37
+ platform: "facebook",
38
+ };
25
39
  }
26
40
 
27
41
  // Facebook share links are opaque shortlinks. Messaging apps sometimes append
@@ -43,12 +57,15 @@ function navigationHeaders(): Record<string, string> {
43
57
  }
44
58
 
45
59
  // Share and fb.watch links redirect to the canonical /reel/<id> or /<page>/videos/<id>
46
- // URL, which the public embed player needs. We only read the resolved location, not the
47
- // (login-walled) page body.
48
- async function canonicalUrl(net: Net, input: string): Promise<string> {
60
+ // URL, which the public embed player needs. Public post pages also carry Open
61
+ // Graph metadata in their logged-out HTML, so retain the body after redirecting.
62
+ async function canonicalPage(net: Net, input: string): Promise<{ html: string; url: string }> {
49
63
  const response = await net(input, { headers: navigationHeaders() });
50
- const resolved = asUrl(response.url);
51
- return `${resolved.origin}${resolved.pathname}`;
64
+ const resolved = asUrl(response.url || input);
65
+ return {
66
+ html: response.ok ? await response.text() : "",
67
+ url: `${resolved.origin}${resolved.pathname}`,
68
+ };
52
69
  }
53
70
 
54
71
  // The logged-out watch page is fingerprint-walled, but the public embed player
@@ -100,6 +117,76 @@ function watchMetadata(html: string): PostMetadata {
100
117
  };
101
118
  }
102
119
 
120
+ // Public post pages expose their caption and page/profile name as Open Graph
121
+ // metadata even when there is no media player. Facebook truncates long captions
122
+ // there, but it is still enough to identify and represent a text post.
123
+ function facebookMetadata(html: string): PostMetadata | undefined {
124
+ const canonical = metaContent(html, "og:url");
125
+ if (!canonical || !facebookPostUrl(canonical)) {
126
+ return undefined;
127
+ }
128
+ const text = metaContent(html, "og:description") ?? metaContent(html, "description");
129
+ const name = metaContent(html, "og:title");
130
+ if (!text && !name) {
131
+ return undefined;
132
+ }
133
+ return {
134
+ text: text ?? undefined,
135
+ author: name ? { name } : undefined,
136
+ };
137
+ }
138
+
139
+ function facebookPostUrl(input: string): boolean {
140
+ try {
141
+ const url = new URL(input);
142
+ return (
143
+ /\/posts\/(?:[^/]+\/)?[A-Za-z0-9]+\/?$/.test(url.pathname) ||
144
+ /\/share\/p\/[A-Za-z0-9]+\/?$/.test(url.pathname) ||
145
+ ((url.pathname === "/permalink.php" || url.pathname === "/story.php") && Boolean(url.searchParams.get("story_fbid")))
146
+ );
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ function metaContent(html: string, key: string): string | null {
153
+ for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
154
+ const tag = match[0];
155
+ if ((attribute(tag, "property") ?? attribute(tag, "name")) !== key) {
156
+ continue;
157
+ }
158
+ const content = attribute(tag, "content");
159
+ return content ? decodeHtml(content) : null;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ function attribute(tag: string, name: string): string | null {
165
+ const match = tag.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, "is"));
166
+ return match?.[2] ?? null;
167
+ }
168
+
169
+ function decodeHtml(input: string): string {
170
+ const named: Record<string, string> = { amp: "&", apos: "'", gt: ">", lt: "<", nbsp: " ", quot: '"' };
171
+ return input.replace(/&(#x[\da-f]+|#\d+|amp|apos|gt|lt|nbsp|quot);/gi, (entity, code: string) => {
172
+ if (code[0] !== "#") {
173
+ return named[code.toLowerCase()] ?? entity;
174
+ }
175
+ const value = code[1]?.toLowerCase() === "x" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
176
+ return Number.isSafeInteger(value) && value <= 0x10ffff ? String.fromCodePoint(value) : entity;
177
+ });
178
+ }
179
+
180
+ function mergeMetadata(page: PostMetadata | undefined, watch: PostMetadata | undefined): PostMetadata | undefined {
181
+ if (!page) {
182
+ return watch;
183
+ }
184
+ if (!watch) {
185
+ return page;
186
+ }
187
+ return { ...page, ...watch, author: watch.author ?? page.author };
188
+ }
189
+
103
190
  function source(html: string, key: string): string | null {
104
191
  const match = html.match(new RegExp(`"${key}":("(?:\\\\.|[^"\\\\])*")`));
105
192
  if (!match) {
package/src/index.ts CHANGED
@@ -26,7 +26,9 @@ export {
26
26
  archive,
27
27
  toResponse,
28
28
  type Archive,
29
+ type DownloadBlobOptions,
29
30
  type DownloadOptions,
31
+ type RemuxedVideo,
30
32
  } from "./download";
31
33
  export { PostfetchError } from "./internal";
32
34
  export type {
package/src/internal.ts CHANGED
@@ -189,6 +189,8 @@ export type Net = (url: string, init?: RequestInit, attempts?: number) => Promis
189
189
  export type ResolveContext = {
190
190
  net: Net;
191
191
  preferredWidth: number;
192
+ /** Requested soft byte cap, for resolvers that can rank exact rendition sizes. */
193
+ tryMaxBytes?: number;
192
194
  url: string;
193
195
  };
194
196
 
@@ -0,0 +1,197 @@
1
+ type CommandResult = {
2
+ stderr: string;
3
+ stdout: string;
4
+ success: boolean;
5
+ };
6
+
7
+ export type PreparedMp4 = {
8
+ bytes: Uint8Array;
9
+ duration: number;
10
+ height: number;
11
+ thumbnail: Uint8Array;
12
+ width: number;
13
+ };
14
+
15
+ export type Mp4RemuxRuntime = {
16
+ makeTempDir(prefix: string): Promise<string>;
17
+ readFile(path: string): Promise<Uint8Array>;
18
+ removeDir(path: string): Promise<void>;
19
+ run(command: string, args: string[]): Promise<CommandResult>;
20
+ writeFile(path: string, bytes: Uint8Array): Promise<void>;
21
+ };
22
+
23
+ /**
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.
27
+ */
28
+ export async function prepareMp4(
29
+ bytes: Uint8Array,
30
+ ffmpegPath = "ffmpeg",
31
+ ffprobePath = "ffprobe",
32
+ runtime?: Mp4RemuxRuntime,
33
+ ): Promise<PreparedMp4 | null> {
34
+ let directory: string | undefined;
35
+ let io: Mp4RemuxRuntime | undefined;
36
+ try {
37
+ io = runtime ?? (await nodeRuntime());
38
+ directory = await io.makeTempDir("postfetch-remux-");
39
+ const inputPath = `${directory}/input.mp4`;
40
+ const outputPath = `${directory}/output.mp4`;
41
+ await io.writeFile(inputPath, bytes);
42
+ const result = await io.run(ffmpegPath, [
43
+ "-nostdin",
44
+ "-hide_banner",
45
+ "-loglevel",
46
+ "error",
47
+ "-y",
48
+ "-i",
49
+ inputPath,
50
+ "-map",
51
+ "0:v:0",
52
+ "-map",
53
+ "0:a:0?",
54
+ "-c",
55
+ "copy",
56
+ "-movflags",
57
+ "+faststart",
58
+ "-avoid_negative_ts",
59
+ "make_zero",
60
+ "-use_editlist",
61
+ "0",
62
+ outputPath,
63
+ ]);
64
+ if (!result.success) {
65
+ return null;
66
+ }
67
+ const output = await io.readFile(outputPath);
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;
115
+ } catch {
116
+ return null;
117
+ } finally {
118
+ if (directory && io) {
119
+ await io.removeDir(directory).catch(() => undefined);
120
+ }
121
+ }
122
+ }
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
+
161
+ // Deno, Bun and Node all expose the Node compatibility modules used here. They
162
+ // stay behind dynamic imports so browser/edge consumers that leave remux off do
163
+ // not load or execute any process/filesystem code.
164
+ async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
165
+ const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
166
+ import("node:child_process"),
167
+ import("node:fs/promises"),
168
+ import("node:os"),
169
+ import("node:path"),
170
+ ]);
171
+ return {
172
+ makeTempDir: (prefix) => mkdtemp(join(tmpdir(), prefix)),
173
+ readFile: async (path) => new Uint8Array(await readFile(path)),
174
+ removeDir: async (path) => {
175
+ await rm(path, { force: true, recursive: true });
176
+ },
177
+ run: (command, args) =>
178
+ new Promise((resolve) => {
179
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
180
+ let stderr = "";
181
+ let stdout = "";
182
+ child.stdout?.on("data", (chunk: Uint8Array) => {
183
+ stdout += new TextDecoder().decode(chunk);
184
+ });
185
+ child.stderr?.on("data", (chunk: Uint8Array) => {
186
+ if (stderr.length < 4096) {
187
+ stderr += new TextDecoder().decode(chunk).slice(0, 4096 - stderr.length);
188
+ }
189
+ });
190
+ child.once("error", (error) => resolve({ stderr: error.message, stdout, success: false }));
191
+ child.once("close", (code) => resolve({ stderr, stdout, success: code === 0 }));
192
+ }),
193
+ writeFile: async (path, value) => {
194
+ await writeFile(path, value);
195
+ },
196
+ };
197
+ }
package/src/postfetch.ts CHANGED
@@ -18,8 +18,10 @@ export type PostfetchOptions = {
18
18
  /**
19
19
  * Soft file-size cap in bytes. The normal rendition is resolved and probed
20
20
  * with HEAD; when it exceeds the cap, a smaller available rendition is
21
- * returned. If size discovery or the smaller rendition is unavailable, the
22
- * normal result is returned unchanged.
21
+ * returned. X probes every available MP4 variant and picks the highest-quality
22
+ * complete result under the cap, or its smallest variants when none fit. For
23
+ * other platforms, if size discovery or a smaller rendition is unavailable,
24
+ * the normal result is returned unchanged.
23
25
  */
24
26
  tryMaxBytes?: number;
25
27
  };
@@ -57,10 +59,13 @@ export async function postfetch(url: string, options: PostfetchOptions = {}): Pr
57
59
  const context: ResolveContext = {
58
60
  net: createNet(options.fetch ?? globalThis.fetch),
59
61
  preferredWidth: options.preferredWidth ?? 720,
62
+ tryMaxBytes: options.tryMaxBytes,
60
63
  url: trimmed,
61
64
  };
62
65
  const result = await resolve(context);
63
- return options.tryMaxBytes === undefined ? result : trySmaller(context, result, options.tryMaxBytes);
66
+ return options.tryMaxBytes === undefined || result.platform === "twitter"
67
+ ? result
68
+ : trySmaller(context, result, options.tryMaxBytes);
64
69
  }
65
70
 
66
71
  async function resolve(context: ResolveContext): Promise<PostfetchResult> {
package/src/twitter.ts CHANGED
@@ -17,6 +17,11 @@ import {
17
17
  } from "./internal";
18
18
  import { browserUserAgent } from "./fingerprint";
19
19
 
20
+ type TwitterCandidate = {
21
+ bitrate: number;
22
+ item: MediaItem;
23
+ };
24
+
20
25
  export async function resolveTwitter(input: ResolveContext): Promise<PostfetchResult> {
21
26
  const id = tweetId(input.url);
22
27
  if (!id) {
@@ -26,13 +31,17 @@ export async function resolveTwitter(input: ResolveContext): Promise<PostfetchRe
26
31
  if (string(tweet.__typename) === "TweetTombstone") {
27
32
  throw new Error("Tweet is unavailable or age-restricted");
28
33
  }
29
- const items = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
34
+ const groups = twitterTweets(tweet, id).flatMap(({ id: postId, tweet: post }) => {
30
35
  const media = Array.isArray(post.mediaDetails) ? post.mediaDetails.filter(object) : [];
31
- return media.flatMap((entry, index) => twitterItem(entry, postId, index + 1));
36
+ return media.flatMap((entry, index) => {
37
+ const candidates = twitterCandidates(entry, postId, index + 1);
38
+ return candidates.length > 0 ? [candidates] : [];
39
+ });
32
40
  });
33
- if (items.length === 0) {
34
- throw new Error("Twitter media not found");
35
- }
41
+ const items = input.tryMaxBytes === undefined ? groups.map((group) => group[0].item) : await sizeLimitedItems(input, groups);
42
+ // A text-only tweet is a valid result with metadata and no media. Syndication
43
+ // has already failed or returned a tombstone when the tweet is unavailable,
44
+ // so an empty item list here does not mean the lookup failed.
36
45
  return { archiveFilename: filename(`twitter_${id}.zip`), id, items, metadata: twitterMetadata(tweet), platform: "twitter" };
37
46
  }
38
47
 
@@ -104,32 +113,112 @@ function syndicationToken(id: string): string {
104
113
  return ((Number(id) / 1e15) * Math.PI).toString(36).replace(/(0+|\.)/g, "");
105
114
  }
106
115
 
107
- function twitterItem(entry: Json, id: string, index: number): MediaItem[] {
116
+ function twitterCandidates(entry: Json, id: string, index: number): TwitterCandidate[] {
108
117
  const headers = { "user-agent": browserUserAgent() };
109
118
  const type = string(entry.type);
110
119
  if (type === "video" || type === "animated_gif") {
111
- const url = bestVariant(entry);
112
- return url
113
- ? [{ filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url }]
114
- : [];
120
+ return videoVariants(entry).map(({ bitrate, url }) => ({
121
+ bitrate,
122
+ item: { filename: filename(`twitter_${id}_${index}.mp4`), headers, id, kind: "video", mime: "video/mp4", platform: "twitter", url },
123
+ }));
115
124
  }
116
125
  const photo = string(entry.media_url_https);
117
126
  return photo
118
- ? [{ filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` }]
127
+ ? [{ bitrate: 0, item: { filename: filename(`twitter_${id}_${index}.jpg`), headers, id, kind: "image", mime: "image/jpeg", platform: "twitter", url: `${photo}?name=orig` } }]
119
128
  : [];
120
129
  }
121
130
 
122
- function bestVariant(entry: Json): string | null {
131
+ function videoVariants(entry: Json): Array<{ bitrate: number; url: string }> {
123
132
  const info = object(entry.video_info) ? entry.video_info : null;
124
133
  const variants = info && Array.isArray(info.variants) ? info.variants.filter(object) : [];
125
- const best = variants
126
- .filter((variant) => string(variant.content_type) === "video/mp4")
127
- .reduce<Json | null>((current, variant) => {
128
- const bitrate = number(variant.bitrate) ?? 0;
129
- const currentBitrate = current ? number(current.bitrate) ?? 0 : -1;
130
- return bitrate > currentBitrate ? variant : current;
131
- }, null);
132
- return best ? string(best.url) : null;
134
+ const seen = new Set<string>();
135
+ return variants
136
+ .flatMap((variant) => {
137
+ const url = string(variant.url);
138
+ if (string(variant.content_type) !== "video/mp4" || !url || seen.has(url)) {
139
+ return [];
140
+ }
141
+ seen.add(url);
142
+ return [{ bitrate: number(variant.bitrate) ?? 0, url }];
143
+ })
144
+ .sort((left, right) => right.bitrate - left.bitrate);
145
+ }
146
+
147
+ async function sizeLimitedItems(input: ResolveContext, groups: TwitterCandidate[][]): Promise<MediaItem[]> {
148
+ if (groups.length === 0 || input.tryMaxBytes === undefined) {
149
+ return groups.map((group) => group[0].item);
150
+ }
151
+ const sizes = new Map<string, Promise<number | null>>();
152
+ const size = (candidate: TwitterCandidate): Promise<number | null> => {
153
+ const existing = sizes.get(candidate.item.url);
154
+ if (existing) {
155
+ return existing;
156
+ }
157
+ const pending = contentLength(input.net, candidate.item);
158
+ sizes.set(candidate.item.url, pending);
159
+ return pending;
160
+ };
161
+
162
+ const highest = groups.map((group) => group[0]);
163
+ const highestSizes = await Promise.all(highest.map(size));
164
+ const highestTotal = knownTotal(highestSizes);
165
+ if (highestTotal !== null && highestTotal <= input.tryMaxBytes) {
166
+ return highest.map(({ item }) => item);
167
+ }
168
+
169
+ const sized = await Promise.all(
170
+ groups.map(async (group) =>
171
+ Promise.all(group.map(async (candidate) => ({ ...candidate, bytes: await size(candidate) }))),
172
+ ),
173
+ );
174
+ const fitting = bestFitting(sized, input.tryMaxBytes);
175
+ return (fitting ?? groups.map((group) => group.at(-1) as TwitterCandidate)).map(({ item }) => item);
176
+ }
177
+
178
+ function knownTotal(sizes: Array<number | null>): number | null {
179
+ return sizes.some((size) => size === null) ? null : sizes.reduce<number>((total, size) => total + (size ?? 0), 0);
180
+ }
181
+
182
+ function bestFitting(
183
+ groups: Array<Array<TwitterCandidate & { bytes: number | null }>>,
184
+ maxBytes: number,
185
+ ): TwitterCandidate[] | null {
186
+ let selections: Array<{ bytes: number; candidates: TwitterCandidate[]; quality: number }> = [
187
+ { bytes: 0, candidates: [], quality: 0 },
188
+ ];
189
+ for (const group of groups) {
190
+ selections = selections.flatMap((selection) =>
191
+ group.flatMap((candidate) => {
192
+ if (candidate.bytes === null || selection.bytes + candidate.bytes > maxBytes) {
193
+ return [];
194
+ }
195
+ return [{
196
+ bytes: selection.bytes + candidate.bytes,
197
+ candidates: [...selection.candidates, candidate],
198
+ quality: selection.quality + candidate.bitrate,
199
+ }];
200
+ }),
201
+ );
202
+ if (selections.length === 0) {
203
+ return null;
204
+ }
205
+ }
206
+ const best = selections.reduce((current, selection) => (selection.quality > current.quality ? selection : current));
207
+ return best.candidates;
208
+ }
209
+
210
+ async function contentLength(net: Net, item: MediaItem): Promise<number | null> {
211
+ try {
212
+ const response = await net(item.url, { headers: item.headers, method: "HEAD" }, 1);
213
+ const raw = response.ok ? response.headers.get("content-length") : null;
214
+ if (!raw || !/^\d+$/.test(raw)) {
215
+ return null;
216
+ }
217
+ const bytes = Number(raw);
218
+ return Number.isSafeInteger(bytes) ? bytes : null;
219
+ } catch {
220
+ return null;
221
+ }
133
222
  }
134
223
 
135
224
  export function tweetId(input: string): string | null {