@postfetch/core 0.9.2 → 0.10.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
@@ -24,9 +24,29 @@ defaults to off, uses `ffmpeg` and `ffprobe` from `PATH` unless their paths are
24
24
  set, and throws if the complete result cannot be produced. The legacy
25
25
  `(url, headers?, options?)` overload remains supported for non-remux downloads.
26
26
 
27
+ `buildAudioSliderVideo(items, { delay: 3000 })` returns an MP4 `Blob` from one or
28
+ more images/videos followed by exactly one audio item. `delay` is milliseconds
29
+ per visual, rounded to 30 fps (minimum two frames), including a right-to-left
30
+ transition lasting up to 300 ms or half the delay. The last visual stays visible;
31
+ a single visual has no transition. Total duration is visual count × rounded
32
+ delay. Videos loop or trim to fit and their own audio is discarded; the trailing
33
+ audio loops and trims to the result. The default 720×1280 canvas preserves
34
+ aspect ratio with black padding. `AudioSliderVideoOptions` supports `width` and
35
+ `height` (positive even integers), `fetch`, and `ffmpegPath`. Requires local
36
+ FFmpeg with `libx264` and a runtime with filesystem/process access (Node, Bun,
37
+ Deno). Downloads retain item headers and assemble HLS; temporary files are
38
+ cleaned up on success and failure.
39
+
40
+ ```ts
41
+ import { buildAudioSliderVideo } from "@postfetch/core";
42
+
43
+ const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
44
+ form.append("video", blob, "slideshow.mp4");
45
+ ```
46
+
27
47
  - `PostfetchOptions` — `{ fetch?: typeof fetch; preferredWidth?: number; tryMaxBytes?: number }`. `tryMaxBytes` is a soft byte cap. X probes every MP4 variant and selects the highest-quality complete result that fits, falling back to its smallest variants when none do. Other platforms return a smaller available rendition when possible and otherwise keep the normal result. Inject `fetch` to unit-test resolvers offline.
28
48
  - Every request rotates a consistent browser/app fingerprint, so a fixed user-agent never gets the whole fleet blocked.
29
49
  - No runtime dependencies, no `yt-dlp`, no cookies. `ffmpeg` is only used when
30
- the opt-in `downloadBlob(..., { remux: true })` path is requested.
50
+ `downloadBlob(..., { remux: true })` or `buildAudioSliderVideo` is requested.
31
51
 
