@jant/core 0.5.3 → 0.6.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 (95) hide show
  1. package/bin/commands/telegram/register-webhooks.js +93 -0
  2. package/dist/{app-C481ssbr.js → app-BIkkbVQk.js} +2252 -383
  3. package/dist/app-Bcr5_wZI.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-Bo7sKkAQ.js +274 -0
  6. package/dist/client/_assets/client-QHRvzZwk.css +2 -0
  7. package/dist/client/_assets/{client-auth-CfBiCAB7.js → client-auth-D1jDQgbH.js} +49 -49
  8. package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
  9. package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
  10. package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
  11. package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
  12. package/dist/index.js +4 -4
  13. package/dist/node.js +61 -5
  14. package/package.json +2 -1
  15. package/src/__tests__/helpers/app.ts +15 -2
  16. package/src/app.tsx +3 -0
  17. package/src/client/components/jant-compose-editor.ts +72 -0
  18. package/src/client/thread-context.ts +146 -2
  19. package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
  20. package/src/client/tiptap/bubble-menu.ts +1 -16
  21. package/src/client/tiptap/extensions.ts +2 -6
  22. package/src/client/tiptap/link-toolbar.ts +0 -21
  23. package/src/client/tiptap/paste-media.ts +49 -33
  24. package/src/client/tiptap/toolbar-mode.ts +0 -43
  25. package/src/client/video-processor.ts +9 -0
  26. package/src/db/migrations/0022_old_gressill.sql +24 -0
  27. package/src/db/migrations/0023_broad_terror.sql +20 -0
  28. package/src/db/migrations/0024_red_the_twelve.sql +3 -0
  29. package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
  30. package/src/db/migrations/meta/0022_snapshot.json +2267 -0
  31. package/src/db/migrations/meta/0023_snapshot.json +2396 -0
  32. package/src/db/migrations/meta/0024_snapshot.json +2417 -0
  33. package/src/db/migrations/meta/0025_snapshot.json +2424 -0
  34. package/src/db/migrations/meta/_journal.json +28 -0
  35. package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
  36. package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
  37. package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
  38. package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
  39. package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
  40. package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
  41. package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
  42. package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
  43. package/src/db/migrations/pg/meta/_journal.json +28 -0
  44. package/src/db/pg/schema.ts +82 -0
  45. package/src/db/schema.ts +90 -0
  46. package/src/i18n/coverage.generated.ts +2 -2
  47. package/src/i18n/locales/public/en.po +8 -0
  48. package/src/i18n/locales/public/zh-Hans.po +8 -0
  49. package/src/i18n/locales/public/zh-Hant.po +8 -0
  50. package/src/i18n/locales/settings/en.po +135 -0
  51. package/src/i18n/locales/settings/en.ts +1 -1
  52. package/src/i18n/locales/settings/zh-Hans.po +136 -1
  53. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  54. package/src/i18n/locales/settings/zh-Hant.po +136 -1
  55. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  56. package/src/lib/__tests__/image-dimensions.test.ts +314 -0
  57. package/src/lib/__tests__/mp4-track-flags.test.ts +117 -0
  58. package/src/lib/__tests__/telegram-entities.test.ts +180 -0
  59. package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
  60. package/src/lib/env.ts +45 -0
  61. package/src/lib/ids.ts +3 -0
  62. package/src/lib/image-dimensions.ts +258 -0
  63. package/src/lib/mp4-track-flags.ts +71 -0
  64. package/src/lib/telegram-entities.ts +240 -0
  65. package/src/lib/telegram-pool-webhooks.ts +86 -0
  66. package/src/lib/telegram-settings-status.tsx +109 -0
  67. package/src/lib/telegram.ts +363 -0
  68. package/src/node/runtime.ts +6 -0
  69. package/src/routes/api/__tests__/telegram.test.ts +612 -0
  70. package/src/routes/api/telegram.ts +782 -0
  71. package/src/routes/api/upload-multipart.ts +34 -12
  72. package/src/routes/api/upload.ts +23 -2
  73. package/src/routes/dash/settings.tsx +131 -1
  74. package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
  75. package/src/routes/pages/page.tsx +3 -2
  76. package/src/runtime/cloudflare.ts +20 -9
  77. package/src/runtime/node.ts +20 -9
  78. package/src/runtime/site.ts +2 -1
  79. package/src/services/__tests__/telegram.test.ts +148 -0
  80. package/src/services/index.ts +9 -0
  81. package/src/services/telegram.ts +613 -0
  82. package/src/services/upload-session.ts +39 -12
  83. package/src/styles/tokens.css +1 -0
  84. package/src/styles/ui.css +134 -38
  85. package/src/types/app-context.ts +6 -0
  86. package/src/types/bindings.ts +3 -0
  87. package/src/types/config.ts +40 -0
  88. package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
  89. package/src/ui/dash/settings/TelegramContent.tsx +549 -0
  90. package/src/ui/feed/ThreadPreview.tsx +91 -38
  91. package/src/ui/feed/__tests__/thread-preview.test.ts +67 -5
  92. package/src/ui/pages/PostPage.tsx +78 -15
  93. package/dist/app-BgMwEN-M.js +0 -6
  94. package/dist/client/_assets/client-CJQYvkEx.js +0 -274
  95. package/dist/client/_assets/client-CQvi1Buw.css +0 -2
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { registerTelegramPoolWebhooks } from "../telegram-pool-webhooks.js";
3
+
4
+ const BOT_ID = "111111";
5
+ const TOKEN = `${BOT_ID}:AA-test-token`;
6
+ const BASE_URL = "https://cloud.example";
7
+ const SECRET = "shared-webhook-secret";
8
+ const EXPECTED_URL = `${BASE_URL}/api/telegram/webhook/${BOT_ID}`;
9
+
10
+ interface Call {
11
+ method: string;
12
+ body: Record<string, unknown>;
13
+ }
14
+
15
+ /** Mocks the Telegram API; `currentWebhookUrl` is what getWebhookInfo reports. */
16
+ function mockFetch(currentWebhookUrl: string): Call[] {
17
+ const calls: Call[] = [];
18
+ vi.stubGlobal(
19
+ "fetch",
20
+ vi.fn(async (url: string, init?: { body?: unknown }) => {
21
+ const method = String(url).split("/").pop() ?? "";
22
+ const body = init?.body ? JSON.parse(String(init.body)) : {};
23
+ calls.push({ method, body });
24
+ const result =
25
+ method === "getWebhookInfo" ? { url: currentWebhookUrl } : true;
26
+ return new Response(JSON.stringify({ ok: true, result }), {
27
+ headers: { "content-type": "application/json" },
28
+ });
29
+ }),
30
+ );
31
+ return calls;
32
+ }
33
+
34
+ describe("registerTelegramPoolWebhooks", () => {
35
+ afterEach(() => {
36
+ vi.unstubAllGlobals();
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it("does nothing when no pool is configured", async () => {
41
+ const calls = mockFetch("");
42
+ await registerTelegramPoolWebhooks({});
43
+ expect(calls).toHaveLength(0);
44
+ });
45
+
46
+ it("does nothing when not a hosted deployment", async () => {
47
+ const calls = mockFetch("");
48
+ await registerTelegramPoolWebhooks({
49
+ TELEGRAM_BOT_TOKENS: TOKEN,
50
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
51
+ });
52
+ expect(calls).toHaveLength(0);
53
+ });
54
+
55
+ it("skips registration when the shared secret is missing", async () => {
56
+ const calls = mockFetch("");
57
+ vi.spyOn(console, "error").mockImplementation(() => {});
58
+ await registerTelegramPoolWebhooks({
59
+ TELEGRAM_BOT_TOKENS: TOKEN,
60
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
61
+ });
62
+ expect(calls).toHaveLength(0);
63
+ });
64
+
65
+ it("registers a webhook that is not yet set", async () => {
66
+ const calls = mockFetch("");
67
+ vi.spyOn(console, "log").mockImplementation(() => {});
68
+ await registerTelegramPoolWebhooks({
69
+ TELEGRAM_BOT_TOKENS: TOKEN,
70
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
71
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
72
+ });
73
+ expect(calls.map((c) => c.method)).toEqual([
74
+ "getWebhookInfo",
75
+ "setWebhook",
76
+ "setMyCommands",
77
+ ]);
78
+ expect(calls[1]?.body).toMatchObject({
79
+ url: EXPECTED_URL,
80
+ secret_token: SECRET,
81
+ });
82
+ });
83
+
84
+ it("skips setWebhook when already pointed at the right URL but still syncs commands", async () => {
85
+ const calls = mockFetch(EXPECTED_URL);
86
+ await registerTelegramPoolWebhooks({
87
+ TELEGRAM_BOT_TOKENS: TOKEN,
88
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
89
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
90
+ });
91
+ expect(calls.map((c) => c.method)).toEqual([
92
+ "getWebhookInfo",
93
+ "setMyCommands",
94
+ ]);
95
+ });
96
+
97
+ it("re-registers when the webhook points elsewhere", async () => {
98
+ const calls = mockFetch(
99
+ "https://stale.example/api/telegram/webhook/111111",
100
+ );
101
+ vi.spyOn(console, "log").mockImplementation(() => {});
102
+ await registerTelegramPoolWebhooks({
103
+ TELEGRAM_BOT_TOKENS: TOKEN,
104
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
105
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
106
+ });
107
+ expect(calls.map((c) => c.method)).toEqual([
108
+ "getWebhookInfo",
109
+ "setWebhook",
110
+ "setMyCommands",
111
+ ]);
112
+ });
113
+
114
+ it("registers /start in the command list so Telegram autocomplete works", async () => {
115
+ const calls = mockFetch("");
116
+ vi.spyOn(console, "log").mockImplementation(() => {});
117
+ await registerTelegramPoolWebhooks({
118
+ TELEGRAM_BOT_TOKENS: TOKEN,
119
+ TELEGRAM_WEBHOOK_SECRET: SECRET,
120
+ HOSTED_CONTROL_PLANE_BASE_URL: BASE_URL,
121
+ });
122
+ const setCommands = calls.find((c) => c.method === "setMyCommands");
123
+ expect(setCommands?.body).toMatchObject({
124
+ commands: [{ command: "start" }],
125
+ });
126
+ });
127
+ });
package/src/lib/env.ts CHANGED
@@ -301,6 +301,51 @@ export function shouldTrustProxy(env: EnvSource): boolean {
301
301
  return getEnvString(env, "TRUST_PROXY") === "true";
302
302
  }
303
303
 
304
+ /** A single platform-managed Telegram bot from `TELEGRAM_BOT_TOKENS`. */
305
+ export interface TelegramPoolBot {
306
+ /** Numeric bot id — the part before `:` in the token. */
307
+ botId: string;
308
+ /** Full `<bot_id>:<secret>` bot token. */
309
+ token: string;
310
+ }
311
+
312
+ /**
313
+ * Parses the platform-managed Telegram bot pool from `TELEGRAM_BOT_TOKENS`.
314
+ *
315
+ * The env value is a comma-separated list of `<bot_id>:<secret>` tokens. The
316
+ * first entry is the public-facing bot (`bot1`); the rest are surfaced only
317
+ * contextually when a binding code reaches an already-bound bot slot.
318
+ *
319
+ * @param env - Runtime environment bindings
320
+ * @returns Parsed pool bots in declared order; empty when unset/invalid
321
+ * @example
322
+ * getTelegramBotPool({ TELEGRAM_BOT_TOKENS: "111:aaa,222:bbb" });
323
+ */
324
+ export function getTelegramBotPool(env: EnvSource): TelegramPoolBot[] {
325
+ const raw = getEnvString(env, "TELEGRAM_BOT_TOKENS");
326
+ if (!raw) {
327
+ return [];
328
+ }
329
+
330
+ return raw
331
+ .split(",")
332
+ .map((entry) => entry.trim())
333
+ .filter((entry) => entry.length > 0)
334
+ .map((token) => {
335
+ const botId = token.split(":")[0]?.trim() ?? "";
336
+ return { botId, token };
337
+ })
338
+ .filter((bot) => bot.botId.length > 0);
339
+ }
340
+
341
+ /**
342
+ * Returns the shared `secret_token` used when registering every pool bot's
343
+ * webhook. Only meaningful alongside `TELEGRAM_BOT_TOKENS`.
344
+ */
345
+ export function getTelegramWebhookSecret(env: EnvSource): string | undefined {
346
+ return getEnvString(env, "TELEGRAM_WEBHOOK_SECRET");
347
+ }
348
+
304
349
  export function shouldUseSecureCookies(
305
350
  env: EnvSource,
306
351
  publicRequestUrl: string,
package/src/lib/ids.ts CHANGED
@@ -16,6 +16,9 @@ export const ID_PREFIX = {
16
16
  session: "ses",
17
17
  account: "acc",
18
18
  verification: "vrf",
19
+ telegramBinding: "tgb",
20
+ telegramBindingCode: "tgc",
21
+ telegramMediaGroupItem: "tmg",
19
22
  } as const;
20
23
 
21
24
  export type IdPrefix = (typeof ID_PREFIX)[keyof typeof ID_PREFIX];
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Parse intrinsic pixel dimensions from a raw image header buffer.
3
+ *
4
+ * Used as a server-side fallback when an upload client does not provide
5
+ * `width` / `height`. Only the file header is needed — typically the first
6
+ * few hundred bytes for PNG/JPEG/GIF/WebP, and up to ~64 KB for AVIF where
7
+ * the `ispe` property can be nested inside a larger `meta` box.
8
+ *
9
+ * Returns `null` when the bytes are too short, the format is unsupported,
10
+ * or the header is malformed.
11
+ *
12
+ * @param mimeType MIME type the upload was declared as (e.g. `image/png`).
13
+ * @param bytes Bytes from the start of the file. Pass at least
14
+ * {@link IMAGE_DIMENSION_PEEK_BYTES} for AVIF reliability; smaller is fine
15
+ * for other formats.
16
+ */
17
+ export function parseImageDimensions(
18
+ mimeType: string,
19
+ bytes: Uint8Array,
20
+ ): { width: number; height: number } | null {
21
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
22
+ switch (mimeType) {
23
+ case "image/png":
24
+ return parsePng(view);
25
+ case "image/jpeg":
26
+ case "image/jpg":
27
+ return parseJpeg(view);
28
+ case "image/gif":
29
+ return parseGif(view);
30
+ case "image/webp":
31
+ return parseWebp(view);
32
+ case "image/avif":
33
+ return parseIsoBmff(view, new Set(["avif", "avis"]));
34
+ default:
35
+ return null;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Recommended number of header bytes to feed into {@link parseImageDimensions}.
41
+ *
42
+ * Covers AVIF files with EXIF/ICC properties placed before the `ispe` box.
43
+ * Smaller formats only inspect the first ~30 bytes.
44
+ */
45
+ export const IMAGE_DIMENSION_PEEK_BYTES = 64 * 1024;
46
+
47
+ function readChars(view: DataView, offset: number, length: number): string {
48
+ let out = "";
49
+ for (let i = 0; i < length; i += 1) {
50
+ out += String.fromCharCode(view.getUint8(offset + i));
51
+ }
52
+ return out;
53
+ }
54
+
55
+ function parsePng(view: DataView): { width: number; height: number } | null {
56
+ if (view.byteLength < 24) return null;
57
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
58
+ for (let i = 0; i < 8; i += 1) {
59
+ if (view.getUint8(i) !== signature[i]) return null;
60
+ }
61
+ const width = view.getUint32(16, false);
62
+ const height = view.getUint32(20, false);
63
+ if (width === 0 || height === 0) return null;
64
+ return { width, height };
65
+ }
66
+
67
+ function parseGif(view: DataView): { width: number; height: number } | null {
68
+ if (view.byteLength < 10) return null;
69
+ if (
70
+ view.getUint8(0) !== 0x47 ||
71
+ view.getUint8(1) !== 0x49 ||
72
+ view.getUint8(2) !== 0x46
73
+ ) {
74
+ return null;
75
+ }
76
+ const width = view.getUint16(6, true);
77
+ const height = view.getUint16(8, true);
78
+ if (width === 0 || height === 0) return null;
79
+ return { width, height };
80
+ }
81
+
82
+ function parseWebp(view: DataView): { width: number; height: number } | null {
83
+ if (view.byteLength < 16) return null;
84
+ if (readChars(view, 0, 4) !== "RIFF") return null;
85
+ if (readChars(view, 8, 4) !== "WEBP") return null;
86
+ const chunk = readChars(view, 12, 4);
87
+
88
+ if (chunk === "VP8 ") {
89
+ if (view.byteLength < 30) return null;
90
+ if (
91
+ view.getUint8(23) !== 0x9d ||
92
+ view.getUint8(24) !== 0x01 ||
93
+ view.getUint8(25) !== 0x2a
94
+ ) {
95
+ return null;
96
+ }
97
+ const width = view.getUint16(26, true) & 0x3fff;
98
+ const height = view.getUint16(28, true) & 0x3fff;
99
+ if (width === 0 || height === 0) return null;
100
+ return { width, height };
101
+ }
102
+
103
+ if (chunk === "VP8L") {
104
+ if (view.byteLength < 25) return null;
105
+ if (view.getUint8(20) !== 0x2f) return null;
106
+ const b0 = view.getUint8(21);
107
+ const b1 = view.getUint8(22);
108
+ const b2 = view.getUint8(23);
109
+ const b3 = view.getUint8(24);
110
+ const width = 1 + (((b1 & 0x3f) << 8) | b0);
111
+ const height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6));
112
+ return { width, height };
113
+ }
114
+
115
+ if (chunk === "VP8X") {
116
+ if (view.byteLength < 30) return null;
117
+ const width =
118
+ 1 +
119
+ (view.getUint8(24) |
120
+ (view.getUint8(25) << 8) |
121
+ (view.getUint8(26) << 16));
122
+ const height =
123
+ 1 +
124
+ (view.getUint8(27) |
125
+ (view.getUint8(28) << 8) |
126
+ (view.getUint8(29) << 16));
127
+ return { width, height };
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ function parseJpeg(view: DataView): { width: number; height: number } | null {
134
+ const length = view.byteLength;
135
+ if (length < 4) return null;
136
+ if (view.getUint8(0) !== 0xff || view.getUint8(1) !== 0xd8) return null;
137
+
138
+ let i = 2;
139
+ while (i < length) {
140
+ while (i < length && view.getUint8(i) !== 0xff) i += 1;
141
+ while (i < length && view.getUint8(i) === 0xff) i += 1;
142
+ if (i >= length) return null;
143
+ const marker = view.getUint8(i);
144
+ i += 1;
145
+
146
+ // Standalone markers without a payload length: 0x00 (escaped FF),
147
+ // 0x01 (TEM), 0xD0–0xD9 (RSTn / SOI / EOI).
148
+ if (marker === 0x00 || marker === 0x01) continue;
149
+ if (marker >= 0xd0 && marker <= 0xd9) continue;
150
+
151
+ if (i + 2 > length) return null;
152
+ const segLen = view.getUint16(i, false);
153
+ if (segLen < 2) return null;
154
+
155
+ const isStartOfFrame =
156
+ (marker >= 0xc0 && marker <= 0xc3) ||
157
+ (marker >= 0xc5 && marker <= 0xc7) ||
158
+ (marker >= 0xc9 && marker <= 0xcb) ||
159
+ (marker >= 0xcd && marker <= 0xcf);
160
+
161
+ if (isStartOfFrame) {
162
+ // SOF payload: length(2) precision(1) height(2) width(2) components(1)
163
+ if (i + 7 > length) return null;
164
+ const height = view.getUint16(i + 3, false);
165
+ const width = view.getUint16(i + 5, false);
166
+ if (width === 0 || height === 0) return null;
167
+ return { width, height };
168
+ }
169
+
170
+ i += segLen;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ interface IsoBox {
176
+ size: number;
177
+ type: string;
178
+ payloadOffset: number;
179
+ end: number;
180
+ }
181
+
182
+ function readBox(view: DataView, pos: number): IsoBox | null {
183
+ if (pos + 8 > view.byteLength) return null;
184
+ let size = view.getUint32(pos, false);
185
+ const type = readChars(view, pos + 4, 4);
186
+ if (size === 1) {
187
+ // 64-bit largesize: we don't bother — the dimension boxes we need fit in
188
+ // the first 64 KB of any sane image.
189
+ return null;
190
+ }
191
+ if (size === 0) {
192
+ size = view.byteLength - pos;
193
+ }
194
+ if (size < 8) return null;
195
+ return { size, type, payloadOffset: pos + 8, end: pos + size };
196
+ }
197
+
198
+ function findChild(
199
+ view: DataView,
200
+ start: number,
201
+ end: number,
202
+ type: string,
203
+ ): IsoBox | null {
204
+ let pos = start;
205
+ while (pos < end) {
206
+ const box = readBox(view, pos);
207
+ if (!box) return null;
208
+ if (box.type === type) return box;
209
+ pos = box.end;
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function parseIsoBmff(
215
+ view: DataView,
216
+ acceptedBrands: Set<string>,
217
+ ): { width: number; height: number } | null {
218
+ const ftyp = readBox(view, 0);
219
+ if (!ftyp || ftyp.type !== "ftyp") return null;
220
+
221
+ // major_brand at payloadOffset, minor_version at +4, compatible_brands from +8
222
+ let isAccepted = false;
223
+ if (ftyp.payloadOffset + 4 <= ftyp.end) {
224
+ isAccepted = acceptedBrands.has(readChars(view, ftyp.payloadOffset, 4));
225
+ }
226
+ for (
227
+ let q = ftyp.payloadOffset + 8;
228
+ !isAccepted && q + 4 <= ftyp.end;
229
+ q += 4
230
+ ) {
231
+ if (acceptedBrands.has(readChars(view, q, 4))) {
232
+ isAccepted = true;
233
+ }
234
+ }
235
+ if (!isAccepted) return null;
236
+
237
+ const meta = findChild(view, ftyp.end, view.byteLength, "meta");
238
+ if (!meta) return null;
239
+
240
+ // meta is a FullBox: skip the 4-byte version/flags before its children.
241
+ const iprp = findChild(view, meta.payloadOffset + 4, meta.end, "iprp");
242
+ if (!iprp) return null;
243
+
244
+ const ipco = findChild(view, iprp.payloadOffset, iprp.end, "ipco");
245
+ if (!ipco) return null;
246
+
247
+ // The first ispe inside ipco describes the primary image.
248
+ const ispe = findChild(view, ipco.payloadOffset, ipco.end, "ispe");
249
+ if (!ispe) return null;
250
+
251
+ // ispe is a FullBox: version(1) flags(3) width(4) height(4)
252
+ const widthOffset = ispe.payloadOffset + 4;
253
+ if (widthOffset + 8 > view.byteLength) return null;
254
+ const width = view.getUint32(widthOffset, false);
255
+ const height = view.getUint32(widthOffset + 4, false);
256
+ if (width === 0 || height === 0) return null;
257
+ return { width, height };
258
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * In-place patching of MP4 track header (`tkhd`) flags.
3
+ *
4
+ * Mediabunny's MP4 muxer writes a non-zero `alternate_group` into every
5
+ * `tkhd` box (video → 1, audio → 2). Per ISO-BMFF, a non-zero
6
+ * `alternate_group` marks a track as one of several mutually exclusive
7
+ * alternates. Safari's native `<video>` player reacts to this by never
8
+ * auto-hiding the control bar during playback — the controls stay pinned.
9
+ * ffmpeg writes 0 here; matching that restores normal control-bar behavior.
10
+ */
11
+
12
+ /** ISO-BMFF container boxes that hold a `tkhd` somewhere below them. */
13
+ const CONTAINER_TYPES = new Set(["moov", "trak"]);
14
+
15
+ /**
16
+ * Byte offset of the `alternate_group` field within a `tkhd` box, measured
17
+ * from the start of the box. The field sits after the box header, the
18
+ * version/flags word, the (version-sized) time/duration fields, the two
19
+ * reserved words, and the 2-byte `layer` field.
20
+ */
21
+ function alternateGroupOffset(version: number): number {
22
+ // header(8) + version/flags(4) + variable middle + reserved(8) + layer(2):
23
+ // v0: creation(4) modification(4) trackID(4) reserved(4) duration(4) = 20
24
+ // v1: creation(8) modification(8) trackID(4) reserved(4) duration(8) = 32
25
+ return 8 + 4 + (version === 1 ? 32 : 20) + 8 + 2;
26
+ }
27
+
28
+ /**
29
+ * Zero the `alternate_group` field of every `tkhd` box in an MP4 buffer,
30
+ * operating in place. Safe to call on any ISO-BMFF file; boxes without a
31
+ * `tkhd` are left untouched.
32
+ *
33
+ * @param buffer - The MP4 file bytes. Mutated in place.
34
+ * @example
35
+ * zeroTrackAlternateGroups(mediabunnyOutput);
36
+ */
37
+ export function zeroTrackAlternateGroups(buffer: ArrayBuffer): void {
38
+ const view = new DataView(buffer);
39
+
40
+ const walk = (start: number, end: number): void => {
41
+ let pos = start;
42
+ while (pos + 8 <= end) {
43
+ let size = view.getUint32(pos);
44
+ const type = String.fromCharCode(
45
+ view.getUint8(pos + 4),
46
+ view.getUint8(pos + 5),
47
+ view.getUint8(pos + 6),
48
+ view.getUint8(pos + 7),
49
+ );
50
+ if (size === 0) size = end - pos;
51
+ if (size < 8 || pos + size > end) break;
52
+
53
+ if (type === "tkhd") {
54
+ const version = view.getUint8(pos + 8);
55
+ const fieldOffset = pos + alternateGroupOffset(version);
56
+ if (
57
+ fieldOffset + 2 <= pos + size &&
58
+ view.getUint16(fieldOffset) !== 0
59
+ ) {
60
+ view.setUint16(fieldOffset, 0);
61
+ }
62
+ } else if (CONTAINER_TYPES.has(type)) {
63
+ walk(pos + 8, pos + size);
64
+ }
65
+
66
+ pos += size;
67
+ }
68
+ };
69
+
70
+ walk(0, buffer.byteLength);
71
+ }