@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.
- package/bin/commands/telegram/register-webhooks.js +93 -0
- package/dist/{app-C481ssbr.js → app-BIkkbVQk.js} +2252 -383
- package/dist/app-Bcr5_wZI.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-Bo7sKkAQ.js +274 -0
- package/dist/client/_assets/client-QHRvzZwk.css +2 -0
- package/dist/client/_assets/{client-auth-CfBiCAB7.js → client-auth-D1jDQgbH.js} +49 -49
- package/dist/{env-CgaH9Mut.js → env-C7e2Nlnt.js} +30 -1
- package/dist/{export-CR9Megtb.js → export-Bbn86HmS.js} +1 -1
- package/dist/{github-sync-DYZq9rQp.js → github-sync-CBQPRZ8H.js} +1 -1
- package/dist/{github-sync-8Vv06aCr.js → github-sync-dXsiZa_e.js} +2 -2
- package/dist/index.js +4 -4
- package/dist/node.js +61 -5
- package/package.json +2 -1
- package/src/__tests__/helpers/app.ts +15 -2
- package/src/app.tsx +3 -0
- package/src/client/components/jant-compose-editor.ts +72 -0
- package/src/client/thread-context.ts +146 -2
- package/src/client/tiptap/__tests__/link-toolbar.test.ts +1 -1
- package/src/client/tiptap/bubble-menu.ts +1 -16
- package/src/client/tiptap/extensions.ts +2 -6
- package/src/client/tiptap/link-toolbar.ts +0 -21
- package/src/client/tiptap/paste-media.ts +49 -33
- package/src/client/tiptap/toolbar-mode.ts +0 -43
- package/src/client/video-processor.ts +9 -0
- package/src/db/migrations/0022_old_gressill.sql +24 -0
- package/src/db/migrations/0023_broad_terror.sql +20 -0
- package/src/db/migrations/0024_red_the_twelve.sql +3 -0
- package/src/db/migrations/0025_exotic_wendell_rand.sql +1 -0
- package/src/db/migrations/meta/0022_snapshot.json +2267 -0
- package/src/db/migrations/meta/0023_snapshot.json +2396 -0
- package/src/db/migrations/meta/0024_snapshot.json +2417 -0
- package/src/db/migrations/meta/0025_snapshot.json +2424 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0020_bizarre_smasher.sql +24 -0
- package/src/db/migrations/pg/0021_sharp_puppet_master.sql +20 -0
- package/src/db/migrations/pg/0022_blushing_blue_shield.sql +3 -0
- package/src/db/migrations/pg/0023_organic_zemo.sql +1 -0
- package/src/db/migrations/pg/meta/0020_snapshot.json +2904 -0
- package/src/db/migrations/pg/meta/0021_snapshot.json +3060 -0
- package/src/db/migrations/pg/meta/0022_snapshot.json +3078 -0
- package/src/db/migrations/pg/meta/0023_snapshot.json +3084 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +82 -0
- package/src/db/schema.ts +90 -0
- package/src/i18n/coverage.generated.ts +2 -2
- package/src/i18n/locales/public/en.po +8 -0
- package/src/i18n/locales/public/zh-Hans.po +8 -0
- package/src/i18n/locales/public/zh-Hant.po +8 -0
- package/src/i18n/locales/settings/en.po +135 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +136 -1
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +136 -1
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/image-dimensions.test.ts +314 -0
- package/src/lib/__tests__/mp4-track-flags.test.ts +117 -0
- package/src/lib/__tests__/telegram-entities.test.ts +180 -0
- package/src/lib/__tests__/telegram-pool-webhooks.test.ts +127 -0
- package/src/lib/env.ts +45 -0
- package/src/lib/ids.ts +3 -0
- package/src/lib/image-dimensions.ts +258 -0
- package/src/lib/mp4-track-flags.ts +71 -0
- package/src/lib/telegram-entities.ts +240 -0
- package/src/lib/telegram-pool-webhooks.ts +86 -0
- package/src/lib/telegram-settings-status.tsx +109 -0
- package/src/lib/telegram.ts +363 -0
- package/src/node/runtime.ts +6 -0
- package/src/routes/api/__tests__/telegram.test.ts +612 -0
- package/src/routes/api/telegram.ts +782 -0
- package/src/routes/api/upload-multipart.ts +34 -12
- package/src/routes/api/upload.ts +23 -2
- package/src/routes/dash/settings.tsx +131 -1
- package/src/routes/pages/__tests__/post-page-title.test.ts +70 -0
- package/src/routes/pages/page.tsx +3 -2
- package/src/runtime/cloudflare.ts +20 -9
- package/src/runtime/node.ts +20 -9
- package/src/runtime/site.ts +2 -1
- package/src/services/__tests__/telegram.test.ts +148 -0
- package/src/services/index.ts +9 -0
- package/src/services/telegram.ts +613 -0
- package/src/services/upload-session.ts +39 -12
- package/src/styles/tokens.css +1 -0
- package/src/styles/ui.css +134 -38
- package/src/types/app-context.ts +6 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +40 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +48 -17
- package/src/ui/dash/settings/TelegramContent.tsx +549 -0
- package/src/ui/feed/ThreadPreview.tsx +91 -38
- package/src/ui/feed/__tests__/thread-preview.test.ts +67 -5
- package/src/ui/pages/PostPage.tsx +78 -15
- package/dist/app-BgMwEN-M.js +0 -6
- package/dist/client/_assets/client-CJQYvkEx.js +0 -274
- 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
|
+
}
|