@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.
Files changed (39) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +56 -0
  3. package/dist/client/instagram-mirror.bundle.js +321 -0
  4. package/dist/client/instagram-mirror.d.ts +58 -0
  5. package/dist/client/instagram-mirror.js +257 -0
  6. package/dist/src/cli.d.ts +2 -0
  7. package/dist/src/cli.js +30 -0
  8. package/dist/src/index.d.ts +10 -0
  9. package/dist/src/index.js +71 -0
  10. package/dist/src/instagram-budget.d.ts +44 -0
  11. package/dist/src/instagram-budget.js +112 -0
  12. package/dist/src/instagram-capabilities.d.ts +4 -0
  13. package/dist/src/instagram-capabilities.js +1249 -0
  14. package/dist/src/instagram-conformance.d.ts +8 -0
  15. package/dist/src/instagram-conformance.js +44 -0
  16. package/dist/src/instagram-connector.d.ts +79 -0
  17. package/dist/src/instagram-connector.js +437 -0
  18. package/dist/src/instagram-errors.d.ts +33 -0
  19. package/dist/src/instagram-errors.js +73 -0
  20. package/dist/src/instagram-media.d.ts +134 -0
  21. package/dist/src/instagram-media.js +388 -0
  22. package/dist/src/instagram-mirror-ui.d.ts +57 -0
  23. package/dist/src/instagram-mirror-ui.js +158 -0
  24. package/dist/src/instagram-server.d.ts +14 -0
  25. package/dist/src/instagram-server.js +146 -0
  26. package/dist/src/instagram-twin.d.ts +52 -0
  27. package/dist/src/instagram-twin.js +884 -0
  28. package/package.json +57 -0
  29. package/src/cli.ts +28 -0
  30. package/src/index.ts +117 -0
  31. package/src/instagram-budget.ts +130 -0
  32. package/src/instagram-capabilities.ts +1191 -0
  33. package/src/instagram-conformance.ts +58 -0
  34. package/src/instagram-connector.ts +400 -0
  35. package/src/instagram-errors.ts +89 -0
  36. package/src/instagram-media.ts +403 -0
  37. package/src/instagram-mirror-ui.ts +173 -0
  38. package/src/instagram-server.ts +146 -0
  39. package/src/instagram-twin.ts +859 -0