32
52
  See the [root README](https://github.com/chelokot/postfetch#readme) for the full API table, supported inputs and the fingerprint design.
@@ -0,0 +1,36 @@
1
+ import { type DownloadOptions } from "./download";
2
+ import { type MediaItem } from "./internal";
3
+ /** Options for {@link buildAudioSliderVideo}. Requires a local FFmpeg binary. */
4
+ export type AudioSliderVideoOptions = DownloadOptions & {
5
+ /** Time per visual item in milliseconds, including its outgoing transition. */
6
+ delay: number;
7
+ /** Output width in pixels; a positive even integer. Defaults to 720. */
8
+ width?: number;
9
+ /** Output height in pixels; a positive even integer. Defaults to 1280. */
10
+ height?: number;
11
+ /** FFmpeg command or executable path. Defaults to `ffmpeg` from PATH. */
12
+ ffmpegPath?: string;
13
+ };
14
+ /**
15
+ * Build an H.264/AAC MP4 Blob from one or more images/videos followed by exactly
16
+ * one audio item. Downloads preserve each item's headers and assemble HLS.
17
+ * Visuals retain their aspect ratio, padded with black to the output size.
18
+ * Video clips play from the beginning, looping or trimming to fit; their own
19
+ * audio is discarded. The final audio item loops and is trimmed to the result.
20
+ *
21
+ * Each visual gets `delay` ms (rounded to 30 fps, minimum two frames). Adjacent
22
+ * visuals slide right to left over the last 300 ms or half the delay, whichever
23
+ * is shorter. The last visual stays on screen; a single visual has no transition.
24
+ * Total duration is the number of visuals times the rounded delay.
25
+ *
26
+ * Requires Node, Bun or Deno with filesystem/process access and FFmpeg with
27
+ * libx264. Temporary files are removed on success and failure.
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const result = await postfetch(photoUrl);
32
+ * const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
33
+ * form.append("video", blob, "slideshow.mp4");
34
+ * ```
35
+ */
36
+ export declare function buildAudioSliderVideo(items: readonly MediaItem[], options: AudioSliderVideoOptions): Promise<Blob>;
package/dist/hls.d.ts CHANGED
@@ -5,6 +5,7 @@ export type HlsVariant = {
5
5
  bandwidth: number;
6
6
  url: string;
7
7
  audioGroup: string | null;
8
+ codecs?: string;
8
9
  };
9
10
  export type HlsMaster = {
10
11
  variants: HlsVariant[];
package/dist/index.d.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * @module
21
21
  */
22
22
  export { postfetch, detect, type PostfetchOptions } from "./postfetch";
23
+ export { buildAudioSliderVideo, type AudioSliderVideoOptions } from "./audio-slider";
23
24
  export { download, downloadBlob, archive, toResponse, type Archive, type DownloadBlobOptions, type DownloadOptions, type RemuxedVideo, } from "./download";
24
25
  export { PostfetchError } from "./internal";
25
26
  export type { InstagramExtra, MediaItem, MediaKind, PinterestExtra, Platform, PlatformResult, PostAuthor, PostfetchReason, PostfetchResult, PostMetadata, RedditExtra, SoundcloudExtra, TiktokExtra, TwitterExtra, TwitterQuotedTweet, YoutubeExtra, } from "./internal";
package/dist/index.js CHANGED
@@ -1038,6 +1038,175 @@ function boxVersion(data, box) {
1038
1038
  return data[box.dataStart];
1039
1039
  }
1040
1040
 
1041
+ // src/packed-aac.ts
1042
+ var sampleRates = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
1043
+ var encoder = new TextEncoder;
1044
+ function packedAacToMp4(segments) {
1045
+ const first = segments[0];
1046
+ if (!first || !id3(first, 0) && !adts(first, 0)) {
1047
+ return null;
1048
+ }
1049
+ let config;
1050
+ let nextTimestamp = 0;
1051
+ const fragments = [];
1052
+ for (const segment of segments) {
1053
+ let offset = 0;
1054
+ let timestamp;
1055
+ while (id3(segment, offset)) {
1056
+ const tag = readId3(segment, offset);
1057
+ timestamp ??= tag.timestamp;
1058
+ offset = tag.end;
1059
+ }
1060
+ const samples = [];
1061
+ while (offset < segment.length) {
1062
+ if (offset + 7 > segment.length || !adts(segment, offset)) {
1063
+ throw new Error("Invalid packed AAC frame");
1064
+ }
1065
+ const profile = (segment[offset + 2] >> 6) + 1;
1066
+ const frequencyIndex = segment[offset + 2] >> 2 & 15;
1067
+ const sampleRate = sampleRates[frequencyIndex];
1068
+ const channels = (segment[offset + 2] & 1) << 2 | segment[offset + 3] >> 6;
1069
+ if (profile !== 2 || !sampleRate || sampleRate > 65535 || channels === 0 || (segment[offset + 6] & 3) !== 0) {
1070
+ throw new Error("Unsupported packed AAC configuration (expected AAC-LC)");
1071
+ }
1072
+ if (config && (config.frequencyIndex !== frequencyIndex || config.channels !== channels)) {
1073
+ throw new Error("Packed AAC configuration changed between frames");
1074
+ }
1075
+ config ??= { frequencyIndex, sampleRate, channels };
1076
+ const length = (segment[offset + 3] & 3) << 11 | segment[offset + 4] << 3 | segment[offset + 5] >> 5;
1077
+ const headerLength = segment[offset + 1] & 1 ? 7 : 9;
1078
+ if (length <= headerLength || offset + length > segment.length) {
1079
+ throw new Error("Truncated packed AAC frame");
1080
+ }
1081
+ samples.push(segment.subarray(offset + headerLength, offset + length));
1082
+ offset += length;
1083
+ }
1084
+ if (!config || samples.length === 0) {
1085
+ throw new Error("Packed AAC segment has no audio frames");
1086
+ }
1087
+ const start = timestamp === undefined ? nextTimestamp : Math.round(timestamp * config.sampleRate / 90000);
1088
+ fragments.push({ samples, timestamp: start });
1089
+ nextTimestamp = start + samples.length * 1024;
1090
+ }
1091
+ return concat([initSegment(config), ...fragments.flatMap((fragment, index) => mediaFragment(fragment, index + 1))]);
1092
+ }
1093
+ function id3(bytes, offset) {
1094
+ return bytes[offset] === 73 && bytes[offset + 1] === 68 && bytes[offset + 2] === 51;
1095
+ }
1096
+ function adts(bytes, offset) {
1097
+ return bytes[offset] === 255 && (bytes[offset + 1] & 246) === 240;
1098
+ }
1099
+ function synchsafe(bytes, offset) {
1100
+ if (offset + 4 > bytes.length || bytes.subarray(offset, offset + 4).some((byte) => byte & 128)) {
1101
+ throw new Error("Invalid packed AAC ID3 size");
1102
+ }
1103
+ return bytes[offset] * 2 ** 21 + bytes[offset + 1] * 2 ** 14 + bytes[offset + 2] * 128 + bytes[offset + 3];
1104
+ }
1105
+ function readId3(bytes, offset) {
1106
+ const version = bytes[offset + 3];
1107
+ if (offset + 10 > bytes.length || version !== 3 && version !== 4 || bytes[offset + 5] & 192) {
1108
+ throw new Error("Unsupported packed AAC ID3 tag");
1109
+ }
1110
+ const end = offset + 10 + synchsafe(bytes, offset + 6);
1111
+ if (end > bytes.length) {
1112
+ throw new Error("Truncated packed AAC ID3 tag");
1113
+ }
1114
+ const owner = encoder.encode("com.apple.streaming.transportStreamTimestamp\x00");
1115
+ let timestamp;
1116
+ for (let cursor = offset + 10;cursor + 10 <= end && bytes[cursor] !== 0; ) {
1117
+ const size = version === 4 ? synchsafe(bytes, cursor + 4) : readU32(bytes, cursor + 4);
1118
+ const body = cursor + 10;
1119
+ if (body + size > end) {
1120
+ throw new Error("Truncated packed AAC ID3 frame");
1121
+ }
1122
+ if (String.fromCharCode(...bytes.subarray(cursor, cursor + 4)) === "PRIV" && size === owner.length + 8 && owner.every((byte, index) => bytes[body + index] === byte)) {
1123
+ if (bytes[cursor + 8] !== 0 || bytes[cursor + 9] !== 0) {
1124
+ throw new Error("Unsupported packed AAC timestamp encoding");
1125
+ }
1126
+ timestamp = Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(body + owner.length) & 0x1ffffffffn);
1127
+ }
1128
+ cursor = body + size;
1129
+ }
1130
+ const footer = version === 4 && bytes[offset + 5] & 16 ? 10 : 0;
1131
+ if (end + footer > bytes.length) {
1132
+ throw new Error("Truncated packed AAC ID3 footer");
1133
+ }
1134
+ return { end: end + footer, timestamp };
1135
+ }
1136
+ function u32(...values) {
1137
+ const bytes = new Uint8Array(values.length * 4);
1138
+ values.forEach((value, index) => writeU32(bytes, index * 4, value));
1139
+ return bytes;
1140
+ }
1141
+ function fullBox(type, version, flags, payload) {
1142
+ return buildBox(type, concat([u32(version * 16777216 + flags), payload]));
1143
+ }
1144
+ function matrix(bytes, offset) {
1145
+ bytes.set(u32(65536, 0, 0, 0, 65536, 0, 0, 0, 1073741824), offset);
1146
+ }
1147
+ function initSegment(config) {
1148
+ const mvhd = new Uint8Array(96);
1149
+ writeU32(mvhd, 8, 1000);
1150
+ writeU32(mvhd, 16, 65536);
1151
+ new DataView(mvhd.buffer).setUint16(20, 256);
1152
+ matrix(mvhd, 32);
1153
+ writeU32(mvhd, 92, 2);
1154
+ const tkhd = new Uint8Array(80);
1155
+ writeU32(tkhd, 8, 1);
1156
+ new DataView(tkhd.buffer).setUint16(32, 256);
1157
+ matrix(tkhd, 36);
1158
+ const mdhd = concat([u32(0, 0, config.sampleRate, 0), Uint8Array.of(85, 196, 0, 0)]);
1159
+ const hdlr = concat([u32(0), encoder.encode("soun"), u32(0, 0, 0), encoder.encode("SoundHandler\x00")]);
1160
+ const stbl = buildBox("stbl", concat([
1161
+ fullBox("stsd", 0, 0, concat([u32(1), sampleEntry(config)])),
1162
+ ...["stts", "stsc", "stco"].map((type) => fullBox(type, 0, 0, u32(0))),
1163
+ fullBox("stsz", 0, 0, u32(0, 0))
1164
+ ]));
1165
+ const minf = buildBox("minf", concat([
1166
+ fullBox("smhd", 0, 0, u32(0)),
1167
+ buildBox("dinf", fullBox("dref", 0, 0, concat([u32(1), fullBox("url ", 0, 1, new Uint8Array)]))),
1168
+ stbl
1169
+ ]));
1170
+ const trak = buildBox("trak", concat([
1171
+ fullBox("tkhd", 0, 7, tkhd),
1172
+ buildBox("mdia", concat([fullBox("mdhd", 0, 0, mdhd), fullBox("hdlr", 0, 0, hdlr), minf]))
1173
+ ]));
1174
+ return concat([
1175
+ buildBox("ftyp", concat([encoder.encode("isom"), u32(512), encoder.encode("isomiso6mp41")])),
1176
+ buildBox("moov", concat([
1177
+ fullBox("mvhd", 0, 0, mvhd),
1178
+ trak,
1179
+ buildBox("mvex", fullBox("trex", 0, 0, u32(1, 1, 1024, 0, 33554432)))
1180
+ ]))
1181
+ ]);
1182
+ }
1183
+ function descriptor(tag, payload) {
1184
+ return concat([Uint8Array.of(tag, payload.length), payload]);
1185
+ }
1186
+ function sampleEntry(config) {
1187
+ const entry = new Uint8Array(28);
1188
+ const view = new DataView(entry.buffer);
1189
+ view.setUint16(6, 1);
1190
+ view.setUint16(16, config.channels === 7 ? 8 : config.channels);
1191
+ view.setUint16(18, 16);
1192
+ view.setUint32(24, config.sampleRate * 65536);
1193
+ const asc = Uint8Array.of(2 << 3 | config.frequencyIndex >> 1, (config.frequencyIndex & 1) << 7 | config.channels << 3);
1194
+ const decoder = descriptor(4, concat([Uint8Array.of(64, 21, 0, 0, 0), u32(0, 0), descriptor(5, asc)]));
1195
+ const esds = descriptor(3, concat([Uint8Array.of(0, 1, 0), decoder, descriptor(6, Uint8Array.of(2))]));
1196
+ return buildBox("mp4a", concat([entry, fullBox("esds", 0, 0, esds)]));
1197
+ }
1198
+ function mediaFragment(fragment, sequence) {
1199
+ const timestamp = new Uint8Array(8);
1200
+ new DataView(timestamp.buffer).setBigUint64(0, BigInt(fragment.timestamp));
1201
+ const trun = fullBox("trun", 0, 513, concat([u32(fragment.samples.length, 0), u32(...fragment.samples.map((sample) => sample.length))]));
1202
+ const moof = buildBox("moof", concat([
1203
+ fullBox("mfhd", 0, 0, u32(sequence)),
1204
+ buildBox("traf", concat([fullBox("tfhd", 0, 131072, u32(1)), fullBox("tfdt", 1, 0, timestamp), trun]))
1205
+ ]));
1206
+ writeU32(moof, moof.length - trun.length + 16, moof.length + 8);
1207
+ return [moof, buildBox("mdat", concat(fragment.samples))];
1208
+ }
1209
+
1041
1210
  // src/hls.ts
1042
1211
  function isMasterPlaylist(text) {
1043
1212
  return text.includes("#EXT-X-STREAM-INF");
@@ -1046,25 +1215,31 @@ function parseMaster(text, baseUrl) {
1046
1215
  const lines = text.split(`
1047
1216
  `).map((line) => line.trim());
1048
1217
  const audio = {};
1218
+ const defaultAudio = new Set;
1049
1219
  const variants = [];
1050
1220
  for (let index = 0;index < lines.length; index += 1) {
1051
1221
  const line = lines[index];
1052
1222
  if (line.startsWith("#EXT-X-MEDIA:") && /TYPE=AUDIO/.test(line)) {
1053
1223
  const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
1054
1224
  const uri = line.match(/URI="([^"]+)"/)?.[1];
1055
- if (group && uri) {
1225
+ const isDefault = /(?:^|,)DEFAULT=YES(?:,|$)/.test(line);
1226
+ if (group && uri && (!audio[group] || isDefault && !defaultAudio.has(group))) {
1056
1227
  audio[group] = new URL(uri, baseUrl).href;
1228
+ if (isDefault)
1229
+ defaultAudio.add(group);
1057
1230
  }
1058
1231
  } else if (line.startsWith("#EXT-X-STREAM-INF:")) {
1059
1232
  const target = lines[index + 1];
1060
1233
  if (target && !target.startsWith("#")) {
1061
1234
  const resolution = line.match(/RESOLUTION=(\d+)x(\d+)/);
1235
+ const codecs = line.match(/CODECS="([^"]+)"/)?.[1];
1062
1236
  variants.push({
1063
1237
  width: resolution ? Number(resolution[1]) : 0,
1064
1238
  height: resolution ? Number(resolution[2]) : 0,
1065
1239
  bandwidth: Number(line.match(/BANDWIDTH=(\d+)/)?.[1] ?? 0),
1066
1240
  url: new URL(target, baseUrl).href,
1067
- audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null
1241
+ audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null,
1242
+ ...codecs ? { codecs } : {}
1068
1243
  });
1069
1244
  }
1070
1245
  }
@@ -1080,7 +1255,8 @@ async function assembleHls(net, playlistUrl, headers) {
1080
1255
  if (segments.length === 0) {
1081
1256
  throw new Error("HLS playlist has no segments");
1082
1257
  }
1083
- return concat(await fetchSegments(net, segments, headers));
1258
+ const bytes = await fetchSegments(net, segments, headers);
1259
+ return packedAacToMp4(bytes) ?? concat(bytes);
1084
1260
  }
1085
1261
  function mediaSegments(text, baseUrl) {
1086
1262
  const segments = [];
@@ -2229,16 +2405,45 @@ var androidVrClient = {
2229
2405
  userAgent: "com.google.android.apps.youtube.vr.oculus/1.65.10 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip",
2230
2406
  version: "1.65.10"
2231
2407
  };
2408
+ var visionOsClient = {
2409
+ clientName: "VISIONOS",
2410
+ clientVersion: "1.02",
2411
+ deviceMake: "Apple",
2412
+ deviceModel: "RealityDevice17,1",
2413
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
2414
+ osName: "visionOS",
2415
+ osVersion: "26.5.23O471"
2416
+ };
2232
2417
  var browserCookie = "PREF=hl=en&tz=UTC; SOCS=CAI";
2233
2418
  async function resolveYoutube(input) {
2234
2419
  const id = youtubeVideoId(input.url);
2235
2420
  if (!id) {
2236
2421
  throw new Error("YouTube video id not found");
2237
2422
  }
2238
- const session = await youtubeSession(input.net, id);
2239
- const response = await input.net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
2240
- body: JSON.stringify(playerBody(id, session)),
2241
- headers: playerHeaders(session),
2423
+ let session;
2424
+ try {
2425
+ session = await youtubeSession(input.net, id);
2426
+ const payload = await youtubePlayer(input.net, playerBody(id, session), playerHeaders(session));
2427
+ const streams = selectStreams(payload, input.preferredWidth);
2428
+ if (!streams) {
2429
+ throw new Error("YouTube mp4 stream not found");
2430
+ }
2431
+ const headers = { "user-agent": androidVrClient.userAgent };
2432
+ await Promise.all([streams.video, streams.audio].filter((url) => url !== null).map((url) => probeStream(input.net, url, headers)));
2433
+ return youtubeResult(id, payload, streams, headers);
2434
+ } catch (primaryError) {
2435
+ try {
2436
+ return await resolveVisionOs(input, id, session?.visitorData);
2437
+ } catch (fallbackError) {
2438
+ const message = (error) => error instanceof Error ? error.message : String(error);
2439
+ throw new Error(`YouTube resolution failed: ${message(primaryError)}; visionOS HLS: ${message(fallbackError)}`, { cause: fallbackError });
2440
+ }
2441
+ }
2442
+ }
2443
+ async function youtubePlayer(net, body, headers) {
2444
+ const response = await net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
2445
+ body: JSON.stringify(body),
2446
+ headers,
2242
2447
  method: "POST"
2243
2448
  });
2244
2449
  if (!response.ok) {
@@ -2250,12 +2455,10 @@ async function resolveYoutube(input) {
2250
2455
  const reason = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.reason) : null;
2251
2456
  throw new Error(reason ?? "YouTube video unavailable");
2252
2457
  }
2253
- const streams = selectStreams(payload, input.preferredWidth);
2254
- if (!streams) {
2255
- throw new Error("YouTube mp4 stream not found");
2256
- }
2458
+ return payload;
2459
+ }
2460
+ function youtubeResult(id, payload, streams, headers) {
2257
2461
  const title = object(payload) && object(payload.videoDetails) ? string(payload.videoDetails.title) : null;
2258
- const headers = { "user-agent": androidVrClient.userAgent };
2259
2462
  const media = {
2260
2463
  filename: filename(`youtube_${title ?? id}_${id}.mp4`),
2261
2464
  headers,
@@ -2264,10 +2467,56 @@ async function resolveYoutube(input) {
2264
2467
  mime: "video/mp4",
2265
2468
  platform: "youtube",
2266
2469
  url: streams.video,
2470
+ ...streams.hls ? { hls: true } : {},
2267
2471
  ...streams.audio ? { audio: { headers, url: streams.audio } } : {}
2268
2472
  };
2269
2473
  return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
2270
2474
  }
2475
+ async function probeStream(net, url, headers) {
2476
+ const response = await net(url, { headers }, 1);
2477
+ await response.body?.cancel();
2478
+ if (!response.ok) {
2479
+ throw new Error(`YouTube stream failed: ${response.status}`);
2480
+ }
2481
+ }
2482
+ async function resolveVisionOs(input, id, visitorData) {
2483
+ const headers = { "user-agent": visionOsClient.userAgent };
2484
+ const payload = await youtubePlayer(input.net, {
2485
+ videoId: id,
2486
+ contentCheckOk: true,
2487
+ racyCheckOk: true,
2488
+ context: { client: { ...visionOsClient, hl: "en", gl: "US", ...visitorData ? { visitorData } : {} } }
2489
+ }, {
2490
+ ...headers,
2491
+ "content-type": "application/json",
2492
+ "x-youtube-client-name": "101",
2493
+ "x-youtube-client-version": visionOsClient.clientVersion,
2494
+ ...visitorData ? { "x-goog-visitor-id": visitorData } : {}
2495
+ });
2496
+ const streaming = object(payload) && object(payload.streamingData) ? payload.streamingData : null;
2497
+ const manifestUrl = streaming ? string(streaming.hlsManifestUrl) : null;
2498
+ if (!manifestUrl) {
2499
+ throw new Error("YouTube HLS manifest not found");
2500
+ }
2501
+ const response = await input.net(manifestUrl, { headers });
2502
+ if (!response.ok) {
2503
+ throw new Error(`YouTube HLS manifest failed: ${response.status}`);
2504
+ }
2505
+ const master = parseMaster(await response.text(), manifestUrl);
2506
+ const variants = master.variants.filter((variant2) => /(?:^|,)(?:vp09|av01)\./i.test(variant2.codecs ?? "") && /(?:^|,)mp4a\.40\.2(?:,|$)/i.test(variant2.codecs ?? "") && variant2.audioGroup && master.audio[variant2.audioGroup]).sort((left, right) => Math.abs(left.width - input.preferredWidth) - Math.abs(right.width - input.preferredWidth) || right.bandwidth - left.bandwidth);
2507
+ const variant = variants[0];
2508
+ if (!variant?.audioGroup) {
2509
+ throw new Error("YouTube fragmented MP4 HLS video with AAC audio not found");
2510
+ }
2511
+ const playlist = await input.net(variant.url, { headers });
2512
+ if (!playlist.ok) {
2513
+ throw new Error(`YouTube HLS playlist failed: ${playlist.status}`);
2514
+ }
2515
+ if (!(await playlist.text()).includes("#EXT-X-MAP:")) {
2516
+ throw new Error("YouTube HLS video is not fragmented MP4");
2517
+ }
2518
+ return youtubeResult(id, payload, { video: variant.url, audio: master.audio[variant.audioGroup], hls: true }, headers);
2519
+ }
2271
2520
  function youtubeMetadata(payload) {
2272
2521
  const details = object(payload) && object(payload.videoDetails) ? payload.videoDetails : null;
2273
2522
  const renderer = object(payload) && object(payload.microformat) && object(payload.microformat.playerMicroformatRenderer) ? payload.microformat.playerMicroformatRenderer : null;
@@ -2840,7 +3089,7 @@ function rewriteMoof(data, moofBox, trackId2, sequence) {
2840
3089
  }
2841
3090
 
2842
3091
  // src/zip.ts
2843
- var encoder = new TextEncoder;
3092
+ var encoder2 = new TextEncoder;
2844
3093
  var table = new Uint32Array(256);
2845
3094
  for (let value = 0;value < table.length; value += 1) {
2846
3095
  let crc = value;
@@ -2851,7 +3100,7 @@ for (let value = 0;value < table.length; value += 1) {
2851
3100
  }
2852
3101
  function zip(entries) {
2853
3102
  const files = entries.map((entry) => {
2854
- const name = encoder.encode(entry.name);
3103
+ const name = encoder2.encode(entry.name);
2855
3104
  const crc = crc32(entry.data);
2856
3105
  return { ...entry, crc, name };
2857
3106
  });
@@ -3064,12 +3313,80 @@ async function fetchBytes(net, url, headers) {
3064
3313
  function toArrayBuffer(bytes) {
3065
3314
  return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
3066
3315
  }
3316
+
3317
+ // src/audio-slider.ts
3318
+ var fps = 30;
3319
+ async function buildAudioSliderVideo(items, options) {
3320
+ if (!Array.isArray(items) || items.length < 2 || items.at(-1)?.kind !== "audio" || Array.from(items.slice(0, -1)).some((item) => item?.kind !== "image" && item?.kind !== "video")) {
3321
+ throw new PostfetchError(400, "Expected one or more image/video items followed by exactly one audio item");
3322
+ }
3323
+ if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
3324
+ throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
3325
+ }
3326
+ const width = options.width ?? 720;
3327
+ const height2 = options.height ?? 1280;
3328
+ for (const [name, value] of [["width", width], ["height", height2]]) {
3329
+ if (!Number.isSafeInteger(value) || value <= 0 || value % 2 !== 0) {
3330
+ throw new PostfetchError(400, `${name} must be a positive even integer`);
3331
+ }
3332
+ }
3333
+ const visuals = items.slice(0, -1);
3334
+ const frames = Math.max(2, Math.round(options.delay * fps / 1000));
3335
+ if (!Number.isSafeInteger(frames * visuals.length)) {
3336
+ throw new PostfetchError(400, "delay produces an unsupported video duration");
3337
+ }
3338
+ const seconds = frames / fps;
3339
+ const transition = Math.min(0.3, seconds / 2);
3340
+ const duration = seconds * visuals.length;
3341
+ const io = await nodeRuntime();
3342
+ const directory = await io.makeTempDir("postfetch-slider-");
3343
+ try {
3344
+ const paths = [];
3345
+ for (const [index, item] of items.entries()) {
3346
+ const path = `${directory}/input-${index}`;
3347
+ const response = await download(item, options);
3348
+ await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
3349
+ paths.push(path);
3350
+ }
3351
+ const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
3352
+ for (const [index, item] of visuals.entries()) {
3353
+ args.push(...item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]);
3354
+ args.push("-i", paths[index]);
3355
+ }
3356
+ args.push("-stream_loop", "-1", "-i", paths[visuals.length]);
3357
+ const filters = visuals.map((_, index) => `[${index}:v:0]setpts=PTS-STARTPTS,` + `scale=${width}:${height2}:force_original_aspect_ratio=decrease:force_divisible_by=2,` + `pad=${width}:${height2}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},` + `format=yuv444p,trim=duration=${seconds + (index > 0 ? transition : 0)},setpts=PTS-STARTPTS,fps=${fps},settb=AVTB[v${index}]`);
3358
+ let current = "v0";
3359
+ for (let index = 1;index < visuals.length; index += 1) {
3360
+ const next = `slide${index}`;
3361
+ filters.push(`[${current}][v${index}]xfade=transition=slideleft:duration=${transition}:offset=${index * seconds - transition},fps=${fps},settb=AVTB[${next}]`);
3362
+ current = next;
3363
+ }
3364
+ filters.push(`[${current}]format=yuv420p[outv]`);
3365
+ filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,atrim=duration=${duration}[outa]`);
3366
+ const output = `${directory}/slideshow.mp4`;
3367
+ args.push("-filter_complex", filters.join(";"), "-map", "[outv]", "-map", "[outa]", "-t", String(duration), "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output);
3368
+ const result = await io.run(options.ffmpegPath ?? "ffmpeg", args);
3369
+ if (!result.success) {
3370
+ throw new PostfetchError(500, `Audio slider video encoding failed: ${result.stderr}`);
3371
+ }
3372
+ const bytes = await io.readFile(output);
3373
+ if (bytes.length === 0) {
3374
+ throw new PostfetchError(500, "Audio slider video encoding produced an empty file");
3375
+ }
3376
+ return new Blob([new Uint8Array(bytes).buffer], { type: "video/mp4" });
3377
+ } finally {
3378
+ await io.removeDir(directory).catch(() => {
3379
+ return;
3380
+ });
3381
+ }
3382
+ }
3067
3383
  export {
3068
3384
  toResponse,
3069
3385
  postfetch,
3070
3386
  downloadBlob,
3071
3387
  download,
3072
3388
  detect,
3389
+ buildAudioSliderVideo,
3073
3390
  archive,
3074
3391
  PostfetchError
3075
3392
  };
@@ -20,8 +20,8 @@ export type MediaItem = {
20
20
  headers: HeadersInit;
21
21
  /**
22
22
  * When `true`, {@link MediaItem.url} (and `audio.url`, if set) are HLS media
23
- * playlists rather than direct files: {@link download} assembles their segments
24
- * into a fragmented MP4 before serving — and merges video with audio when both
23
+ * playlists rather than direct files: {@link download} assembles their fMP4
24
+ * segments (or packages packed AAC audio) before serving, and merges tracks when both
25
25
  * are present. Callers that read `url` directly get the playlist, not the media.
26
26
  */
27
27
  hls?: boolean;
@@ -23,4 +23,5 @@ export type Mp4RemuxRuntime = {
23
23
  * FFmpeg only rebuilds the container and timing tables.
24
24
  */
25
25
  export declare function prepareMp4(bytes: Uint8Array, ffmpegPath?: string, ffprobePath?: string, runtime?: Mp4RemuxRuntime): Promise<PreparedMp4 | null>;
26
+ export declare function nodeRuntime(): Promise<Mp4RemuxRuntime>;
26
27
  export {};
@@ -0,0 +1 @@
1
+ export declare function packedAacToMp4(segments: Uint8Array[]): Uint8Array | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postfetch/core",
3
- "version": "0.9.2",
3
+ "version": "0.10.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",
@@ -0,0 +1,118 @@
1
+ import { download, type DownloadOptions } from "./download";
2
+ import { PostfetchError, type MediaItem } from "./internal";
3
+ import { nodeRuntime } from "./mp4-remux";
4
+
5
+ /** Options for {@link buildAudioSliderVideo}. Requires a local FFmpeg binary. */
6
+ export type AudioSliderVideoOptions = DownloadOptions & {
7
+ /** Time per visual item in milliseconds, including its outgoing transition. */
8
+ delay: number;
9
+ /** Output width in pixels; a positive even integer. Defaults to 720. */
10
+ width?: number;
11
+ /** Output height in pixels; a positive even integer. Defaults to 1280. */
12
+ height?: number;
13
+ /** FFmpeg command or executable path. Defaults to `ffmpeg` from PATH. */
14
+ ffmpegPath?: string;
15
+ };
16
+
17
+ const fps = 30;
18
+
19
+ /**
20
+ * Build an H.264/AAC MP4 Blob from one or more images/videos followed by exactly
21
+ * one audio item. Downloads preserve each item's headers and assemble HLS.
22
+ * Visuals retain their aspect ratio, padded with black to the output size.
23
+ * Video clips play from the beginning, looping or trimming to fit; their own
24
+ * audio is discarded. The final audio item loops and is trimmed to the result.
25
+ *
26
+ * Each visual gets `delay` ms (rounded to 30 fps, minimum two frames). Adjacent
27
+ * visuals slide right to left over the last 300 ms or half the delay, whichever
28
+ * is shorter. The last visual stays on screen; a single visual has no transition.
29
+ * Total duration is the number of visuals times the rounded delay.
30
+ *
31
+ * Requires Node, Bun or Deno with filesystem/process access and FFmpeg with
32
+ * libx264. Temporary files are removed on success and failure.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * const result = await postfetch(photoUrl);
37
+ * const blob = await buildAudioSliderVideo(result.items, { delay: 3000 });
38
+ * form.append("video", blob, "slideshow.mp4");
39
+ * ```
40
+ */
41
+ export async function buildAudioSliderVideo(
42
+ items: readonly MediaItem[],
43
+ options: AudioSliderVideoOptions,
44
+ ): Promise<Blob> {
45
+ if (
46
+ !Array.isArray(items) || items.length < 2 || items.at(-1)?.kind !== "audio" ||
47
+ Array.from(items.slice(0, -1)).some((item) => item?.kind !== "image" && item?.kind !== "video")
48
+ ) {
49
+ throw new PostfetchError(400, "Expected one or more image/video items followed by exactly one audio item");
50
+ }
51
+ if (!options || !Number.isFinite(options.delay) || options.delay <= 0) {
52
+ throw new PostfetchError(400, "delay must be a positive finite number of milliseconds");
53
+ }
54
+ const width = options.width ?? 720;
55
+ const height = options.height ?? 1280;
56
+ for (const [name, value] of [["width", width], ["height", height]] as const) {
57
+ if (!Number.isSafeInteger(value) || value <= 0 || value % 2 !== 0) {
58
+ throw new PostfetchError(400, `${name} must be a positive even integer`);
59
+ }
60
+ }
61
+ const visuals = items.slice(0, -1);
62
+ const frames = Math.max(2, Math.round(options.delay * fps / 1000));
63
+ if (!Number.isSafeInteger(frames * visuals.length)) {
64
+ throw new PostfetchError(400, "delay produces an unsupported video duration");
65
+ }
66
+ const seconds = frames / fps;
67
+ const transition = Math.min(0.3, seconds / 2);
68
+ const duration = seconds * visuals.length;
69
+ const io = await nodeRuntime();
70
+ const directory = await io.makeTempDir("postfetch-slider-");
71
+ try {
72
+ // Numbered local paths keep remote filenames out of FFmpeg arguments.
73
+ const paths: string[] = [];
74
+ for (const [index, item] of items.entries()) {
75
+ const path = `${directory}/input-${index}`;
76
+ const response = await download(item, options);
77
+ await io.writeFile(path, new Uint8Array(await response.arrayBuffer()));
78
+ paths.push(path);
79
+ }
80
+ const args = ["-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-filter_complex_threads", "1"];
81
+ for (const [index, item] of visuals.entries()) {
82
+ args.push(...(item.kind === "image" ? ["-loop", "1", "-framerate", String(fps)] : ["-stream_loop", "-1"]));
83
+ args.push("-i", paths[index]);
84
+ }
85
+ args.push("-stream_loop", "-1", "-i", paths[visuals.length]);
86
+ const filters = visuals.map((_, index) =>
87
+ `[${index}:v:0]setpts=PTS-STARTPTS,` +
88
+ `scale=${width}:${height}:force_original_aspect_ratio=decrease:force_divisible_by=2,` +
89
+ `pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${fps},` +
90
+ `format=yuv444p,trim=duration=${seconds + (index > 0 ? transition : 0)},setpts=PTS-STARTPTS,fps=${fps},settb=AVTB[v${index}]`,
91
+ );
92
+ let current = "v0";
93
+ for (let index = 1; index < visuals.length; index += 1) {
94
+ const next = `slide${index}`;
95
+ filters.push(`[${current}][v${index}]xfade=transition=slideleft:duration=${transition}:offset=${index * seconds - transition},fps=${fps},settb=AVTB[${next}]`);
96
+ current = next;
97
+ }
98
+ filters.push(`[${current}]format=yuv420p[outv]`);
99
+ filters.push(`[${visuals.length}:a:0]asetpts=PTS-STARTPTS,atrim=duration=${duration}[outa]`);
100
+ const output = `${directory}/slideshow.mp4`;
101
+ args.push(
102
+ "-filter_complex", filters.join(";"), "-map", "[outv]", "-map", "[outa]",
103
+ "-t", String(duration), "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
104
+ "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", output,
105
+ );
106
+ const result = await io.run(options.ffmpegPath ?? "ffmpeg", args);
107
+ if (!result.success) {
108
+ throw new PostfetchError(500, `Audio slider video encoding failed: ${result.stderr}`);
109
+ }
110
+ const bytes = await io.readFile(output);
111
+ if (bytes.length === 0) {
112
+ throw new PostfetchError(500, "Audio slider video encoding produced an empty file");
113
+ }
114
+ return new Blob([new Uint8Array(bytes).buffer], { type: "video/mp4" });
115
+ } finally {
116
+ await io.removeDir(directory).catch(() => undefined);
117
+ }
118
+ }
package/src/hls.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import { concat } from "./isobmff";
2
2
  import type { Net } from "./internal";
3
+ import { packedAacToMp4 } from "./packed-aac";
3
4
 
4
5
  // HLS support for the fMP4/CMAF playlists reddit-style muxing already covers:
5
6
  // a media playlist is an EXT-X-MAP init plus media segments (often byte ranges of
6
- // one CMAF file), so assembling it is just fetching those byte ranges in order and
7
- // concatenating them into a fragmented MP4. No MPEG-TS demuxing is involved.
7
+ // one CMAF file). Packed AAC audio is also packaged into fMP4 for merging with
8
+ // YouTube's HLS video. No MPEG-TS demuxing is involved.
8
9
 
9
10
  type Segment = { url: string; range: { length: number; offset: number } | null };
10
11
 
11
- export type HlsVariant = { width: number; height: number; bandwidth: number; url: string; audioGroup: string | null };
12
+ export type HlsVariant = { width: number; height: number; bandwidth: number; url: string; audioGroup: string | null; codecs?: string };
12
13
  export type HlsMaster = { variants: HlsVariant[]; audio: Record<string, string> };
13
14
 
14
15
  export function isMasterPlaylist(text: string): boolean {
@@ -18,25 +19,30 @@ export function isMasterPlaylist(text: string): boolean {
18
19
  export function parseMaster(text: string, baseUrl: string): HlsMaster {
19
20
  const lines = text.split("\n").map((line) => line.trim());
20
21
  const audio: Record<string, string> = {};
22
+ const defaultAudio = new Set<string>();
21
23
  const variants: HlsVariant[] = [];
22
24
  for (let index = 0; index < lines.length; index += 1) {
23
25
  const line = lines[index];
24
26
  if (line.startsWith("#EXT-X-MEDIA:") && /TYPE=AUDIO/.test(line)) {
25
27
  const group = line.match(/GROUP-ID="([^"]+)"/)?.[1];
26
28
  const uri = line.match(/URI="([^"]+)"/)?.[1];
27
- if (group && uri) {
29
+ const isDefault = /(?:^|,)DEFAULT=YES(?:,|$)/.test(line);
30
+ if (group && uri && (!audio[group] || (isDefault && !defaultAudio.has(group)))) {
28
31
  audio[group] = new URL(uri, baseUrl).href;
32
+ if (isDefault) defaultAudio.add(group);
29
33
  }
30
34
  } else if (line.startsWith("#EXT-X-STREAM-INF:")) {
31
35
  const target = lines[index + 1];
32
36
  if (target && !target.startsWith("#")) {
33
37
  const resolution = line.match(/RESOLUTION=(\d+)x(\d+)/);
38
+ const codecs = line.match(/CODECS="([^"]+)"/)?.[1];
34
39
  variants.push({
35
40
  width: resolution ? Number(resolution[1]) : 0,
36
41
  height: resolution ? Number(resolution[2]) : 0,
37
42
  bandwidth: Number(line.match(/BANDWIDTH=(\d+)/)?.[1] ?? 0),
38
43
  url: new URL(target, baseUrl).href,
39
44
  audioGroup: line.match(/AUDIO="([^"]+)"/)?.[1] ?? null,
45
+ ...(codecs ? { codecs } : {}),
40
46
  });
41
47
  }
42
48
  }
@@ -53,7 +59,8 @@ export async function assembleHls(net: Net, playlistUrl: string, headers: Header
53
59
  if (segments.length === 0) {
54
60
  throw new Error("HLS playlist has no segments");
55
61
  }
56
- return concat(await fetchSegments(net, segments, headers));
62
+ const bytes = await fetchSegments(net, segments, headers);
63
+ return packedAacToMp4(bytes) ?? concat(bytes);
57
64
  }
58
65
 
59
66
  function mediaSegments(text: string, baseUrl: string): Segment[] {
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  * @module
21
21
  */
22
22
  export { postfetch, detect, type PostfetchOptions } from "./postfetch";
23
+ export { buildAudioSliderVideo, type AudioSliderVideoOptions } from "./audio-slider";
23
24
  export {
24
25
  download,
25
26
  downloadBlob,
package/src/internal.ts CHANGED
@@ -19,8 +19,8 @@ export type MediaItem = {
19
19
  headers: HeadersInit;
20
20
  /**
21
21
  * When `true`, {@link MediaItem.url} (and `audio.url`, if set) are HLS media
22
- * playlists rather than direct files: {@link download} assembles their segments
23
- * into a fragmented MP4 before serving — and merges video with audio when both
22
+ * playlists rather than direct files: {@link download} assembles their fMP4
23
+ * segments (or packages packed AAC audio) before serving, and merges tracks when both
24
24
  * are present. Callers that read `url` directly get the playlist, not the media.
25
25
  */
26
26
  hls?: boolean;
package/src/mp4-remux.ts CHANGED
@@ -161,7 +161,7 @@ function parseVideoMetadata(value: string): Pick<PreparedMp4, "duration" | "heig
161
161
  // Deno, Bun and Node all expose the Node compatibility modules used here. They
162
162
  // stay behind dynamic imports so browser/edge consumers that leave remux off do
163
163
  // not load or execute any process/filesystem code.
164
- async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
164
+ export async function nodeRuntime(): Promise<Mp4RemuxRuntime> {
165
165
  const [{ spawn }, { mkdtemp, readFile, rm, writeFile }, { tmpdir }, { join }] = await Promise.all([
166
166
  import("node:child_process"),
167
167
  import("node:fs/promises"),
@@ -0,0 +1,187 @@
1
+ import { buildBox, concat, readU32, writeU32 } from "./isobmff";
2
+
3
+ // YouTube's fMP4 HLS video is paired with packed AAC: each audio segment is
4
+ // ID3 (including a 90 kHz timestamp) followed by ADTS frames. Strip the transport
5
+ // headers and put the original AAC samples into CMAF fragments for our merger.
6
+ type Config = { frequencyIndex: number; sampleRate: number; channels: number };
7
+ type Fragment = { samples: Uint8Array[]; timestamp: number };
8
+ const sampleRates = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
9
+ const encoder = new TextEncoder();
10
+
11
+ export function packedAacToMp4(segments: Uint8Array[]): Uint8Array | null {
12
+ const first = segments[0];
13
+ if (!first || (!id3(first, 0) && !adts(first, 0))) {
14
+ return null;
15
+ }
16
+ let config: Config | undefined;
17
+ let nextTimestamp = 0;
18
+ const fragments: Fragment[] = [];
19
+ for (const segment of segments) {
20
+ let offset = 0;
21
+ let timestamp: number | undefined;
22
+ while (id3(segment, offset)) {
23
+ const tag = readId3(segment, offset);
24
+ timestamp ??= tag.timestamp;
25
+ offset = tag.end;
26
+ }
27
+ const samples: Uint8Array[] = [];
28
+ while (offset < segment.length) {
29
+ if (offset + 7 > segment.length || !adts(segment, offset)) {
30
+ throw new Error("Invalid packed AAC frame");
31
+ }
32
+ const profile = (segment[offset + 2] >> 6) + 1;
33
+ const frequencyIndex = (segment[offset + 2] >> 2) & 15;
34
+ const sampleRate = sampleRates[frequencyIndex];
35
+ const channels = ((segment[offset + 2] & 1) << 2) | (segment[offset + 3] >> 6);
36
+ if (profile !== 2 || !sampleRate || sampleRate > 65535 || channels === 0 || (segment[offset + 6] & 3) !== 0) {
37
+ throw new Error("Unsupported packed AAC configuration (expected AAC-LC)");
38
+ }
39
+ if (config && (config.frequencyIndex !== frequencyIndex || config.channels !== channels)) {
40
+ throw new Error("Packed AAC configuration changed between frames");
41
+ }
42
+ config ??= { frequencyIndex, sampleRate, channels };
43
+ const length = ((segment[offset + 3] & 3) << 11) | (segment[offset + 4] << 3) | (segment[offset + 5] >> 5);
44
+ const headerLength = segment[offset + 1] & 1 ? 7 : 9;
45
+ if (length <= headerLength || offset + length > segment.length) {
46
+ throw new Error("Truncated packed AAC frame");
47
+ }
48
+ samples.push(segment.subarray(offset + headerLength, offset + length));
49
+ offset += length;
50
+ }
51
+ if (!config || samples.length === 0) {
52
+ throw new Error("Packed AAC segment has no audio frames");
53
+ }
54
+ const start = timestamp === undefined ? nextTimestamp : Math.round(timestamp * config.sampleRate / 90000);
55
+ fragments.push({ samples, timestamp: start });
56
+ nextTimestamp = start + samples.length * 1024;
57
+ }
58
+ return concat([initSegment(config!), ...fragments.flatMap((fragment, index) => mediaFragment(fragment, index + 1))]);
59
+ }
60
+
61
+ function id3(bytes: Uint8Array, offset: number): boolean {
62
+ return bytes[offset] === 0x49 && bytes[offset + 1] === 0x44 && bytes[offset + 2] === 0x33;
63
+ }
64
+
65
+ function adts(bytes: Uint8Array, offset: number): boolean {
66
+ return bytes[offset] === 0xff && (bytes[offset + 1] & 0xf6) === 0xf0;
67
+ }
68
+
69
+ function synchsafe(bytes: Uint8Array, offset: number): number {
70
+ if (offset + 4 > bytes.length || bytes.subarray(offset, offset + 4).some((byte) => byte & 128)) {
71
+ throw new Error("Invalid packed AAC ID3 size");
72
+ }
73
+ return bytes[offset] * 2 ** 21 + bytes[offset + 1] * 2 ** 14 + bytes[offset + 2] * 128 + bytes[offset + 3];
74
+ }
75
+
76
+ function readId3(bytes: Uint8Array, offset: number): { end: number; timestamp?: number } {
77
+ const version = bytes[offset + 3];
78
+ if (offset + 10 > bytes.length || (version !== 3 && version !== 4) || (bytes[offset + 5] & 0xc0)) {
79
+ throw new Error("Unsupported packed AAC ID3 tag");
80
+ }
81
+ const end = offset + 10 + synchsafe(bytes, offset + 6);
82
+ if (end > bytes.length) {
83
+ throw new Error("Truncated packed AAC ID3 tag");
84
+ }
85
+ const owner = encoder.encode("com.apple.streaming.transportStreamTimestamp\0");
86
+ let timestamp: number | undefined;
87
+ for (let cursor = offset + 10; cursor + 10 <= end && bytes[cursor] !== 0;) {
88
+ const size = version === 4 ? synchsafe(bytes, cursor + 4) : readU32(bytes, cursor + 4);
89
+ const body = cursor + 10;
90
+ if (body + size > end) {
91
+ throw new Error("Truncated packed AAC ID3 frame");
92
+ }
93
+ if (String.fromCharCode(...bytes.subarray(cursor, cursor + 4)) === "PRIV" &&
94
+ size === owner.length + 8 && owner.every((byte, index) => bytes[body + index] === byte)) {
95
+ if (bytes[cursor + 8] !== 0 || bytes[cursor + 9] !== 0) {
96
+ throw new Error("Unsupported packed AAC timestamp encoding");
97
+ }
98
+ timestamp = Number(new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(body + owner.length) & 0x1ffffffffn);
99
+ }
100
+ cursor = body + size;
101
+ }
102
+ const footer = version === 4 && (bytes[offset + 5] & 0x10) ? 10 : 0;
103
+ if (end + footer > bytes.length) {
104
+ throw new Error("Truncated packed AAC ID3 footer");
105
+ }
106
+ return { end: end + footer, timestamp };
107
+ }
108
+
109
+ function u32(...values: number[]): Uint8Array {
110
+ const bytes = new Uint8Array(values.length * 4);
111
+ values.forEach((value, index) => writeU32(bytes, index * 4, value));
112
+ return bytes;
113
+ }
114
+
115
+ function fullBox(type: string, version: number, flags: number, payload: Uint8Array): Uint8Array {
116
+ return buildBox(type, concat([u32(version * 0x1000000 + flags), payload]));
117
+ }
118
+
119
+ function matrix(bytes: Uint8Array, offset: number): void {
120
+ bytes.set(u32(0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000), offset);
121
+ }
122
+
123
+ function initSegment(config: Config): Uint8Array {
124
+ const mvhd = new Uint8Array(96);
125
+ writeU32(mvhd, 8, 1000);
126
+ writeU32(mvhd, 16, 0x10000);
127
+ new DataView(mvhd.buffer).setUint16(20, 0x100);
128
+ matrix(mvhd, 32);
129
+ writeU32(mvhd, 92, 2);
130
+ const tkhd = new Uint8Array(80);
131
+ writeU32(tkhd, 8, 1);
132
+ new DataView(tkhd.buffer).setUint16(32, 0x100);
133
+ matrix(tkhd, 36);
134
+ const mdhd = concat([u32(0, 0, config.sampleRate, 0), Uint8Array.of(0x55, 0xc4, 0, 0)]);
135
+ const hdlr = concat([u32(0), encoder.encode("soun"), u32(0, 0, 0), encoder.encode("SoundHandler\0")]);
136
+ const stbl = buildBox("stbl", concat([
137
+ fullBox("stsd", 0, 0, concat([u32(1), sampleEntry(config)])),
138
+ ...["stts", "stsc", "stco"].map((type) => fullBox(type, 0, 0, u32(0))),
139
+ fullBox("stsz", 0, 0, u32(0, 0)),
140
+ ]));
141
+ const minf = buildBox("minf", concat([
142
+ fullBox("smhd", 0, 0, u32(0)),
143
+ buildBox("dinf", fullBox("dref", 0, 0, concat([u32(1), fullBox("url ", 0, 1, new Uint8Array())]))),
144
+ stbl,
145
+ ]));
146
+ const trak = buildBox("trak", concat([
147
+ fullBox("tkhd", 0, 7, tkhd),
148
+ buildBox("mdia", concat([fullBox("mdhd", 0, 0, mdhd), fullBox("hdlr", 0, 0, hdlr), minf])),
149
+ ]));
150
+ return concat([
151
+ buildBox("ftyp", concat([encoder.encode("isom"), u32(512), encoder.encode("isomiso6mp41")])),
152
+ buildBox("moov", concat([
153
+ fullBox("mvhd", 0, 0, mvhd), trak,
154
+ buildBox("mvex", fullBox("trex", 0, 0, u32(1, 1, 1024, 0, 0x02000000))),
155
+ ])),
156
+ ]);
157
+ }
158
+
159
+ function descriptor(tag: number, payload: Uint8Array): Uint8Array {
160
+ // The fixed AAC-LC descriptors below are all shorter than 128 bytes.
161
+ return concat([Uint8Array.of(tag, payload.length), payload]);
162
+ }
163
+
164
+ function sampleEntry(config: Config): Uint8Array {
165
+ const entry = new Uint8Array(28);
166
+ const view = new DataView(entry.buffer);
167
+ view.setUint16(6, 1);
168
+ view.setUint16(16, config.channels === 7 ? 8 : config.channels);
169
+ view.setUint16(18, 16);
170
+ view.setUint32(24, config.sampleRate * 65536);
171
+ const asc = Uint8Array.of((2 << 3) | (config.frequencyIndex >> 1), ((config.frequencyIndex & 1) << 7) | (config.channels << 3));
172
+ const decoder = descriptor(4, concat([Uint8Array.of(0x40, 0x15, 0, 0, 0), u32(0, 0), descriptor(5, asc)]));
173
+ const esds = descriptor(3, concat([Uint8Array.of(0, 1, 0), decoder, descriptor(6, Uint8Array.of(2))]));
174
+ return buildBox("mp4a", concat([entry, fullBox("esds", 0, 0, esds)]));
175
+ }
176
+
177
+ function mediaFragment(fragment: Fragment, sequence: number): Uint8Array[] {
178
+ const timestamp = new Uint8Array(8);
179
+ new DataView(timestamp.buffer).setBigUint64(0, BigInt(fragment.timestamp));
180
+ const trun = fullBox("trun", 0, 0x201, concat([u32(fragment.samples.length, 0), u32(...fragment.samples.map((sample) => sample.length))]));
181
+ const moof = buildBox("moof", concat([
182
+ fullBox("mfhd", 0, 0, u32(sequence)),
183
+ buildBox("traf", concat([fullBox("tfhd", 0, 0x020000, u32(1)), fullBox("tfdt", 1, 0, timestamp), trun])),
184
+ ]));
185
+ writeU32(moof, moof.length - trun.length + 16, moof.length + 8);
186
+ return [moof, buildBox("mdat", concat(fragment.samples))];
187
+ }
package/src/youtube.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type MediaItem,
16
16
  } from "./internal";
17
17
  import { browserUserAgent } from "./fingerprint";
18
+ import { parseMaster } from "./hls";
18
19
 
19
20
  type YoutubeSession = {
20
21
  cookie: string;
@@ -29,6 +30,16 @@ const androidVrClient = {
29
30
  version: "1.65.10",
30
31
  };
31
32
 
33
+ const visionOsClient = {
34
+ clientName: "VISIONOS",
35
+ clientVersion: "1.02",
36
+ deviceMake: "Apple",
37
+ deviceModel: "RealityDevice17,1",
38
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_7_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15",
39
+ osName: "visionOS",
40
+ osVersion: "26.5.23O471",
41
+ };
42
+
32
43
  const browserCookie = "PREF=hl=en&tz=UTC; SOCS=CAI";
33
44
 
34
45
  export async function resolveYoutube(input: ResolveContext): Promise<PostfetchResult> {
@@ -36,11 +47,33 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
36
47
  if (!id) {
37
48
  throw new Error("YouTube video id not found");
38
49
  }
39
- const session = await youtubeSession(input.net, id);
40
- const response = await input.net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
41
- body: JSON.stringify(playerBody(id, session)),
42
- headers: playerHeaders(session),
43
- method: "POST",
50
+ let session: YoutubeSession | undefined;
51
+ try {
52
+ session = await youtubeSession(input.net, id);
53
+ const payload = await youtubePlayer(input.net, playerBody(id, session), playerHeaders(session));
54
+ const streams = selectStreams(payload, input.preferredWidth);
55
+ if (!streams) {
56
+ throw new Error("YouTube mp4 stream not found");
57
+ }
58
+ const headers = { "user-agent": androidVrClient.userAgent };
59
+ // A successful player response can still contain CDN URLs that return 403.
60
+ // Check the actual GET and cancel its body: HEAD/Range probes can succeed
61
+ // even when YouTube rejects the full download.
62
+ await Promise.all([streams.video, streams.audio].filter((url): url is string => url !== null).map((url) => probeStream(input.net, url, headers)));
63
+ return youtubeResult(id, payload, streams, headers);
64
+ } catch (primaryError) {
65
+ try {
66
+ return await resolveVisionOs(input, id, session?.visitorData);
67
+ } catch (fallbackError) {
68
+ const message = (error: unknown) => error instanceof Error ? error.message : String(error);
69
+ throw new Error(`YouTube resolution failed: ${message(primaryError)}; visionOS HLS: ${message(fallbackError)}`, { cause: fallbackError });
70
+ }
71
+ }
72
+ }
73
+
74
+ async function youtubePlayer(net: Net, body: Json, headers: HeadersInit): Promise<unknown> {
75
+ const response = await net("https://www.youtube.com/youtubei/v1/player?prettyPrint=false", {
76
+ body: JSON.stringify(body), headers, method: "POST",
44
77
  });
45
78
  if (!response.ok) {
46
79
  throw new Error(`YouTube player failed: ${response.status}`);
@@ -51,12 +84,16 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
51
84
  const reason = object(payload) && object(payload.playabilityStatus) ? string(payload.playabilityStatus.reason) : null;
52
85
  throw new Error(reason ?? "YouTube video unavailable");
53
86
  }
54
- const streams = selectStreams(payload, input.preferredWidth);
55
- if (!streams) {
56
- throw new Error("YouTube mp4 stream not found");
57
- }
87
+ return payload;
88
+ }
89
+
90
+ function youtubeResult(
91
+ id: string,
92
+ payload: unknown,
93
+ streams: { video: string; audio: string | null; hls?: boolean },
94
+ headers: HeadersInit,
95
+ ): PostfetchResult {
58
96
  const title = object(payload) && object(payload.videoDetails) ? string(payload.videoDetails.title) : null;
59
- const headers = { "user-agent": androidVrClient.userAgent };
60
97
  const media: MediaItem = {
61
98
  filename: filename(`youtube_${title ?? id}_${id}.mp4`),
62
99
  headers,
@@ -65,11 +102,67 @@ export async function resolveYoutube(input: ResolveContext): Promise<PostfetchRe
65
102
  mime: "video/mp4",
66
103
  platform: "youtube",
67
104
  url: streams.video,
105
+ ...(streams.hls ? { hls: true } : {}),
68
106
  ...(streams.audio ? { audio: { headers, url: streams.audio } } : {}),
69
107
  };
70
108
  return { archiveFilename: filename(`youtube_${id}.zip`), id, items: [media], metadata: youtubeMetadata(payload), platform: "youtube" };
71
109
  }
72
110
 
111
+ async function probeStream(net: Net, url: string, headers: HeadersInit): Promise<void> {
112
+ const response = await net(url, { headers }, 1);
113
+ await response.body?.cancel();
114
+ if (!response.ok) {
115
+ throw new Error(`YouTube stream failed: ${response.status}`);
116
+ }
117
+ }
118
+
119
+ async function resolveVisionOs(input: ResolveContext, id: string, visitorData?: string): Promise<PostfetchResult> {
120
+ const headers = { "user-agent": visionOsClient.userAgent };
121
+ const payload = await youtubePlayer(input.net, {
122
+ videoId: id,
123
+ contentCheckOk: true,
124
+ racyCheckOk: true,
125
+ context: { client: { ...visionOsClient, hl: "en", gl: "US", ...(visitorData ? { visitorData } : {}) } },
126
+ }, {
127
+ ...headers,
128
+ "content-type": "application/json",
129
+ "x-youtube-client-name": "101",
130
+ "x-youtube-client-version": visionOsClient.clientVersion,
131
+ ...(visitorData ? { "x-goog-visitor-id": visitorData } : {}),
132
+ });
133
+ const streaming = object(payload) && object(payload.streamingData) ? payload.streamingData : null;
134
+ const manifestUrl = streaming ? string(streaming.hlsManifestUrl) : null;
135
+ if (!manifestUrl) {
136
+ throw new Error("YouTube HLS manifest not found");
137
+ }
138
+ const response = await input.net(manifestUrl, { headers });
139
+ if (!response.ok) {
140
+ throw new Error(`YouTube HLS manifest failed: ${response.status}`);
141
+ }
142
+ const master = parseMaster(await response.text(), manifestUrl);
143
+ // YouTube's AVC HLS variants use MPEG-TS. VP9/AV1 variants use fMP4 and
144
+ // work with the bundled box-level merger. Require the paired AAC-LC track.
145
+ const variants = master.variants.filter((variant) =>
146
+ /(?:^|,)(?:vp09|av01)\./i.test(variant.codecs ?? "") &&
147
+ /(?:^|,)mp4a\.40\.2(?:,|$)/i.test(variant.codecs ?? "") &&
148
+ variant.audioGroup && master.audio[variant.audioGroup]
149
+ ).sort((left, right) =>
150
+ Math.abs(left.width - input.preferredWidth) - Math.abs(right.width - input.preferredWidth) || right.bandwidth - left.bandwidth
151
+ );
152
+ const variant = variants[0];
153
+ if (!variant?.audioGroup) {
154
+ throw new Error("YouTube fragmented MP4 HLS video with AAC audio not found");
155
+ }
156
+ const playlist = await input.net(variant.url, { headers });
157
+ if (!playlist.ok) {
158
+ throw new Error(`YouTube HLS playlist failed: ${playlist.status}`);
159
+ }
160
+ if (!(await playlist.text()).includes("#EXT-X-MAP:")) {
161
+ throw new Error("YouTube HLS video is not fragmented MP4");
162
+ }
163
+ return youtubeResult(id, payload, { video: variant.url, audio: master.audio[variant.audioGroup], hls: true }, headers);
164
+ }
165
+
73
166
  export function youtubeMetadata(payload: unknown): PostMetadata & { extra?: YoutubeExtra } {
74
167
  const details = object(payload) && object(payload.videoDetails) ? payload.videoDetails : null;
75
168
  const renderer =