diff --git a/apps/browser-extension/utils/twitter-utils.ts b/apps/browser-extension/utils/twitter-utils.ts index 8085bf3a8..2682e6edf 100644 --- a/apps/browser-extension/utils/twitter-utils.ts +++ b/apps/browser-extension/utils/twitter-utils.ts @@ -56,13 +56,45 @@ interface MediaEntity { } } video_info?: { - variants?: Array<{ - url: string - }> + variants?: VideoVariant[] duration_millis?: number } } +export interface VideoVariant { + url: string + bitrate?: number + content_type?: string +} + +/** + * Twitter returns several video variants for a single video: an HLS `.m3u8` + * playlist (no bitrate) plus multiple `video/mp4` renditions at different + * bitrates, in no guaranteed order. Taking `variants[0]` therefore often stored + * the HLS playlist URL (not a directly usable file) or the lowest-quality clip. + * Pick the highest-bitrate MP4 instead, falling back to the first variant when + * no MP4 rendition is present. + */ +export function pickBestVideoVariantUrl( + variants: VideoVariant[] | undefined, +): string { + if (!variants || variants.length === 0) return "" + + const mp4s = variants.filter( + (v) => v.content_type === "video/mp4" || /\.mp4(?:\?|$)/i.test(v.url), + ) + const pool = mp4s.length > 0 ? mp4s : variants + + let best = pool[0] + for (const variant of pool) { + if ((variant.bitrate ?? 0) > (best?.bitrate ?? 0)) { + best = variant + } + } + + return best?.url || "" +} + export interface Tweet { __typename?: string lang?: string @@ -257,7 +289,7 @@ export function transformTweetData( const videos = media .filter((m) => m.type === "video") .map((m) => ({ - url: m.video_info?.variants?.[0]?.url || "", + url: pickBestVideoVariantUrl(m.video_info?.variants), thumbnail_url: m.media_url_https, duration: m.video_info?.duration_millis || 0, })) diff --git a/apps/browser-extension/utils/twitter-video-variant.test.ts b/apps/browser-extension/utils/twitter-video-variant.test.ts new file mode 100644 index 000000000..f9e8b53b9 --- /dev/null +++ b/apps/browser-extension/utils/twitter-video-variant.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test" +import { pickBestVideoVariantUrl } from "./twitter-utils" + +describe("pickBestVideoVariantUrl", () => { + it("returns an empty string when there are no variants", () => { + expect(pickBestVideoVariantUrl(undefined)).toBe("") + expect(pickBestVideoVariantUrl([])).toBe("") + }) + + it("picks the highest-bitrate mp4, not the first variant", () => { + const variants = [ + { + url: "https://video.twimg.com/playlist.m3u8", + content_type: "application/x-mpegURL", + }, + { + url: "https://video.twimg.com/low.mp4", + content_type: "video/mp4", + bitrate: 256000, + }, + { + url: "https://video.twimg.com/high.mp4", + content_type: "video/mp4", + bitrate: 2176000, + }, + { + url: "https://video.twimg.com/mid.mp4", + content_type: "video/mp4", + bitrate: 832000, + }, + ] + expect(pickBestVideoVariantUrl(variants)).toBe( + "https://video.twimg.com/high.mp4", + ) + }) + + it("does not return the HLS playlist when mp4 renditions exist", () => { + const variants = [ + { + url: "https://video.twimg.com/playlist.m3u8", + content_type: "application/x-mpegURL", + }, + { + url: "https://video.twimg.com/only.mp4", + content_type: "video/mp4", + bitrate: 632000, + }, + ] + expect(pickBestVideoVariantUrl(variants)).toBe( + "https://video.twimg.com/only.mp4", + ) + }) + + it("falls back to the first variant when no mp4 is present", () => { + const variants = [ + { + url: "https://video.twimg.com/playlist.m3u8", + content_type: "application/x-mpegURL", + }, + ] + expect(pickBestVideoVariantUrl(variants)).toBe( + "https://video.twimg.com/playlist.m3u8", + ) + }) + + it("detects mp4 by extension when content_type is absent", () => { + const variants = [ + { url: "https://video.twimg.com/240/vid.mp4?tag=12" }, + { url: "https://video.twimg.com/720/vid.mp4?tag=12", bitrate: 2176000 }, + ] + expect(pickBestVideoVariantUrl(variants)).toBe( + "https://video.twimg.com/720/vid.mp4?tag=12", + ) + }) +})