V1
This commit is contained in:
289
src/emusks-local/helpers/media.js
Normal file
289
src/emusks-local/helpers/media.js
Normal file
@@ -0,0 +1,289 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { extname } from "path";
|
||||
import getCycleTLS from "../cycletls.js";
|
||||
|
||||
const UPLOAD_URL = "https://upload.twitter.com/i/media/upload.json";
|
||||
const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MB
|
||||
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
const MAX_GIF_SIZE = 15 * 1024 * 1024; // 15 MB
|
||||
const MAX_VIDEO_SIZE = 512 * 1024 * 1024; // 512 MB
|
||||
|
||||
const MIME_BY_EXT = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".mp4": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
".avi": "video/x-msvideo",
|
||||
};
|
||||
|
||||
function detectMediaType(buf) {
|
||||
if (buf[0] === 0xff && buf[1] === 0xd8) return "image/jpeg";
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";
|
||||
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return "image/gif";
|
||||
if (
|
||||
buf[0] === 0x52 &&
|
||||
buf[1] === 0x49 &&
|
||||
buf[2] === 0x46 &&
|
||||
buf[3] === 0x46 &&
|
||||
buf[8] === 0x57 &&
|
||||
buf[9] === 0x45 &&
|
||||
buf[10] === 0x42 &&
|
||||
buf[11] === 0x50
|
||||
)
|
||||
return "image/webp";
|
||||
if (buf.length > 7 && buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70)
|
||||
return "video/mp4";
|
||||
if (buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) return "video/webm";
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMediaCategory(mediaType, uploadType) {
|
||||
if (mediaType === "image/gif") return `${uploadType}_gif`;
|
||||
if (mediaType.startsWith("video/")) return `${uploadType}_video`;
|
||||
if (mediaType.startsWith("image/")) return `${uploadType}_image`;
|
||||
throw new Error(`unsupported media type: ${mediaType}`);
|
||||
}
|
||||
|
||||
function checkMediaSize(category, size) {
|
||||
const fmt = (x) => `${(x / 1e6).toFixed(2)} MB`;
|
||||
if (category.includes("image") && !category.includes("gif") && size > MAX_IMAGE_SIZE)
|
||||
throw new Error(`cannot upload ${fmt(size)} image \u2014 max is ${fmt(MAX_IMAGE_SIZE)}`);
|
||||
if (category.includes("gif") && size > MAX_GIF_SIZE)
|
||||
throw new Error(`cannot upload ${fmt(size)} gif \u2014 max is ${fmt(MAX_GIF_SIZE)}`);
|
||||
if (category.includes("video") && size > MAX_VIDEO_SIZE)
|
||||
throw new Error(`cannot upload ${fmt(size)} video \u2014 max is ${fmt(MAX_VIDEO_SIZE)}`);
|
||||
}
|
||||
|
||||
async function makeUploadRequest(instance, method, params, body, extraHeaders = {}) {
|
||||
const cycleTLS = await getCycleTLS();
|
||||
const url = `${UPLOAD_URL}?${new URLSearchParams(params).toString()}`;
|
||||
|
||||
const headers = {
|
||||
accept: "*/*",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
authorization: `Bearer ${instance.auth.client.bearer}`,
|
||||
"x-csrf-token": instance.auth.csrfToken,
|
||||
"x-twitter-active-user": "yes",
|
||||
"x-twitter-auth-type": "OAuth2Session",
|
||||
"x-twitter-client-language": "en",
|
||||
priority: "u=1, i",
|
||||
"sec-ch-ua": "Not(A:Brand;v=8, Chromium;v=144",
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": "macOS",
|
||||
"sec-fetch-dest": "empty",
|
||||
"sec-fetch-mode": "cors",
|
||||
"sec-fetch-site": "same-site",
|
||||
"sec-gpc": "1",
|
||||
cookie:
|
||||
instance.auth.client.headers.cookie +
|
||||
(instance.elevatedCookies ? `; ${instance.elevatedCookies}` : ""),
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
return await cycleTLS(
|
||||
url,
|
||||
{
|
||||
headers,
|
||||
userAgent:
|
||||
instance.auth.client.fingerprints?.userAgent ||
|
||||
instance.auth.client.fingerprints?.["user-agent"],
|
||||
ja3: instance.auth.client.fingerprints?.ja3,
|
||||
ja4r: instance.auth.client.fingerprints?.ja4r,
|
||||
body: body || undefined,
|
||||
proxy: instance.proxy || undefined,
|
||||
referrer: "https://x.com/",
|
||||
},
|
||||
method,
|
||||
);
|
||||
}
|
||||
|
||||
export async function create(source, opts = {}) {
|
||||
if (!this.auth) throw new Error("you must be logged in to upload media");
|
||||
|
||||
let buf;
|
||||
let mediaType = opts.mediaType;
|
||||
|
||||
if (typeof source === "string") {
|
||||
buf = await readFile(source);
|
||||
if (!mediaType) mediaType = MIME_BY_EXT[extname(source).toLowerCase()];
|
||||
} else if (typeof Blob !== "undefined" && source instanceof Blob) {
|
||||
buf = Buffer.from(await source.arrayBuffer());
|
||||
if (!mediaType) mediaType = source.type || undefined;
|
||||
} else if (source instanceof ArrayBuffer || source instanceof SharedArrayBuffer) {
|
||||
buf = Buffer.from(source);
|
||||
} else if (Buffer.isBuffer(source) || source instanceof Uint8Array) {
|
||||
buf = Buffer.from(source);
|
||||
} else {
|
||||
throw new Error(
|
||||
"source must be a file path (string), Buffer, Uint8Array, ArrayBuffer, or Blob",
|
||||
);
|
||||
}
|
||||
|
||||
if (!mediaType) mediaType = detectMediaType(buf);
|
||||
if (!mediaType)
|
||||
throw new Error("could not detect media type \u2014 pass opts.mediaType (e.g. 'image/png')");
|
||||
|
||||
const totalBytes = buf.length;
|
||||
const uploadType = opts.type === "dm" ? "dm" : "tweet";
|
||||
const category = getMediaCategory(mediaType, uploadType);
|
||||
checkMediaSize(category, totalBytes);
|
||||
|
||||
const initRes = await makeUploadRequest(this, "post", {
|
||||
command: "INIT",
|
||||
media_type: mediaType,
|
||||
total_bytes: totalBytes.toString(),
|
||||
media_category: category,
|
||||
});
|
||||
|
||||
const initData = await initRes.json();
|
||||
if (!initData?.media_id_string) {
|
||||
throw new Error(`upload INIT failed: ${JSON.stringify(initData)}`);
|
||||
}
|
||||
const mediaId = initData.media_id_string;
|
||||
|
||||
let segmentIndex = 0;
|
||||
for (let offset = 0; offset < totalBytes; offset += CHUNK_SIZE) {
|
||||
const chunk = buf.slice(offset, Math.min(offset + CHUNK_SIZE, totalBytes));
|
||||
const base64 = chunk.toString("base64");
|
||||
|
||||
await makeUploadRequest(
|
||||
this,
|
||||
"post",
|
||||
{
|
||||
command: "APPEND",
|
||||
media_id: mediaId,
|
||||
segment_index: segmentIndex.toString(),
|
||||
},
|
||||
`media_data=${encodeURIComponent(base64)}`,
|
||||
{ "content-type": "application/x-www-form-urlencoded" },
|
||||
);
|
||||
segmentIndex++;
|
||||
}
|
||||
|
||||
const finalizeRes = await makeUploadRequest(this, "post", {
|
||||
command: "FINALIZE",
|
||||
media_id: mediaId,
|
||||
allow_async: "true",
|
||||
});
|
||||
|
||||
const finalizeData = await finalizeRes.json();
|
||||
if (finalizeData?.error) {
|
||||
throw new Error(`upload FINALIZE failed: ${JSON.stringify(finalizeData)}`);
|
||||
}
|
||||
|
||||
let processingInfo = finalizeData?.processing_info;
|
||||
while (processingInfo) {
|
||||
if (processingInfo.error) {
|
||||
throw new Error(`media processing error: ${JSON.stringify(processingInfo.error)}`);
|
||||
}
|
||||
if (processingInfo.state === "succeeded") break;
|
||||
if (processingInfo.state === "failed") {
|
||||
throw new Error(`media processing failed: ${JSON.stringify(processingInfo)}`);
|
||||
}
|
||||
|
||||
const wait = (processingInfo.check_after_secs || 2) * 1000;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
|
||||
const statusRes = await makeUploadRequest(this, "get", {
|
||||
command: "STATUS",
|
||||
media_id: mediaId,
|
||||
});
|
||||
const statusData = await statusRes.json();
|
||||
processingInfo = statusData?.processing_info;
|
||||
}
|
||||
|
||||
if (opts.alt_text) {
|
||||
await this.v1_1("media/metadata/create", {
|
||||
body: JSON.stringify({
|
||||
media_id: mediaId,
|
||||
alt_text: { text: opts.alt_text },
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return { media_id: mediaId, ...finalizeData };
|
||||
}
|
||||
|
||||
export async function createFromUrl(url, opts = {}) {
|
||||
if (!this.auth) throw new Error("you must be logged in to upload media");
|
||||
|
||||
const mediaType = opts.mediaType || "image/gif";
|
||||
const uploadType = opts.type === "dm" ? "dm" : "tweet";
|
||||
const category = opts.mediaCategory || getMediaCategory(mediaType, uploadType);
|
||||
|
||||
const initRes = await makeUploadRequest(this, "post", {
|
||||
command: "INIT",
|
||||
source_url: url,
|
||||
media_type: mediaType,
|
||||
media_category: category,
|
||||
});
|
||||
|
||||
const initData = await initRes.json();
|
||||
if (!initData?.media_id_string) {
|
||||
throw new Error(`upload INIT failed: ${JSON.stringify(initData)}`);
|
||||
}
|
||||
const mediaId = initData.media_id_string;
|
||||
|
||||
let processingInfo = initData?.processing_info;
|
||||
while (processingInfo) {
|
||||
if (processingInfo.error) {
|
||||
throw new Error(`media processing error: ${JSON.stringify(processingInfo.error)}`);
|
||||
}
|
||||
if (processingInfo.state === "succeeded") break;
|
||||
if (processingInfo.state === "failed") {
|
||||
throw new Error(`media processing failed: ${JSON.stringify(processingInfo)}`);
|
||||
}
|
||||
|
||||
const wait = (processingInfo.check_after_secs || 2) * 1000;
|
||||
await new Promise((r) => setTimeout(r, wait));
|
||||
|
||||
const statusRes = await makeUploadRequest(this, "get", {
|
||||
command: "STATUS",
|
||||
media_id: mediaId,
|
||||
});
|
||||
const statusData = await statusRes.json();
|
||||
processingInfo = statusData?.processing_info;
|
||||
}
|
||||
|
||||
const metadataBody = { media_id: mediaId };
|
||||
const altText = opts.altText || opts.alt_text;
|
||||
if (altText) metadataBody.alt_text = { text: altText };
|
||||
if (opts.origin) metadataBody.found_media_origin = opts.origin;
|
||||
|
||||
if (altText || opts.origin) {
|
||||
await this.v1_1("media/metadata/create", {
|
||||
body: JSON.stringify(metadataBody),
|
||||
});
|
||||
}
|
||||
|
||||
return { media_id: mediaId, ...initData };
|
||||
}
|
||||
|
||||
export async function createMetadata(mediaId, altText, opts = {}) {
|
||||
const res = await this.v1_1("media/metadata/create", {
|
||||
body: JSON.stringify({
|
||||
media_id: mediaId,
|
||||
alt_text: { text: altText },
|
||||
...opts,
|
||||
}),
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function createSubtitles(mediaId, subtitles) {
|
||||
const res = await this.v1_1("media/subtitles/create", {
|
||||
body: JSON.stringify({
|
||||
media_id: mediaId,
|
||||
media_category: "tweet_video",
|
||||
subtitle_info: {
|
||||
subtitles: Array.isArray(subtitles) ? subtitles : [subtitles],
|
||||
},
|
||||
}),
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
Reference in New Issue
Block a user