@@ -0,0 +1,403 @@
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
+
28
+ const SERVICE = 'instagram';
29
+
30
+ // ── the Reels specification (IG User Media reference, "Reel Specifications", read 2026-09-27) ──
31
+
32
+ /** "File size: 300MB maximum." Decimal, as written. */
33
+ export const MAX_REEL_BYTES = 300_000_000;
34
+ /** "Duration: 15 mins maximum, 3 seconds minimum." */
35
+ export const MIN_REEL_MS = 3_000;
36
+ export const MAX_REEL_MS = 15 * 60_000;
37
+ /** "Maximum columns (horizontal pixels): 1920." */
38
+ export const MAX_REEL_COLUMNS = 1920;
39
+ /** "Required aspect ratio is between 0.01:1 and 10:1." */
40
+ export const MIN_REEL_ASPECT = 0.01;
41
+ export const MAX_REEL_ASPECT = 10;
42
+ /** "Frame rate: 23-60 FPS." */
43
+ export const MIN_REEL_FPS = 23;
44
+ export const MAX_REEL_FPS = 60;
45
+ /** "Audio codec: AAC, 48khz sample rate maximum, 1 or 2 channels." */
46
+ export const MAX_AUDIO_HZ = 48_000;
47
+ /** "Video bitrate: VBR, 25Mbps maximum"; "Audio bitrate: 128kbps". The twin reads no per-track sizes,
48
+ * so it holds the file's AVERAGE bitrate (bytes × 8 over the duration) to the two together. */
49
+ export const MAX_REEL_BITS_PER_S = 25_000_000 + 128_000;
50
+ /** "Containers expire after 24 hours." */
51
+ export const CONTAINER_LIFETIME_MS = 24 * 3_600_000;
52
+ /** How long (world time) an uploaded Reel stays IN_PROGRESS before its verdict (FINISHED or ERROR).
53
+ * THE TWIN'S VALUE: Meta publishes none. A read folds the state from the stored instant and the
54
+ * request's own — reading never moves anything, and a frozen World clock keeps it IN_PROGRESS. */
55
+ export const PROCESSING_MS = 5_000;
56
+
57
+ /** The subcode a spec violation is answered with: "The video format is not supported. Please check
58
+ * spec for supported {video} format" (Error Codes reference, 352 / 2207026). */
59
+ export const SUBCODE_UNSUPPORTED_VIDEO = 2207026;
60
+ /** "Thumbnail offset must be greater than or equal to 0 and less than video duration" (1 / 2207057). */
61
+ export const SUBCODE_THUMB_OFFSET = 2207057;
62
+
63
+ export type ContainerRecord = {
64
+ id: string;
65
+ /** The IG user id the container was created on. */
66
+ owner: string;
67
+ media_type: 'REELS';
68
+ upload_type: 'resumable';
69
+ caption?: string;
70
+ share_to_feed: boolean;
71
+ cover_url?: string;
72
+ thumb_offset?: number;
73
+ audio_name?: string;
74
+ is_ai_generated?: boolean;
75
+ created_ms: number;
76
+ /** The declared `file_size` of the rupload, fixed by its first POST. */
77
+ file_size?: number;
78
+ /** Bytes received so far, in order (the next POST's `offset` must equal this). */
79
+ received: number;
80
+ /** The world instant the last byte arrived; processing completes PROCESSING_MS later. */
81
+ uploaded_ms?: number;
82
+ sha256?: string;
83
+ size?: number;
84
+ video?: VideoInfo;
85
+ /** Set when processing refuses the file: the Error Codes subcode and why. */
86
+ error_subcode?: number;
87
+ error_reason?: string;
88
+ };
89
+
90
+ export type ContainerStatus = 'IN_PROGRESS' | 'FINISHED' | 'ERROR' | 'EXPIRED' | 'PUBLISHED';
91
+
92
+ /**
93
+ * A container's status_code at `nowMs` — a pure fold of the record, the instant, and whether a
94
+ * published media names it: PUBLISHED once published; EXPIRED 24 h after creation if not; ERROR or
95
+ * FINISHED once PROCESSING_MS of world time has passed since the last byte; IN_PROGRESS before.
96
+ */
97
+ export function containerStatus(c: ContainerRecord, nowMs: number, published: boolean): ContainerStatus {
98
+ if (published) return 'PUBLISHED';
99
+ if (nowMs >= c.created_ms + CONTAINER_LIFETIME_MS) return 'EXPIRED';
100
+ if (c.uploaded_ms === undefined || nowMs < c.uploaded_ms + PROCESSING_MS) return 'IN_PROGRESS';
101
+ return c.error_subcode !== undefined ? 'ERROR' : 'FINISHED';
102
+ }
103
+
104
+ const resourcesDir = (root?: string): string => worldPaths(SERVICE, root).resources;
105
+ /** Where the bytes of a digest live under this service's resources (the key the ranged reads take). */
106
+ export const blobKey = (sha256: string): string => join('blobs', 'sha256', sha256.slice(0, 2), sha256);
107
+ const recordKey = (id: string): string => join('containers', `${safeId(id)}.json`);
108
+ const chunkKey = (id: string, offset: number): string => join('container-chunks', safeId(id), `${String(offset).padStart(12, '0')}.part`);
109
+
110
+ export function isNumericId(id: string): boolean {
111
+ return /^\d{1,25}$/.test(id);
112
+ }
113
+
114
+ function safeId(id: string): string {
115
+ if (!isNumericId(id)) throw new Error(`invalid Instagram container id for storage: ${JSON.stringify(id)}`);
116
+ return id;
117
+ }
118
+
119
+ /** Store bytes content-addressed; a re-upload of identical content is a no-op write. */
120
+ export async function putInstagramBlob(bytes: Uint8Array, root?: string): Promise<{ sha256: string; size: number }> {
121
+ const sha256 = blobDigest(bytes);
122
+ const key = join(resourcesDir(root), blobKey(sha256));
123
+ if (!(await getActiveBlobStore().exists(key))) await getActiveBlobStore().put(key, bytes);
124
+ return { sha256, size: bytes.length };
125
+ }
126
+
127
+ /** A stored blob's size, here or at an ancestor; null when absent or the digest is malformed. */
128
+ export async function instagramBlobSize(sha256: unknown, root?: string): Promise<number | null> {
129
+ if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256)) return null;
130
+ return resourceBlobSize(SERVICE, blobKey(sha256), root);
131
+ }
132
+
133
+ /** Bytes start..endInclusive of a stored blob, here or at an ancestor. */
134
+ export async function readInstagramBlobRange(sha256: unknown, start: number, endInclusive: number, root?: string): Promise<Uint8Array | null> {
135
+ if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256)) return null;
136
+ return readResourceBlobRange(SERVICE, blobKey(sha256), start, endInclusive, root);
137
+ }
138
+
139
+ /** A whole stored blob (a deploy reads the Reel it uploads); null when absent. */
140
+ export async function readInstagramBlob(sha256: unknown, root?: string): Promise<Uint8Array | null> {
141
+ if (typeof sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(sha256)) return null;
142
+ return readResourceBlob(SERVICE, blobKey(sha256), root);
143
+ }
144
+
145
+ export async function writeContainer(record: ContainerRecord, root?: string): Promise<void> {
146
+ await getActiveBlobStore().put(join(resourcesDir(root), recordKey(record.id)), new TextEncoder().encode(JSON.stringify(record)));
147
+ }
148
+
149
+ export async function readContainer(id: string, root?: string): Promise<ContainerRecord | undefined> {
150
+ if (!isNumericId(id)) return undefined;
151
+ try {
152
+ const stored = await readResourceBlob(SERVICE, recordKey(id), root);
153
+ if (stored === null) return undefined;
154
+ const parsed = JSON.parse(new TextDecoder().decode(stored)) as ContainerRecord;
155
+ return typeof parsed.id === 'string' && parsed.media_type === 'REELS' ? parsed : undefined;
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /** Every container this branch AND its retained ancestors hold (what a read could resolve). */
162
+ export async function listContainers(root?: string): Promise<ContainerRecord[]> {
163
+ const ids = new Set<string>();
164
+ const seen = new Set<string>();
165
+ let current = worldPaths(SERVICE, root).root;
166
+ for (;;) {
167
+ if (seen.has(current)) break;
168
+ seen.add(current);
169
+ const prefix = `${join(worldPaths(SERVICE, current).resources, 'containers')}/`;
170
+ for (const key of await getActiveBlobStore().list(prefix)) {
171
+ if (key.startsWith(prefix) && !key.slice(prefix.length).includes('/') && key.endsWith('.json')) ids.add(key.slice(prefix.length, -'.json'.length));
172
+ }
173
+ const parent = readBranchMeta(SERVICE, current)?.parent;
174
+ if (!parent) break;
175
+ current = parent.at;
176
+ }
177
+ const out: ContainerRecord[] = [];
178
+ for (const id of ids) { const c = await readContainer(id, root); if (c) out.push(c); }
179
+ return out;
180
+ }
181
+
182
+ /** One uploaded chunk, keyed by the offset it starts at. Chunks are staging on THIS branch only. */
183
+ export async function putChunk(id: string, offset: number, bytes: Uint8Array, root?: string): Promise<void> {
184
+ await getActiveBlobStore().put(join(resourcesDir(root), chunkKey(id, offset)), bytes);
185
+ }
186
+
187
+ /** Every chunk of a container, in byte order (the seam's sorted listing; offsets are zero-padded). */
188
+ export async function readChunks(id: string, root?: string): Promise<Uint8Array[]> {
189
+ const prefix = `${join(resourcesDir(root), 'container-chunks', safeId(id))}/`;
190
+ const keys = (await getActiveBlobStore().list(prefix)).filter((k) => k.endsWith('.part')).sort();
191
+ const out: Uint8Array[] = [];
192
+ for (const key of keys) { const b = await getActiveBlobStore().get(key); if (b) out.push(b); }
193
+ return out;
194
+ }
195
+
196
+ export async function clearChunks(id: string, root?: string): Promise<void> {
197
+ const prefix = `${join(resourcesDir(root), 'container-chunks', safeId(id))}/`;
198
+ for (const key of await getActiveBlobStore().list(prefix)) {
199
+ try { await getActiveBlobStore().remove(key); } catch { /* already gone */ }
200
+ }
201
+ }
202
+
203
+ // cache: one chain of pending work per container record (`<resources dir>|<id>`), so two rupload POSTs
204
+ // for one container never interleave their read-modify-write of `containers/<id>.json`.
205
+ const chains = new Map<string, Promise<unknown>>();
206
+
207
+ /** Run `fn` after every earlier `withContainer` of the same container in this process has settled. */
208
+ export async function withContainer<T>(id: string, root: string | undefined, fn: () => Promise<T>): Promise<T> {
209
+ const key = `${resourcesDir(root)}|${id}`;
210
+ const prior = chains.get(key) ?? Promise.resolve();
211
+ const run = prior.then(fn, fn);
212
+ const settled = run.then(() => undefined, () => undefined);
213
+ chains.set(key, settled);
214
+ try { return await run; } finally { if (chains.get(key) === settled) chains.delete(key); }
215
+ }
216
+
217
+ // ── a perform's container, kept across retries ─────────────────────────────────────────────
218
+ // A perform that uploaded a Reel to a container at Meta and then failed (the wait ran out, the publish
219
+ // refused) keeps the vendor's container id here, keyed by the entry, so its retry publishes THAT
220
+ // container instead of uploading the file a second time (the LinkedIn pack's kept video URN).
221
+
222
+ /** `created_ms`: the LOCAL instant the container opened (its 24-hour lifetime is counted here);
223
+ * `vendor_opened_ms`: the VENDOR's instant for it (a lost publish is matched on the vendor's clock);
224
+ * `uploaded`: the bytes the vendor has confirmed, chunk by chunk — a retry resumes there; `unsure`: the
225
+ * whole file is believed held only because a resumed POST was refused while the container processed. */
226
+ export type PerformContainer = { container: string; sha256: string; created_ms: number; vendor_opened_ms?: number; uploaded: number; target: string; unsure?: boolean };
227
+ const performKey = (actionId: string, root?: string): string => join(resourcesDir(root), 'perform-containers', `${blobDigest(new TextEncoder().encode(actionId))}.json`);
228
+
229
+ export async function readPerformContainer(actionId: string, root?: string): Promise<PerformContainer | null> {
230
+ const stored = await getActiveBlobStore().get(performKey(actionId, root));
231
+ if (stored === null) return null;
232
+ try {
233
+ const v = JSON.parse(new TextDecoder().decode(stored)) as PerformContainer;
234
+ return typeof v.container === 'string' && typeof v.sha256 === 'string' && typeof v.created_ms === 'number' && typeof v.uploaded === 'number' && typeof v.target === 'string' ? v : null;
235
+ } catch { return null; }
236
+ }
237
+ export async function writePerformContainer(actionId: string, record: PerformContainer, root?: string): Promise<void> {
238
+ await getActiveBlobStore().put(performKey(actionId, root), new TextEncoder().encode(JSON.stringify(record)));
239
+ }
240
+ export async function clearPerformContainer(actionId: string, root?: string): Promise<void> {
241
+ try { await getActiveBlobStore().remove(performKey(actionId, root)); } catch { /* none */ }
242
+ }
243
+
244
+ // ── what the bytes are ──────────────────────────────────────────────────────────────────────
245
+ // Meta decides a Reel's fitness from the file it received. The twin reads the same facts from the
246
+ // ISO BMFF boxes — nothing is decoded: the brand, whether `moov` precedes `mdat` ("moov atom at the
247
+ // front of the file"), the movie duration, and per track its handler, codec (the first stsd entry),
248
+ // presented size (tkhd), frame count over media duration (stts, mdhd) and, for audio, the AAC entry's
249
+ // channels and sample rate.
250
+
251
+ export type VideoInfo = {
252
+ /** 'mp4' or 'mov' (ftyp major brand `qt `). */
253
+ container: 'mp4' | 'mov';
254
+ moovFirst: boolean;
255
+ durationMs: number;
256
+ width: number;
257
+ height: number;
258
+ videoCodec: string;
259
+ fps?: number;
260
+ audioCodec?: string;
261
+ audioChannels?: number;
262
+ audioHz?: number;
263
+ };
264
+
265
+ type Box = [type: string, start: number, end: number];
266
+
267
+ export function sniffVideo(bytes: Uint8Array): VideoInfo | undefined {
268
+ const b = bytes;
269
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
270
+ const type = (at: number) => String.fromCharCode(b[at + 4]!, b[at + 5]!, b[at + 6]!, b[at + 7]!);
271
+ const boxes = (start: number, end: number): Box[] => {
272
+ const out: Box[] = [];
273
+ let at = start;
274
+ while (at + 8 <= end) {
275
+ let size = view.getUint32(at);
276
+ let header = 8;
277
+ if (size === 1) {
278
+ if (at + 16 > end) break;
279
+ size = Number(view.getBigUint64(at + 8));
280
+ header = 16;
281
+ } else if (size === 0) size = end - at;
282
+ if (size < header || at + size > end) break;
283
+ out.push([type(at), at + header, at + size]);
284
+ at += size;
285
+ }
286
+ return out;
287
+ };
288
+ const child = (parent: Box | undefined, name: string): Box | undefined => (parent ? boxes(parent[1], parent[2]).find(([t]) => t === name) : undefined);
289
+ const top = boxes(0, b.length);
290
+ if (top[0]?.[0] !== 'ftyp') return undefined;
291
+ const brand = String.fromCharCode(b[top[0][1]]!, b[top[0][1] + 1]!, b[top[0][1] + 2]!, b[top[0][1] + 3]!);
292
+ const moovAt = top.findIndex(([t]) => t === 'moov');
293
+ const mdatAt = top.findIndex(([t]) => t === 'mdat');
294
+ if (moovAt < 0) return undefined;
295
+ const moov = top[moovAt]!;
296
+ const mvhd = child(moov, 'mvhd');
297
+ if (!mvhd || mvhd[2] - mvhd[1] < 32) return undefined;
298
+ const mv = b[mvhd[1]]!;
299
+ const movieScale = mv === 1 ? view.getUint32(mvhd[1] + 20) : view.getUint32(mvhd[1] + 12);
300
+ const movieDuration = mv === 1 ? Number(view.getBigUint64(mvhd[1] + 24)) : view.getUint32(mvhd[1] + 16);
301
+ if (movieScale === 0) return undefined;
302
+ const info: VideoInfo = {
303
+ container: brand === 'qt ' ? 'mov' : 'mp4', moovFirst: mdatAt < 0 || moovAt < mdatAt,
304
+ durationMs: Math.round((movieDuration * 1000) / movieScale), width: 0, height: 0, videoCodec: '',
305
+ };
306
+ for (const trak of boxes(moov[1], moov[2]).filter(([t]) => t === 'trak')) {
307
+ const mdia = child(trak, 'mdia');
308
+ const hdlr = child(mdia, 'hdlr');
309
+ 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]!) : '';
310
+ const stbl = child(child(mdia, 'minf'), 'stbl');
311
+ const stsd = child(stbl, 'stsd');
312
+ // stsd: version/flags (4), entry count (4), then the first sample entry box
313
+ const entry = stsd && stsd[2] - stsd[1] >= 16 ? stsd[1] + 8 : undefined;
314
+ const codec = entry !== undefined ? type(entry) : '';
315
+ if (handler === 'vide' && info.videoCodec === '') {
316
+ info.videoCodec = codec;
317
+ const tkhd = child(trak, 'tkhd');
318
+ if (tkhd) {
319
+ const v = b[tkhd[1]]!;
320
+ const at = tkhd[1] + (v === 1 ? 88 : 76);
321
+ if (at + 8 <= tkhd[2]) { info.width = view.getUint32(at) >>> 16; info.height = view.getUint32(at + 4) >>> 16; }
322
+ }
323
+ const mdhd = child(mdia, 'mdhd');
324
+ const stts = child(stbl, 'stts');
325
+ if (mdhd && stts && stts[2] - stts[1] >= 8) {
326
+ const v = b[mdhd[1]]!;
327
+ const scale = v === 1 ? view.getUint32(mdhd[1] + 20) : view.getUint32(mdhd[1] + 12);
328
+ const duration = v === 1 ? Number(view.getBigUint64(mdhd[1] + 24)) : view.getUint32(mdhd[1] + 16);
329
+ const n = view.getUint32(stts[1] + 4);
330
+ let frames = 0;
331
+ for (let i = 0; i < n && stts[1] + 8 + i * 8 + 8 <= stts[2]; i += 1) frames += view.getUint32(stts[1] + 8 + i * 8);
332
+ if (scale > 0 && duration > 0 && frames > 0) info.fps = Math.round((frames * scale * 100) / duration) / 100;
333
+ }
334
+ } else if (handler === 'soun' && info.audioCodec === undefined) {
335
+ info.audioCodec = codec;
336
+ // AudioSampleEntry: 8 box header, 6 reserved, 2 data-ref, 8 reserved, channelcount(2),
337
+ // samplesize(2), pre_defined(2), reserved(2), samplerate(4, 16.16)
338
+ if (entry !== undefined && entry + 36 <= stsd![2]) {
339
+ info.audioChannels = view.getUint16(entry + 24);
340
+ info.audioHz = view.getUint32(entry + 32) >>> 16;
341
+ }
342
+ }
343
+ }
344
+ if (info.videoCodec === '' || info.width === 0 || info.height === 0) return undefined;
345
+ return info;
346
+ }
347
+
348
+ /** Why a file is not a publishable Reel, or undefined when it is — the spec above, in order. */
349
+ export function reelSpecViolation(info: VideoInfo | undefined, size: number, thumbOffset?: number): { subcode: number; reason: string } | undefined {
350
+ const bad = (reason: string) => ({ subcode: SUBCODE_UNSUPPORTED_VIDEO, reason });
351
+ if (!info) return bad('The file is not an MOV or MP4 (MPEG-4 Part 14) with a video track.');
352
+ if (!info.moovFirst) return bad('The moov atom is not at the front of the file.');
353
+ if (!['avc1', 'avc3', 'hvc1', 'hev1'].includes(info.videoCodec)) return bad(`Video codec ${info.videoCodec} is not HEVC or H264.`);
354
+ if (info.audioCodec !== undefined && info.audioCodec !== 'mp4a') return bad(`Audio codec ${info.audioCodec} is not AAC.`);
355
+ if (info.audioHz !== undefined && info.audioHz > MAX_AUDIO_HZ) return bad(`Audio sample rate ${info.audioHz} Hz is over 48 kHz.`);
356
+ if (info.audioChannels !== undefined && (info.audioChannels < 1 || info.audioChannels > 2)) return bad(`Audio has ${info.audioChannels} channels; 1 or 2 are accepted.`);
357
+ if (info.fps !== undefined && (info.fps < MIN_REEL_FPS || info.fps > MAX_REEL_FPS)) return bad(`Frame rate ${info.fps} FPS is outside 23-60 FPS.`);
358
+ if (info.width > MAX_REEL_COLUMNS) return bad(`${info.width} columns is over the 1920 maximum.`);
359
+ const aspect = info.width / info.height;
360
+ if (aspect < MIN_REEL_ASPECT || aspect > MAX_REEL_ASPECT) return bad(`Aspect ratio ${aspect.toFixed(3)}:1 is outside 0.01:1 to 10:1.`);
361
+ if (info.durationMs < MIN_REEL_MS || info.durationMs > MAX_REEL_MS) return bad(`Duration ${info.durationMs} ms is outside 3 seconds to 15 minutes.`);
362
+ if (size > MAX_REEL_BYTES) return bad(`File size ${size} bytes is over 300MB.`);
363
+ const bps = (size * 8 * 1000) / info.durationMs;
364
+ if (bps > MAX_REEL_BITS_PER_S) return bad(`Average bitrate ${Math.round(bps / 1000)} kbps is over the 25 Mbps video and 128 kbps audio maximum.`);
365
+ if (thumbOffset !== undefined && thumbOffset >= info.durationMs) {
366
+ 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}` };
367
+ }
368
+ return undefined;
369
+ }
370
+
371
+ /** An image's type and size from its header — JPEG or PNG (a seeded profile picture). */
372
+ export function sniffImage(bytes: Uint8Array): { mediaType: 'image/png' | 'image/jpeg'; width: number; height: number } | undefined {
373
+ const b = bytes;
374
+ const u16be = (i: number) => (b[i]! << 8) | b[i + 1]!;
375
+ const u32be = (i: number) => ((b[i]! << 24) >>> 0) + (b[i + 1]! << 16) + (b[i + 2]! << 8) + b[i + 3]!;
376
+ if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return { mediaType: 'image/png', width: u32be(16), height: u32be(20) };
377
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
378
+ let i = 2;
379
+ while (i + 9 < b.length) {
380
+ if (b[i] !== 0xff) { i += 1; continue; }
381
+ const marker = b[i + 1]!;
382
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { i += 2; continue; }
383
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { mediaType: 'image/jpeg', height: u16be(i + 5), width: u16be(i + 7) };
384
+ i += 2 + u16be(i + 2);
385
+ }
386
+ }
387
+ return undefined;
388
+ }
389
+
390
+ /**
391
+ * The thumbnail Meta cuts from a Reel at `thumb_offset` when no cover_url is given. The twin decodes
392
+ * no frames, so it serves a STAND-IN at the Reel's own aspect — a dark frame with a play glyph — and
393
+ * says so here (`instagram.media.frame_thumbnail`, a todo).
394
+ */
395
+ export function reelPosterSvg(width: number, height: number): string {
396
+ const w = width > 0 ? width : 1080;
397
+ const h = height > 0 ? height : 1920;
398
+ const r = Math.round(Math.min(w, h) * 0.09);
399
+ const cx = w / 2;
400
+ const cy = h / 2;
401
+ 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"/>`
402
+ + `<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>`;
403
+ }
@@ -0,0 +1,173 @@
1
+ // INSTAGRAM MIRROR UI — instagram.com's own view of the twin's state: a professional account's
2
+ // profile (its avatar, username, name, bio, and the posts / followers / following counts it holds),
3
+ // its Reels grid (9:16 tiles with a play glyph), and a Reel viewer (a full-height vertical player with
4
+ // the caption and username over it). A React/TSX app bundled by Bun that renders by consuming the
5
+ // twin's OWN Graph API on the same origin (GET /v26.0/me and GET /v26.0/{ig-user-id}/media) — the same
6
+ // routes any Instagram Platform client uses — so every screen is data-coupled to real twin state.
7
+ // Archetype A (passthrough), transcribed from the X and LinkedIn mirrors: one serving code path, so
8
+ // API↔UI parity cannot drift.
9
+ //
10
+ // SIGN-IN. instagram.com's login (username, then password) with the password replaced by the access
11
+ // token the twin issued through /_twin/tokens: the mirror is a client like any other and presents a
12
+ // registered token rather than bypassing the auth gate. The username is checked against the token's
13
+ // own account (`/me?fields=username`). Both are kept in the TAB'S sessionStorage.
14
+ //
15
+ // ROUTES are `#/…` hash routes, reached by LINKS (`<a href="#/…">`) — never by assigning location — so
16
+ // under a World's <base> the served shell's own link handler keeps them on the shell
17
+ // (world-core's mirror-shell.ts).
18
+ //
19
+ // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's own
20
+ // fetch adapter as its API backend and reads every byte of state back over the wire.
21
+ import { readFile } from 'node:fs/promises';
22
+ import { bundleClient, fileResponse, serveHttp } from '@volter/world-core';
23
+ import { createInstagramTwinFetch } from './instagram-server.ts';
24
+
25
+ const CLIENT_ENTRY = () => new URL('../client/instagram-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects a top-level relative import.meta.url
26
+ const CLIENT_CSS = () => new URL('../client/instagram-mirror.css', import.meta.url).pathname;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Pure, dependency-free render/format helpers (importable by the React client; Bun tree-shakes the
30
+ // server-only exports out of the browser bundle). A capability verify renders the mirror's OWN
31
+ // functions over twin state.
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export type IgRow = Record<string, any>;
35
+
36
+ /** The version the mirror's reads pin — the twin's newest. */
37
+ export const MIRROR_VERSION = 'v26.0';
38
+
39
+ /** The account fields the profile header reads. */
40
+ export const PROFILE_FIELDS = 'id,username,name,biography,website,followers_count,follows_count,media_count,profile_picture_url';
41
+ /** The media fields the grid and the viewer read. */
42
+ export 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';
43
+
44
+ /** instagram.com's count: "1,234" below ten thousand, then "12.5K", "1.2M" (one decimal, trailing .0 dropped). */
45
+ export function compactCount(n: unknown): string {
46
+ const v = typeof n === 'number' && Number.isFinite(n) ? n : 0;
47
+ const one = (x: number, unit: string) => `${(Math.floor(x * 10) / 10).toFixed(1).replace(/\.0$/, '')}${unit}`;
48
+ if (v >= 1_000_000) return one(v / 1_000_000, 'M');
49
+ if (v >= 10_000) return one(v / 1_000, 'K');
50
+ return v.toLocaleString('en-US');
51
+ }
52
+
53
+ /** instagram.com's age of a post: "now", "5m", "3h", "2d", "3w", then the date ("March 4" / "March 4, 2025"). */
54
+ export function timeAgo(timestamp: unknown, nowMs: number): string {
55
+ const ms = Date.parse(String(timestamp ?? '').replace(/([+-]\d\d)(\d\d)$/, '$1:$2'));
56
+ if (!Number.isFinite(ms)) return '';
57
+ const s = Math.max(0, Math.floor((nowMs - ms) / 1000));
58
+ if (s < 60) return 'now';
59
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
60
+ if (s < 86_400) return `${Math.floor(s / 3600)}h`;
61
+ if (s < 7 * 86_400) return `${Math.floor(s / 86_400)}d`;
62
+ if (s < 30 * 86_400) return `${Math.floor(s / (7 * 86_400))}w`;
63
+ const d = new Date(ms);
64
+ const month = d.toLocaleString('en-US', { month: 'long', timeZone: 'UTC' });
65
+ return d.getUTCFullYear() === new Date(nowMs).getUTCFullYear() ? `${month} ${d.getUTCDate()}` : `${month} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
66
+ }
67
+
68
+ export type Segment = { kind: 'text' | 'hashtag' | 'mention'; value: string };
69
+
70
+ /** A caption as instagram.com draws it: `#tag` and `@user` as links, everything else text. */
71
+ export function captionSegments(caption: unknown): Segment[] {
72
+ const source = typeof caption === 'string' ? caption : '';
73
+ const out: Segment[] = [];
74
+ let last = 0;
75
+ for (const m of source.matchAll(/(^|[^\w&])([#@])([\p{L}\p{N}_.]*[\p{L}\p{N}_])/gu)) {
76
+ const at = (m.index ?? 0) + m[1]!.length;
77
+ if (at > last) out.push({ kind: 'text', value: source.slice(last, at) });
78
+ out.push({ kind: m[2] === '#' ? 'hashtag' : 'mention', value: `${m[2]}${m[3]}` });
79
+ last = at + 1 + m[3]!.length;
80
+ }
81
+ if (last < source.length) out.push({ kind: 'text', value: source.slice(last) });
82
+ return out;
83
+ }
84
+
85
+ /** A profile as the mirror draws it, from the account read — every field from that answer, nothing else. */
86
+ export type MirrorProfile = { id: string; username: string; name?: string; biography?: string; website?: string; avatarUrl?: string; posts?: number; followers?: number; following?: number };
87
+
88
+ export function profileFromRead(me: IgRow): MirrorProfile {
89
+ return {
90
+ id: String(me.id), username: String(me.username),
91
+ ...(typeof me.name === 'string' && me.name !== '' ? { name: me.name } : {}),
92
+ ...(typeof me.biography === 'string' && me.biography !== '' ? { biography: me.biography } : {}),
93
+ ...(typeof me.website === 'string' && me.website !== '' ? { website: me.website } : {}),
94
+ ...(typeof me.profile_picture_url === 'string' ? { avatarUrl: me.profile_picture_url } : {}),
95
+ ...(typeof me.media_count === 'number' ? { posts: me.media_count } : {}),
96
+ ...(typeof me.followers_count === 'number' ? { followers: me.followers_count } : {}),
97
+ ...(typeof me.follows_count === 'number' ? { following: me.follows_count } : {}),
98
+ };
99
+ }
100
+
101
+ /** Whether a media is a Reel (the Content Publishing guide: request media_product_type, since a
102
+ * published Reel's media_type reads VIDEO). */
103
+ export function isReel(m: IgRow): boolean {
104
+ return m.media_product_type === 'REELS';
105
+ }
106
+
107
+ /** What a profile's tab shows: the Reels tab every Reel; the Posts tab only those shared to the feed
108
+ * (IG Media: `is_shared_to_feed` false "indicates the reel can only appear in the Reels tab"). */
109
+ export function tabReels(reels: IgRow[], tab: 'posts' | 'reels'): IgRow[] {
110
+ return tab === 'reels' ? reels : reels.filter((r) => r.is_shared_to_feed !== false);
111
+ }
112
+
113
+ /** The mirror's own route for a Reel: instagram.com's `/reel/<shortcode>/`. */
114
+ export function reelRoute(shortcode: string): string {
115
+ return `#/reel/${encodeURIComponent(shortcode)}/`;
116
+ }
117
+
118
+ /** The read the grid and the viewer draw: the account's media, newest first. */
119
+ export function mediaQuery(userId: string, after?: string): string {
120
+ return `/${MIRROR_VERSION}/${userId}/media?fields=${REEL_FIELDS}&limit=24${after ? `&after=${encodeURIComponent(after)}` : ''}`;
121
+ }
122
+
123
+ const APP_SHELL = `<!doctype html>
124
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
125
+ <base href="/"><title>Instagram</title><link rel="stylesheet" href="assets/styles.css"></head>
126
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
127
+
128
+ let clientBundle: Promise<string> | null = null;
129
+ /** Build the React/TSX mirror client to browser JS; memoized at module scope, so a pack on its own
130
+ * does exactly one `Bun.build`. */
131
+ export function buildInstagramMirrorClient(): Promise<string> {
132
+ if (!clientBundle) {
133
+ clientBundle = bundleClient(CLIENT_ENTRY()).catch((error) => { clientBundle = null; throw error; });
134
+ }
135
+ return clientBundle;
136
+ }
137
+
138
+ /** Serve the Instagram mirror UI (React app) + its backing Graph API on one origin. */
139
+ export async function createInstagramMirrorServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; url: string; stop: () => void }> {
140
+ const twin = createInstagramTwinFetch(options);
141
+ const server = await serveHttp({
142
+ hostname: '127.0.0.1',
143
+ port: options.port ?? 0,
144
+ idleTimeout: 60,
145
+ async fetch(request) {
146
+ const url = new URL(request.url);
147
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
148
+ try { return new Response(await buildInstagramMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
149
+ catch (error) { return new Response(String(error), { status: 500 }); }
150
+ }
151
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
152
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
153
+ }
154
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
155
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
156
+ }
157
+ // Everything else -> the twin's OWN FETCH ADAPTER, the same closure createInstagramTwinServer serves.
158
+ return twin(request);
159
+ },
160
+ });
161
+ const port = server.port ?? options.port ?? 0;
162
+ return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
163
+ }
164
+
165
+ /** The app-shell HTML (pure). */
166
+ export function instagramMirrorHtml(): string {
167
+ return APP_SHELL;
168
+ }
169
+
170
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
171
+ export function instagramMirrorStyles(): Promise<string> {
172
+ return readFile(CLIENT_CSS(), 'utf8');
173
+ }