@volter/twin-instagram 0.1.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/LICENSE +202 -0
- package/README.md +56 -0
- package/dist/client/instagram-mirror.bundle.js +321 -0
- package/dist/client/instagram-mirror.d.ts +58 -0
- package/dist/client/instagram-mirror.js +257 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +30 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +71 -0
- package/dist/src/instagram-budget.d.ts +44 -0
- package/dist/src/instagram-budget.js +112 -0
- package/dist/src/instagram-capabilities.d.ts +4 -0
- package/dist/src/instagram-capabilities.js +1249 -0
- package/dist/src/instagram-conformance.d.ts +8 -0
- package/dist/src/instagram-conformance.js +44 -0
- package/dist/src/instagram-connector.d.ts +79 -0
- package/dist/src/instagram-connector.js +437 -0
- package/dist/src/instagram-errors.d.ts +33 -0
- package/dist/src/instagram-errors.js +73 -0
- package/dist/src/instagram-media.d.ts +134 -0
- package/dist/src/instagram-media.js +388 -0
- package/dist/src/instagram-mirror-ui.d.ts +57 -0
- package/dist/src/instagram-mirror-ui.js +158 -0
- package/dist/src/instagram-server.d.ts +14 -0
- package/dist/src/instagram-server.js +146 -0
- package/dist/src/instagram-twin.d.ts +52 -0
- package/dist/src/instagram-twin.js +884 -0
- package/package.json +57 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +117 -0
- package/src/instagram-budget.ts +130 -0
- package/src/instagram-capabilities.ts +1191 -0
- package/src/instagram-conformance.ts +58 -0
- package/src/instagram-connector.ts +400 -0
- package/src/instagram-errors.ts +89 -0
- package/src/instagram-media.ts +403 -0
- package/src/instagram-mirror-ui.ts +173 -0
- package/src/instagram-server.ts +146 -0
- package/src/instagram-twin.ts +859 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Meta's Graph API error envelope, as the Instagram Platform answers it.
|
|
2
|
+
//
|
|
3
|
+
// SHAPE. `{ error: { message, type, code, error_subcode?, is_transient, error_user_title?,
|
|
4
|
+
// error_user_msg?, fbtrace_id } }` — the Graph API "Handle Errors" guide's sample and the Instagram
|
|
5
|
+
// Platform "Error Codes" reference sample (developers.facebook.com, read 2026-09-27). Codes and
|
|
6
|
+
// subcodes below are that reference's table (-2/2207020 expired, 9/2207042 publish limit,
|
|
7
|
+
// 24/2207006 unknown media, 24/2207008 unknown or expired creation id, 9007/2207027 not ready,
|
|
8
|
+
// 352/2207026 unsupported video format, 1/2207057 thumb offset, 36004/2207010 caption length,
|
|
9
|
+
// 100/2207040 too many @ tags, 100/2207023 unknown media type) and the Graph guide's general codes
|
|
10
|
+
// (190 invalid token, 10 / 200-299 permission, 100 invalid parameter).
|
|
11
|
+
//
|
|
12
|
+
// EVIDENCE BOUNDARY. The codes and subcodes are the docs'. The `message` wording is quoted where the
|
|
13
|
+
// table gives a "User Message"; elsewhere it states the refusal plainly and is not claimed to be
|
|
14
|
+
// Meta's (`instagram.errors.message_wording`). `fbtrace_id` is opaque at Meta; the twin draws it from
|
|
15
|
+
// a digest of the request so the same request answers the same body (R9).
|
|
16
|
+
//
|
|
17
|
+
// THE RUPLOAD HOST answers its own envelope — `{ success, message }` on success and
|
|
18
|
+
// `{ debug_info: { retriable, type, message } }` on failure (the Content Publishing guide's samples).
|
|
19
|
+
import { blobDigest } from '@volter/world-core';
|
|
20
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=UTF-8' };
|
|
21
|
+
/** The fbtrace_id of a refusal: 11 characters drawn from what was refused — stable for one request. */
|
|
22
|
+
const TRACE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
23
|
+
function traceId(seed) {
|
|
24
|
+
const hex = blobDigest(new TextEncoder().encode(seed));
|
|
25
|
+
let out = '';
|
|
26
|
+
for (let i = 0; i < 11; i += 1)
|
|
27
|
+
out += TRACE_ALPHABET[parseInt(hex.slice(i * 2, i * 2 + 2), 16) % 64];
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
export function graphError(status, code, message, extra = {}) {
|
|
31
|
+
return {
|
|
32
|
+
status,
|
|
33
|
+
headers: { ...JSON_HEADERS },
|
|
34
|
+
body: {
|
|
35
|
+
error: {
|
|
36
|
+
message,
|
|
37
|
+
type: extra.type ?? 'OAuthException',
|
|
38
|
+
code,
|
|
39
|
+
...(extra.subcode !== undefined ? { error_subcode: extra.subcode } : {}),
|
|
40
|
+
is_transient: extra.transient ?? false,
|
|
41
|
+
...(extra.title ? { error_user_title: extra.title } : {}),
|
|
42
|
+
...(extra.userMsg ? { error_user_msg: extra.userMsg } : {}),
|
|
43
|
+
fbtrace_id: traceId(`${status}|${code}|${extra.subcode ?? ''}|${message}`),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** No token at all. (Code 104 and its wording are the Graph API's long-standing answer; not in the
|
|
49
|
+
* pages this build read — `instagram.errors.message_wording`.) */
|
|
50
|
+
export const noToken = () => graphError(400, 104, 'An access token is required to request this resource.');
|
|
51
|
+
/** A token the twin never issued. */
|
|
52
|
+
export const invalidToken = () => graphError(400, 190, 'Invalid OAuth access token - Cannot parse access token');
|
|
53
|
+
/** A token without the permission this edge needs (Graph code 10, "API Permission Denied"). */
|
|
54
|
+
export const permissionDenied = (what) => graphError(400, 10, `(#10) Application does not have permission for this action: ${what}`);
|
|
55
|
+
/** An unknown node or edge (Graph's "Unsupported get request" family, code 100 subcode 33). */
|
|
56
|
+
export const unsupported = (method, id) => graphError(400, 100, `Unsupported ${method.toLowerCase()} request. Object with ID '${id}' does not exist, cannot be loaded due to missing permissions, or does not support this operation. Please read the Graph API documentation at https://developers.facebook.com/docs/graph-api`, { type: 'GraphMethodException', subcode: 33 });
|
|
57
|
+
/** A path the Graph API does not know. */
|
|
58
|
+
export const unknownPath = (path) => graphError(400, 2500, `Unknown path components: ${path}`);
|
|
59
|
+
export const invalidParam = (message, subcode) => graphError(400, 100, `(#100) ${message}`, subcode !== undefined ? { subcode } : {});
|
|
60
|
+
export const missingParam = (name) => graphError(400, 100, `(#100) The parameter ${name} is required`);
|
|
61
|
+
/** Real Instagram Platform surface this twin does not model: refused by name, never answered as a success. */
|
|
62
|
+
export const unmodeled = (what) => graphError(422, 100, `[twin gap] ${what} is real Instagram Platform surface this twin does not model`);
|
|
63
|
+
export const readOnlyRefusal = () => graphError(405, 3, '(#3) This twin is read-only');
|
|
64
|
+
export function ok(body, status = 200) {
|
|
65
|
+
return { status, body, headers: { ...JSON_HEADERS } };
|
|
66
|
+
}
|
|
67
|
+
// ── rupload.facebook.com ────────────────────────────────────────────────────────────────────
|
|
68
|
+
export const ruploadOk = () => ok({ success: true, message: 'Upload successful.' });
|
|
69
|
+
/** The rupload failure envelope. `type` names are the twin's where the guide gives only its one
|
|
70
|
+
* sample (`ProcessingFailedError`, "unauthorized user request"). */
|
|
71
|
+
export function ruploadFailure(status, type, message, retriable = false) {
|
|
72
|
+
return { status, headers: { ...JSON_HEADERS }, body: { debug_info: { retriable, type, message: JSON.stringify({ success: false, error: { message } }) } } };
|
|
73
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** "File size: 300MB maximum." Decimal, as written. */
|
|
2
|
+
export declare const MAX_REEL_BYTES = 300000000;
|
|
3
|
+
/** "Duration: 15 mins maximum, 3 seconds minimum." */
|
|
4
|
+
export declare const MIN_REEL_MS = 3000;
|
|
5
|
+
export declare const MAX_REEL_MS: number;
|
|
6
|
+
/** "Maximum columns (horizontal pixels): 1920." */
|
|
7
|
+
export declare const MAX_REEL_COLUMNS = 1920;
|
|
8
|
+
/** "Required aspect ratio is between 0.01:1 and 10:1." */
|
|
9
|
+
export declare const MIN_REEL_ASPECT = 0.01;
|
|
10
|
+
export declare const MAX_REEL_ASPECT = 10;
|
|
11
|
+
/** "Frame rate: 23-60 FPS." */
|
|
12
|
+
export declare const MIN_REEL_FPS = 23;
|
|
13
|
+
export declare const MAX_REEL_FPS = 60;
|
|
14
|
+
/** "Audio codec: AAC, 48khz sample rate maximum, 1 or 2 channels." */
|
|
15
|
+
export declare const MAX_AUDIO_HZ = 48000;
|
|
16
|
+
/** "Video bitrate: VBR, 25Mbps maximum"; "Audio bitrate: 128kbps". The twin reads no per-track sizes,
|
|
17
|
+
* so it holds the file's AVERAGE bitrate (bytes × 8 over the duration) to the two together. */
|
|
18
|
+
export declare const MAX_REEL_BITS_PER_S: number;
|
|
19
|
+
/** "Containers expire after 24 hours." */
|
|
20
|
+
export declare const CONTAINER_LIFETIME_MS: number;
|
|
21
|
+
/** How long (world time) an uploaded Reel stays IN_PROGRESS before its verdict (FINISHED or ERROR).
|
|
22
|
+
* THE TWIN'S VALUE: Meta publishes none. A read folds the state from the stored instant and the
|
|
23
|
+
* request's own — reading never moves anything, and a frozen World clock keeps it IN_PROGRESS. */
|
|
24
|
+
export declare const PROCESSING_MS = 5000;
|
|
25
|
+
/** The subcode a spec violation is answered with: "The video format is not supported. Please check
|
|
26
|
+
* spec for supported {video} format" (Error Codes reference, 352 / 2207026). */
|
|
27
|
+
export declare const SUBCODE_UNSUPPORTED_VIDEO = 2207026;
|
|
28
|
+
/** "Thumbnail offset must be greater than or equal to 0 and less than video duration" (1 / 2207057). */
|
|
29
|
+
export declare const SUBCODE_THUMB_OFFSET = 2207057;
|
|
30
|
+
export type ContainerRecord = {
|
|
31
|
+
id: string;
|
|
32
|
+
/** The IG user id the container was created on. */
|
|
33
|
+
owner: string;
|
|
34
|
+
media_type: 'REELS';
|
|
35
|
+
upload_type: 'resumable';
|
|
36
|
+
caption?: string;
|
|
37
|
+
share_to_feed: boolean;
|
|
38
|
+
cover_url?: string;
|
|
39
|
+
thumb_offset?: number;
|
|
40
|
+
audio_name?: string;
|
|
41
|
+
is_ai_generated?: boolean;
|
|
42
|
+
created_ms: number;
|
|
43
|
+
/** The declared `file_size` of the rupload, fixed by its first POST. */
|
|
44
|
+
file_size?: number;
|
|
45
|
+
/** Bytes received so far, in order (the next POST's `offset` must equal this). */
|
|
46
|
+
received: number;
|
|
47
|
+
/** The world instant the last byte arrived; processing completes PROCESSING_MS later. */
|
|
48
|
+
uploaded_ms?: number;
|
|
49
|
+
sha256?: string;
|
|
50
|
+
size?: number;
|
|
51
|
+
video?: VideoInfo;
|
|
52
|
+
/** Set when processing refuses the file: the Error Codes subcode and why. */
|
|
53
|
+
error_subcode?: number;
|
|
54
|
+
error_reason?: string;
|
|
55
|
+
};
|
|
56
|
+
export type ContainerStatus = 'IN_PROGRESS' | 'FINISHED' | 'ERROR' | 'EXPIRED' | 'PUBLISHED';
|
|
57
|
+
/**
|
|
58
|
+
* A container's status_code at `nowMs` — a pure fold of the record, the instant, and whether a
|
|
59
|
+
* published media names it: PUBLISHED once published; EXPIRED 24 h after creation if not; ERROR or
|
|
60
|
+
* FINISHED once PROCESSING_MS of world time has passed since the last byte; IN_PROGRESS before.
|
|
61
|
+
*/
|
|
62
|
+
export declare function containerStatus(c: ContainerRecord, nowMs: number, published: boolean): ContainerStatus;
|
|
63
|
+
/** Where the bytes of a digest live under this service's resources (the key the ranged reads take). */
|
|
64
|
+
export declare const blobKey: (sha256: string) => string;
|
|
65
|
+
export declare function isNumericId(id: string): boolean;
|
|
66
|
+
/** Store bytes content-addressed; a re-upload of identical content is a no-op write. */
|
|
67
|
+
export declare function putInstagramBlob(bytes: Uint8Array, root?: string): Promise<{
|
|
68
|
+
sha256: string;
|
|
69
|
+
size: number;
|
|
70
|
+
}>;
|
|
71
|
+
/** A stored blob's size, here or at an ancestor; null when absent or the digest is malformed. */
|
|
72
|
+
export declare function instagramBlobSize(sha256: unknown, root?: string): Promise<number | null>;
|
|
73
|
+
/** Bytes start..endInclusive of a stored blob, here or at an ancestor. */
|
|
74
|
+
export declare function readInstagramBlobRange(sha256: unknown, start: number, endInclusive: number, root?: string): Promise<Uint8Array | null>;
|
|
75
|
+
/** A whole stored blob (a deploy reads the Reel it uploads); null when absent. */
|
|
76
|
+
export declare function readInstagramBlob(sha256: unknown, root?: string): Promise<Uint8Array | null>;
|
|
77
|
+
export declare function writeContainer(record: ContainerRecord, root?: string): Promise<void>;
|
|
78
|
+
export declare function readContainer(id: string, root?: string): Promise<ContainerRecord | undefined>;
|
|
79
|
+
/** Every container this branch AND its retained ancestors hold (what a read could resolve). */
|
|
80
|
+
export declare function listContainers(root?: string): Promise<ContainerRecord[]>;
|
|
81
|
+
/** One uploaded chunk, keyed by the offset it starts at. Chunks are staging on THIS branch only. */
|
|
82
|
+
export declare function putChunk(id: string, offset: number, bytes: Uint8Array, root?: string): Promise<void>;
|
|
83
|
+
/** Every chunk of a container, in byte order (the seam's sorted listing; offsets are zero-padded). */
|
|
84
|
+
export declare function readChunks(id: string, root?: string): Promise<Uint8Array[]>;
|
|
85
|
+
export declare function clearChunks(id: string, root?: string): Promise<void>;
|
|
86
|
+
/** Run `fn` after every earlier `withContainer` of the same container in this process has settled. */
|
|
87
|
+
export declare function withContainer<T>(id: string, root: string | undefined, fn: () => Promise<T>): Promise<T>;
|
|
88
|
+
/** `created_ms`: the LOCAL instant the container opened (its 24-hour lifetime is counted here);
|
|
89
|
+
* `vendor_opened_ms`: the VENDOR's instant for it (a lost publish is matched on the vendor's clock);
|
|
90
|
+
* `uploaded`: the bytes the vendor has confirmed, chunk by chunk — a retry resumes there; `unsure`: the
|
|
91
|
+
* whole file is believed held only because a resumed POST was refused while the container processed. */
|
|
92
|
+
export type PerformContainer = {
|
|
93
|
+
container: string;
|
|
94
|
+
sha256: string;
|
|
95
|
+
created_ms: number;
|
|
96
|
+
vendor_opened_ms?: number;
|
|
97
|
+
uploaded: number;
|
|
98
|
+
target: string;
|
|
99
|
+
unsure?: boolean;
|
|
100
|
+
};
|
|
101
|
+
export declare function readPerformContainer(actionId: string, root?: string): Promise<PerformContainer | null>;
|
|
102
|
+
export declare function writePerformContainer(actionId: string, record: PerformContainer, root?: string): Promise<void>;
|
|
103
|
+
export declare function clearPerformContainer(actionId: string, root?: string): Promise<void>;
|
|
104
|
+
export type VideoInfo = {
|
|
105
|
+
/** 'mp4' or 'mov' (ftyp major brand `qt `). */
|
|
106
|
+
container: 'mp4' | 'mov';
|
|
107
|
+
moovFirst: boolean;
|
|
108
|
+
durationMs: number;
|
|
109
|
+
width: number;
|
|
110
|
+
height: number;
|
|
111
|
+
videoCodec: string;
|
|
112
|
+
fps?: number;
|
|
113
|
+
audioCodec?: string;
|
|
114
|
+
audioChannels?: number;
|
|
115
|
+
audioHz?: number;
|
|
116
|
+
};
|
|
117
|
+
export declare function sniffVideo(bytes: Uint8Array): VideoInfo | undefined;
|
|
118
|
+
/** Why a file is not a publishable Reel, or undefined when it is — the spec above, in order. */
|
|
119
|
+
export declare function reelSpecViolation(info: VideoInfo | undefined, size: number, thumbOffset?: number): {
|
|
120
|
+
subcode: number;
|
|
121
|
+
reason: string;
|
|
122
|
+
} | undefined;
|
|
123
|
+
/** An image's type and size from its header — JPEG or PNG (a seeded profile picture). */
|
|
124
|
+
export declare function sniffImage(bytes: Uint8Array): {
|
|
125
|
+
mediaType: 'image/png' | 'image/jpeg';
|
|
126
|
+
width: number;
|
|
127
|
+
height: number;
|
|
128
|
+
} | undefined;
|
|
129
|
+
/**
|
|
130
|
+
* The thumbnail Meta cuts from a Reel at `thumb_offset` when no cover_url is given. The twin decodes
|
|
131
|
+
* no frames, so it serves a STAND-IN at the Reel's own aspect — a dark frame with a play glyph — and
|
|
132
|
+
* says so here (`instagram.media.frame_thumbnail`, a todo).
|
|
133
|
+
*/
|
|
134
|
+
export declare function reelPosterSvg(width: number, height: number): string;
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// Media CONTAINERS and their BYTES for the Instagram twin — what `POST /{ig-user-id}/media` opens,
|
|
2
|
+
// what the rupload host fills, and what `media_publish` makes public.
|
|
3
|
+
//
|
|
4
|
+
// The method is the YouTube pack's byte annex (youtube-blobs.ts) and the LinkedIn pack's asset
|
|
5
|
+
// records (linkedin-media.ts). Bytes ride the KERNEL'S BLOB SEAM (`getActiveBlobStore()`, runtime
|
|
6
|
+
// contract R11), content-addressed by sha256 under this service's own `resources` directory, and are
|
|
7
|
+
// READ through the kernel's resource-blob helpers (`readResourceBlobRange`, `resourceBlobSize`), which
|
|
8
|
+
// look in this branch and then its retained ancestors and read a RANGE natively — a seek never loads
|
|
9
|
+
// the whole Reel.
|
|
10
|
+
//
|
|
11
|
+
// A CONTAINER IS STAGING, NOT A KERNEL ACTION. At Meta a container publishes nothing: it is a place to
|
|
12
|
+
// put a video that only `media_publish` makes public, and it expires after 24 hours. Recording it
|
|
13
|
+
// through `applyTwinWrite` would create a durably pending entry with no vendor write of its own and
|
|
14
|
+
// wedge every deploy behind it. So the container (owner, parameters, upload progress, the finished
|
|
15
|
+
// digest, the processing verdict) is a bare pointer record in the byte annex, and its uploaded chunks
|
|
16
|
+
// are keys beside it. What becomes state is the PUBLISH: the media's own entry names its video by
|
|
17
|
+
// DIGEST (instagram-twin.ts), and that is what a deploy performs (instagram-connector.ts creates a
|
|
18
|
+
// container at Meta, uploads the bytes, waits, publishes).
|
|
19
|
+
//
|
|
20
|
+
// A container is authorized by the caller's ACCESS TOKEN on every request (the rupload host takes the
|
|
21
|
+
// same token as the Graph API — `Authorization: OAuth <token>`); no upload URL is signed, so no secret
|
|
22
|
+
// is held, and no token is ever written here or in the log.
|
|
23
|
+
//
|
|
24
|
+
// Every read-modify-write of a container record runs in that container's chain (`withContainer`).
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { blobDigest, getActiveBlobStore, readBranchMeta, readResourceBlob, readResourceBlobRange, resourceBlobSize, worldPaths } from '@volter/world-core';
|
|
27
|
+
const SERVICE = 'instagram';
|
|
28
|
+
// ── the Reels specification (IG User Media reference, "Reel Specifications", read 2026-09-27) ──
|
|
29
|
+
/** "File size: 300MB maximum." Decimal, as written. */
|
|
30
|
+
export const MAX_REEL_BYTES = 300_000_000;
|
|
31
|
+
/** "Duration: 15 mins maximum, 3 seconds minimum." */
|
|
32
|
+
export const MIN_REEL_MS = 3_000;
|
|
33
|
+
export const MAX_REEL_MS = 15 * 60_000;
|
|
34
|
+
/** "Maximum columns (horizontal pixels): 1920." */
|
|
35
|
+
export const MAX_REEL_COLUMNS = 1920;
|
|
36
|
+
/** "Required aspect ratio is between 0.01:1 and 10:1." */
|
|
37
|
+
export const MIN_REEL_ASPECT = 0.01;
|
|
38
|
+
export const MAX_REEL_ASPECT = 10;
|
|
39
|
+
/** "Frame rate: 23-60 FPS." */
|
|
40
|
+
export const MIN_REEL_FPS = 23;
|
|
41
|
+
export const MAX_REEL_FPS = 60;
|
|
42
|
+
/** "Audio codec: AAC, 48khz sample rate maximum, 1 or 2 channels." */
|
|
43
|
+
export const MAX_AUDIO_HZ = 48_000;
|
|
44
|
+
/** "Video bitrate: VBR, 25Mbps maximum"; "Audio bitrate: 128kbps". The twin reads no per-track sizes,
|
|
45
|
+
* so it holds the file's AVERAGE bitrate (bytes × 8 over the duration) to the two together. */
|
|
46
|
+
export const MAX_REEL_BITS_PER_S = 25_000_000 + 128_000;
|
|
47
|
+
/** "Containers expire after 24 hours." */
|
|
48
|
+
export const CONTAINER_LIFETIME_MS = 24 * 3_600_000;
|
|
49
|
+
/** How long (world time) an uploaded Reel stays IN_PROGRESS before its verdict (FINISHED or ERROR).
|
|
50
|
+
* THE TWIN'S VALUE: Meta publishes none. A read folds the state from the stored instant and the
|
|
51
|
+
* request's own — reading never moves anything, and a frozen World clock keeps it IN_PROGRESS. */
|
|
52
|
+
export const PROCESSING_MS = 5_000;
|
|
53
|
+
/** The subcode a spec violation is answered with: "The video format is not supported. Please check
|
|
54
|
+
* spec for supported {video} format" (Error Codes reference, 352 / 2207026). */
|
|
55
|
+
export const SUBCODE_UNSUPPORTED_VIDEO = 2207026;
|
|
56
|
+
/** "Thumbnail offset must be greater than or equal to 0 and less than video duration" (1 / 2207057). */
|
|
57
|
+
export const SUBCODE_THUMB_OFFSET = 2207057;
|
|
58
|
+
/**
|
|
59
|
+
* A container's status_code at `nowMs` — a pure fold of the record, the instant, and whether a
|
|
60
|
+
* published media names it: PUBLISHED once published; EXPIRED 24 h after creation if not; ERROR or
|
|
61
|
+
* FINISHED once PROCESSING_MS of world time has passed since the last byte; IN_PROGRESS before.
|
|
62
|
+
*/
|
|
63
|
+
export function containerStatus(c, nowMs, published) {
|
|
64
|
+
if (published)
|
|
65
|
+
return 'PUBLISHED';
|
|
66
|
+
if (nowMs >= c.created_ms + CONTAINER_LIFETIME_MS)
|
|
67
|
+
return 'EXPIRED';
|
|
68
|
+
if (c.uploaded_ms === undefined || nowMs < c.uploaded_ms + PROCESSING_MS)
|
|
69
|
+
return 'IN_PROGRESS';
|
|
70
|
+
return c.error_subcode !== undefined ? 'ERROR' : 'FINISHED';
|
|
71
|
+
}
|
|
72
|
+
const resourcesDir = (root) => worldPaths(SERVICE, root).resources;
|
|
73
|
+
/** Where the bytes of a digest live under this service's resources (the key the ranged reads take). */
|
|
74
|
+
export const blobKey = (sha256) => join('blobs', 'sha256', sha256.slice(0, 2), sha256);
|
|
75
|
+
const recordKey = (id) => join('containers', `${safeId(id)}.json`);
|
|
76
|
+
const chunkKey = (id, offset) => join('container-chunks', safeId(id), `${String(offset).padStart(12, '0')}.part`);
|
|
77
|
+
export function isNumericId(id) {
|
|
78
|
+
return /^\d{1,25}$/.test(id);
|
|
79
|
+
}
|
|
80
|
+
function safeId(id) {
|
|
81
|
+
if (!isNumericId(id))
|
|
82
|
+
throw new Error(`invalid Instagram container id for storage: ${JSON.stringify(id)}`);
|
|
83
|
+
return id;
|
|
84
|
+
}
|
|
85
|
+
/** Store bytes content-addressed; a re-upload of identical content is a no-op write. */
|
|
86
|
+
export async function putInstagramBlob(bytes, root) {
|
|
87
|
+
const sha256 = blobDigest(bytes);
|
|
88
|
+
const key = join(resourcesDir(root), blobKey(sha256));
|
|
89
|
+
if (!(await getActiveBlobStore().exists(key)))
|
|
90
|
+
await getActiveBlobStore().put(key, bytes);
|
|
91
|
+
return { sha256, size: bytes.length };
|
|
92
|
+
}
|
|
93
|
+
/** A stored blob's size, here or at an ancestor; null when absent or the digest is malformed. */
|
|
94
|
+
export async function instagramBlobSize(sha256, root) {
|
|
95
|
+
if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256))
|
|
96
|
+
return null;
|
|
97
|
+
return resourceBlobSize(SERVICE, blobKey(sha256), root);
|
|
98
|
+
}
|
|
99
|
+
/** Bytes start..endInclusive of a stored blob, here or at an ancestor. */
|
|
100
|
+
export async function readInstagramBlobRange(sha256, start, endInclusive, root) {
|
|
101
|
+
if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256))
|
|
102
|
+
return null;
|
|
103
|
+
return readResourceBlobRange(SERVICE, blobKey(sha256), start, endInclusive, root);
|
|
104
|
+
}
|
|
105
|
+
/** A whole stored blob (a deploy reads the Reel it uploads); null when absent. */
|
|
106
|
+
export async function readInstagramBlob(sha256, root) {
|
|
107
|
+
if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256))
|
|
108
|
+
return null;
|
|
109
|
+
return readResourceBlob(SERVICE, blobKey(sha256), root);
|
|
110
|
+
}
|
|
111
|
+
export async function writeContainer(record, root) {
|
|
112
|
+
await getActiveBlobStore().put(join(resourcesDir(root), recordKey(record.id)), new TextEncoder().encode(JSON.stringify(record)));
|
|
113
|
+
}
|
|
114
|
+
export async function readContainer(id, root) {
|
|
115
|
+
if (!isNumericId(id))
|
|
116
|
+
return undefined;
|
|
117
|
+
try {
|
|
118
|
+
const stored = await readResourceBlob(SERVICE, recordKey(id), root);
|
|
119
|
+
if (stored === null)
|
|
120
|
+
return undefined;
|
|
121
|
+
const parsed = JSON.parse(new TextDecoder().decode(stored));
|
|
122
|
+
return typeof parsed.id === 'string' && parsed.media_type === 'REELS' ? parsed : undefined;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Every container this branch AND its retained ancestors hold (what a read could resolve). */
|
|
129
|
+
export async function listContainers(root) {
|
|
130
|
+
const ids = new Set();
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
let current = worldPaths(SERVICE, root).root;
|
|
133
|
+
for (;;) {
|
|
134
|
+
if (seen.has(current))
|
|
135
|
+
break;
|
|
136
|
+
seen.add(current);
|
|
137
|
+
const prefix = `${join(worldPaths(SERVICE, current).resources, 'containers')}/`;
|
|
138
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
139
|
+
if (key.startsWith(prefix) && !key.slice(prefix.length).includes('/') && key.endsWith('.json'))
|
|
140
|
+
ids.add(key.slice(prefix.length, -'.json'.length));
|
|
141
|
+
}
|
|
142
|
+
const parent = readBranchMeta(SERVICE, current)?.parent;
|
|
143
|
+
if (!parent)
|
|
144
|
+
break;
|
|
145
|
+
current = parent.at;
|
|
146
|
+
}
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const id of ids) {
|
|
149
|
+
const c = await readContainer(id, root);
|
|
150
|
+
if (c)
|
|
151
|
+
out.push(c);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
/** One uploaded chunk, keyed by the offset it starts at. Chunks are staging on THIS branch only. */
|
|
156
|
+
export async function putChunk(id, offset, bytes, root) {
|
|
157
|
+
await getActiveBlobStore().put(join(resourcesDir(root), chunkKey(id, offset)), bytes);
|
|
158
|
+
}
|
|
159
|
+
/** Every chunk of a container, in byte order (the seam's sorted listing; offsets are zero-padded). */
|
|
160
|
+
export async function readChunks(id, root) {
|
|
161
|
+
const prefix = `${join(resourcesDir(root), 'container-chunks', safeId(id))}/`;
|
|
162
|
+
const keys = (await getActiveBlobStore().list(prefix)).filter((k) => k.endsWith('.part')).sort();
|
|
163
|
+
const out = [];
|
|
164
|
+
for (const key of keys) {
|
|
165
|
+
const b = await getActiveBlobStore().get(key);
|
|
166
|
+
if (b)
|
|
167
|
+
out.push(b);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
export async function clearChunks(id, root) {
|
|
172
|
+
const prefix = `${join(resourcesDir(root), 'container-chunks', safeId(id))}/`;
|
|
173
|
+
for (const key of await getActiveBlobStore().list(prefix)) {
|
|
174
|
+
try {
|
|
175
|
+
await getActiveBlobStore().remove(key);
|
|
176
|
+
}
|
|
177
|
+
catch { /* already gone */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// cache: one chain of pending work per container record (`<resources dir>|<id>`), so two rupload POSTs
|
|
181
|
+
// for one container never interleave their read-modify-write of `containers/<id>.json`.
|
|
182
|
+
const chains = new Map();
|
|
183
|
+
/** Run `fn` after every earlier `withContainer` of the same container in this process has settled. */
|
|
184
|
+
export async function withContainer(id, root, fn) {
|
|
185
|
+
const key = `${resourcesDir(root)}|${id}`;
|
|
186
|
+
const prior = chains.get(key) ?? Promise.resolve();
|
|
187
|
+
const run = prior.then(fn, fn);
|
|
188
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
189
|
+
chains.set(key, settled);
|
|
190
|
+
try {
|
|
191
|
+
return await run;
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
if (chains.get(key) === settled)
|
|
195
|
+
chains.delete(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const performKey = (actionId, root) => join(resourcesDir(root), 'perform-containers', `${blobDigest(new TextEncoder().encode(actionId))}.json`);
|
|
199
|
+
export async function readPerformContainer(actionId, root) {
|
|
200
|
+
const stored = await getActiveBlobStore().get(performKey(actionId, root));
|
|
201
|
+
if (stored === null)
|
|
202
|
+
return null;
|
|
203
|
+
try {
|
|
204
|
+
const v = JSON.parse(new TextDecoder().decode(stored));
|
|
205
|
+
return typeof v.container === 'string' && typeof v.sha256 === 'string' && typeof v.created_ms === 'number' && typeof v.uploaded === 'number' && typeof v.target === 'string' ? v : null;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
export async function writePerformContainer(actionId, record, root) {
|
|
212
|
+
await getActiveBlobStore().put(performKey(actionId, root), new TextEncoder().encode(JSON.stringify(record)));
|
|
213
|
+
}
|
|
214
|
+
export async function clearPerformContainer(actionId, root) {
|
|
215
|
+
try {
|
|
216
|
+
await getActiveBlobStore().remove(performKey(actionId, root));
|
|
217
|
+
}
|
|
218
|
+
catch { /* none */ }
|
|
219
|
+
}
|
|
220
|
+
export function sniffVideo(bytes) {
|
|
221
|
+
const b = bytes;
|
|
222
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
223
|
+
const type = (at) => String.fromCharCode(b[at + 4], b[at + 5], b[at + 6], b[at + 7]);
|
|
224
|
+
const boxes = (start, end) => {
|
|
225
|
+
const out = [];
|
|
226
|
+
let at = start;
|
|
227
|
+
while (at + 8 <= end) {
|
|
228
|
+
let size = view.getUint32(at);
|
|
229
|
+
let header = 8;
|
|
230
|
+
if (size === 1) {
|
|
231
|
+
if (at + 16 > end)
|
|
232
|
+
break;
|
|
233
|
+
size = Number(view.getBigUint64(at + 8));
|
|
234
|
+
header = 16;
|
|
235
|
+
}
|
|
236
|
+
else if (size === 0)
|
|
237
|
+
size = end - at;
|
|
238
|
+
if (size < header || at + size > end)
|
|
239
|
+
break;
|
|
240
|
+
out.push([type(at), at + header, at + size]);
|
|
241
|
+
at += size;
|
|
242
|
+
}
|
|
243
|
+
return out;
|
|
244
|
+
};
|
|
245
|
+
const child = (parent, name) => (parent ? boxes(parent[1], parent[2]).find(([t]) => t === name) : undefined);
|
|
246
|
+
const top = boxes(0, b.length);
|
|
247
|
+
if (top[0]?.[0] !== 'ftyp')
|
|
248
|
+
return undefined;
|
|
249
|
+
const brand = String.fromCharCode(b[top[0][1]], b[top[0][1] + 1], b[top[0][1] + 2], b[top[0][1] + 3]);
|
|
250
|
+
const moovAt = top.findIndex(([t]) => t === 'moov');
|
|
251
|
+
const mdatAt = top.findIndex(([t]) => t === 'mdat');
|
|
252
|
+
if (moovAt < 0)
|
|
253
|
+
return undefined;
|
|
254
|
+
const moov = top[moovAt];
|
|
255
|
+
const mvhd = child(moov, 'mvhd');
|
|
256
|
+
if (!mvhd || mvhd[2] - mvhd[1] < 32)
|
|
257
|
+
return undefined;
|
|
258
|
+
const mv = b[mvhd[1]];
|
|
259
|
+
const movieScale = mv === 1 ? view.getUint32(mvhd[1] + 20) : view.getUint32(mvhd[1] + 12);
|
|
260
|
+
const movieDuration = mv === 1 ? Number(view.getBigUint64(mvhd[1] + 24)) : view.getUint32(mvhd[1] + 16);
|
|
261
|
+
if (movieScale === 0)
|
|
262
|
+
return undefined;
|
|
263
|
+
const info = {
|
|
264
|
+
container: brand === 'qt ' ? 'mov' : 'mp4', moovFirst: mdatAt < 0 || moovAt < mdatAt,
|
|
265
|
+
durationMs: Math.round((movieDuration * 1000) / movieScale), width: 0, height: 0, videoCodec: '',
|
|
266
|
+
};
|
|
267
|
+
for (const trak of boxes(moov[1], moov[2]).filter(([t]) => t === 'trak')) {
|
|
268
|
+
const mdia = child(trak, 'mdia');
|
|
269
|
+
const hdlr = child(mdia, 'hdlr');
|
|
270
|
+
const handler = hdlr && hdlr[2] - hdlr[1] >= 12 ? String.fromCharCode(b[hdlr[1] + 8], b[hdlr[1] + 9], b[hdlr[1] + 10], b[hdlr[1] + 11]) : '';
|
|
271
|
+
const stbl = child(child(mdia, 'minf'), 'stbl');
|
|
272
|
+
const stsd = child(stbl, 'stsd');
|
|
273
|
+
// stsd: version/flags (4), entry count (4), then the first sample entry box
|
|
274
|
+
const entry = stsd && stsd[2] - stsd[1] >= 16 ? stsd[1] + 8 : undefined;
|
|
275
|
+
const codec = entry !== undefined ? type(entry) : '';
|
|
276
|
+
if (handler === 'vide' && info.videoCodec === '') {
|
|
277
|
+
info.videoCodec = codec;
|
|
278
|
+
const tkhd = child(trak, 'tkhd');
|
|
279
|
+
if (tkhd) {
|
|
280
|
+
const v = b[tkhd[1]];
|
|
281
|
+
const at = tkhd[1] + (v === 1 ? 88 : 76);
|
|
282
|
+
if (at + 8 <= tkhd[2]) {
|
|
283
|
+
info.width = view.getUint32(at) >>> 16;
|
|
284
|
+
info.height = view.getUint32(at + 4) >>> 16;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const mdhd = child(mdia, 'mdhd');
|
|
288
|
+
const stts = child(stbl, 'stts');
|
|
289
|
+
if (mdhd && stts && stts[2] - stts[1] >= 8) {
|
|
290
|
+
const v = b[mdhd[1]];
|
|
291
|
+
const scale = v === 1 ? view.getUint32(mdhd[1] + 20) : view.getUint32(mdhd[1] + 12);
|
|
292
|
+
const duration = v === 1 ? Number(view.getBigUint64(mdhd[1] + 24)) : view.getUint32(mdhd[1] + 16);
|
|
293
|
+
const n = view.getUint32(stts[1] + 4);
|
|
294
|
+
let frames = 0;
|
|
295
|
+
for (let i = 0; i < n && stts[1] + 8 + i * 8 + 8 <= stts[2]; i += 1)
|
|
296
|
+
frames += view.getUint32(stts[1] + 8 + i * 8);
|
|
297
|
+
if (scale > 0 && duration > 0 && frames > 0)
|
|
298
|
+
info.fps = Math.round((frames * scale * 100) / duration) / 100;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
else if (handler === 'soun' && info.audioCodec === undefined) {
|
|
302
|
+
info.audioCodec = codec;
|
|
303
|
+
// AudioSampleEntry: 8 box header, 6 reserved, 2 data-ref, 8 reserved, channelcount(2),
|
|
304
|
+
// samplesize(2), pre_defined(2), reserved(2), samplerate(4, 16.16)
|
|
305
|
+
if (entry !== undefined && entry + 36 <= stsd[2]) {
|
|
306
|
+
info.audioChannels = view.getUint16(entry + 24);
|
|
307
|
+
info.audioHz = view.getUint32(entry + 32) >>> 16;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (info.videoCodec === '' || info.width === 0 || info.height === 0)
|
|
312
|
+
return undefined;
|
|
313
|
+
return info;
|
|
314
|
+
}
|
|
315
|
+
/** Why a file is not a publishable Reel, or undefined when it is — the spec above, in order. */
|
|
316
|
+
export function reelSpecViolation(info, size, thumbOffset) {
|
|
317
|
+
const bad = (reason) => ({ subcode: SUBCODE_UNSUPPORTED_VIDEO, reason });
|
|
318
|
+
if (!info)
|
|
319
|
+
return bad('The file is not an MOV or MP4 (MPEG-4 Part 14) with a video track.');
|
|
320
|
+
if (!info.moovFirst)
|
|
321
|
+
return bad('The moov atom is not at the front of the file.');
|
|
322
|
+
if (!['avc1', 'avc3', 'hvc1', 'hev1'].includes(info.videoCodec))
|
|
323
|
+
return bad(`Video codec ${info.videoCodec} is not HEVC or H264.`);
|
|
324
|
+
if (info.audioCodec !== undefined && info.audioCodec !== 'mp4a')
|
|
325
|
+
return bad(`Audio codec ${info.audioCodec} is not AAC.`);
|
|
326
|
+
if (info.audioHz !== undefined && info.audioHz > MAX_AUDIO_HZ)
|
|
327
|
+
return bad(`Audio sample rate ${info.audioHz} Hz is over 48 kHz.`);
|
|
328
|
+
if (info.audioChannels !== undefined && (info.audioChannels < 1 || info.audioChannels > 2))
|
|
329
|
+
return bad(`Audio has ${info.audioChannels} channels; 1 or 2 are accepted.`);
|
|
330
|
+
if (info.fps !== undefined && (info.fps < MIN_REEL_FPS || info.fps > MAX_REEL_FPS))
|
|
331
|
+
return bad(`Frame rate ${info.fps} FPS is outside 23-60 FPS.`);
|
|
332
|
+
if (info.width > MAX_REEL_COLUMNS)
|
|
333
|
+
return bad(`${info.width} columns is over the 1920 maximum.`);
|
|
334
|
+
const aspect = info.width / info.height;
|
|
335
|
+
if (aspect < MIN_REEL_ASPECT || aspect > MAX_REEL_ASPECT)
|
|
336
|
+
return bad(`Aspect ratio ${aspect.toFixed(3)}:1 is outside 0.01:1 to 10:1.`);
|
|
337
|
+
if (info.durationMs < MIN_REEL_MS || info.durationMs > MAX_REEL_MS)
|
|
338
|
+
return bad(`Duration ${info.durationMs} ms is outside 3 seconds to 15 minutes.`);
|
|
339
|
+
if (size > MAX_REEL_BYTES)
|
|
340
|
+
return bad(`File size ${size} bytes is over 300MB.`);
|
|
341
|
+
const bps = (size * 8 * 1000) / info.durationMs;
|
|
342
|
+
if (bps > MAX_REEL_BITS_PER_S)
|
|
343
|
+
return bad(`Average bitrate ${Math.round(bps / 1000)} kbps is over the 25 Mbps video and 128 kbps audio maximum.`);
|
|
344
|
+
if (thumbOffset !== undefined && thumbOffset >= info.durationMs) {
|
|
345
|
+
return { subcode: SUBCODE_THUMB_OFFSET, reason: `Thumbnail offset must be greater than or equal to 0 and less than video duration, i.e. ${info.durationMs}` };
|
|
346
|
+
}
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
/** An image's type and size from its header — JPEG or PNG (a seeded profile picture). */
|
|
350
|
+
export function sniffImage(bytes) {
|
|
351
|
+
const b = bytes;
|
|
352
|
+
const u16be = (i) => (b[i] << 8) | b[i + 1];
|
|
353
|
+
const u32be = (i) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3];
|
|
354
|
+
if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
|
|
355
|
+
return { mediaType: 'image/png', width: u32be(16), height: u32be(20) };
|
|
356
|
+
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
|
357
|
+
let i = 2;
|
|
358
|
+
while (i + 9 < b.length) {
|
|
359
|
+
if (b[i] !== 0xff) {
|
|
360
|
+
i += 1;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const marker = b[i + 1];
|
|
364
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
365
|
+
i += 2;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
|
|
369
|
+
return { mediaType: 'image/jpeg', height: u16be(i + 5), width: u16be(i + 7) };
|
|
370
|
+
i += 2 + u16be(i + 2);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The thumbnail Meta cuts from a Reel at `thumb_offset` when no cover_url is given. The twin decodes
|
|
377
|
+
* no frames, so it serves a STAND-IN at the Reel's own aspect — a dark frame with a play glyph — and
|
|
378
|
+
* says so here (`instagram.media.frame_thumbnail`, a todo).
|
|
379
|
+
*/
|
|
380
|
+
export function reelPosterSvg(width, height) {
|
|
381
|
+
const w = width > 0 ? width : 1080;
|
|
382
|
+
const h = height > 0 ? height : 1920;
|
|
383
|
+
const r = Math.round(Math.min(w, h) * 0.09);
|
|
384
|
+
const cx = w / 2;
|
|
385
|
+
const cy = h / 2;
|
|
386
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#262626"/>`
|
|
387
|
+
+ `<path d="M${cx - r * 0.35} ${cy - r * 0.5}L${cx + r * 0.55} ${cy}L${cx - r * 0.35} ${cy + r * 0.5}Z" fill="#fff"/></svg>`;
|
|
388
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type IgRow = Record<string, any>;
|
|
2
|
+
/** The version the mirror's reads pin — the twin's newest. */
|
|
3
|
+
export declare const MIRROR_VERSION = "v26.0";
|
|
4
|
+
/** The account fields the profile header reads. */
|
|
5
|
+
export declare const PROFILE_FIELDS = "id,username,name,biography,website,followers_count,follows_count,media_count,profile_picture_url";
|
|
6
|
+
/** The media fields the grid and the viewer read. */
|
|
7
|
+
export declare const REEL_FIELDS = "id,media_type,media_product_type,media_url,thumbnail_url,permalink,caption,timestamp,shortcode,username,like_count,comments_count,is_shared_to_feed";
|
|
8
|
+
/** instagram.com's count: "1,234" below ten thousand, then "12.5K", "1.2M" (one decimal, trailing .0 dropped). */
|
|
9
|
+
export declare function compactCount(n: unknown): string;
|
|
10
|
+
/** instagram.com's age of a post: "now", "5m", "3h", "2d", "3w", then the date ("March 4" / "March 4, 2025"). */
|
|
11
|
+
export declare function timeAgo(timestamp: unknown, nowMs: number): string;
|
|
12
|
+
export type Segment = {
|
|
13
|
+
kind: 'text' | 'hashtag' | 'mention';
|
|
14
|
+
value: string;
|
|
15
|
+
};
|
|
16
|
+
/** A caption as instagram.com draws it: `#tag` and `@user` as links, everything else text. */
|
|
17
|
+
export declare function captionSegments(caption: unknown): Segment[];
|
|
18
|
+
/** A profile as the mirror draws it, from the account read — every field from that answer, nothing else. */
|
|
19
|
+
export type MirrorProfile = {
|
|
20
|
+
id: string;
|
|
21
|
+
username: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
biography?: string;
|
|
24
|
+
website?: string;
|
|
25
|
+
avatarUrl?: string;
|
|
26
|
+
posts?: number;
|
|
27
|
+
followers?: number;
|
|
28
|
+
following?: number;
|
|
29
|
+
};
|
|
30
|
+
export declare function profileFromRead(me: IgRow): MirrorProfile;
|
|
31
|
+
/** Whether a media is a Reel (the Content Publishing guide: request media_product_type, since a
|
|
32
|
+
* published Reel's media_type reads VIDEO). */
|
|
33
|
+
export declare function isReel(m: IgRow): boolean;
|
|
34
|
+
/** What a profile's tab shows: the Reels tab every Reel; the Posts tab only those shared to the feed
|
|
35
|
+
* (IG Media: `is_shared_to_feed` false "indicates the reel can only appear in the Reels tab"). */
|
|
36
|
+
export declare function tabReels(reels: IgRow[], tab: 'posts' | 'reels'): IgRow[];
|
|
37
|
+
/** The mirror's own route for a Reel: instagram.com's `/reel/<shortcode>/`. */
|
|
38
|
+
export declare function reelRoute(shortcode: string): string;
|
|
39
|
+
/** The read the grid and the viewer draw: the account's media, newest first. */
|
|
40
|
+
export declare function mediaQuery(userId: string, after?: string): string;
|
|
41
|
+
/** Build the React/TSX mirror client to browser JS; memoized at module scope, so a pack on its own
|
|
42
|
+
* does exactly one `Bun.build`. */
|
|
43
|
+
export declare function buildInstagramMirrorClient(): Promise<string>;
|
|
44
|
+
/** Serve the Instagram mirror UI (React app) + its backing Graph API on one origin. */
|
|
45
|
+
export declare function createInstagramMirrorServer(options: {
|
|
46
|
+
root?: string;
|
|
47
|
+
port?: number;
|
|
48
|
+
readOnly?: boolean;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
port: number;
|
|
51
|
+
url: string;
|
|
52
|
+
stop: () => void;
|
|
53
|
+
}>;
|
|
54
|
+
/** The app-shell HTML (pure). */
|
|
55
|
+
export declare function instagramMirrorHtml(): string;
|
|
56
|
+
/** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
|
|
57
|
+
export declare function instagramMirrorStyles(): Promise<string>;
|