From 06e798939ad4d5ea65816bcdb1959fb814d588a4 Mon Sep 17 00:00:00 2001 From: noelorin Date: Sun, 27 Sep 2026 02:01:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(danmaku):=20review=20wave=20=E2=80=94=20uns?= =?UTF-8?q?tick=20local-xml,=20hint=20scoring,=20prefs=20sync,=20byte=20li?= =?UTF-8?q?mits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - I1: selectMatch 删 local-xml + 播放页本地源可重新匹配并清 localStorage - I2: rankCandidates 接受本地 hint,标题包含/相等与集号相等真实拉开分差 - I3: scrape.matchDandanplay 低置信不写库不覆盖标题/海报,返回候选手选 - I4: 播放页 prefs.data 到达后覆盖 danmakuOn/density/opacity,localStorage 仅回退 - I5: importXml 与 Zod 按 UTF-8 字节 ≤20MB(非 UTF-16 code unit) --- app/web/src/components/pages/WatchBody.tsx | 39 ++++++++- packages/danmaku/src/browser.ts | 2 +- packages/danmaku/src/index.ts | 2 +- packages/danmaku/src/match.test.ts | 79 +++++++++++++++++++ packages/danmaku/src/match.ts | 60 ++++++++++++-- packages/dao/src/danmaku-docs.ts | 11 +++ .../trpc/src/services/danmaku.service.test.ts | 23 +++++- packages/trpc/src/services/danmaku.service.ts | 21 ++++- .../trpc/src/services/scrape.service.test.ts | 66 ++++++++++++++++ packages/trpc/src/services/scrape.service.ts | 20 ++++- packages/types/src/media-schemas.test.ts | 13 +++ packages/types/src/media-schemas.ts | 20 ++++- 12 files changed, 337 insertions(+), 19 deletions(-) diff --git a/app/web/src/components/pages/WatchBody.tsx b/app/web/src/components/pages/WatchBody.tsx index ffb691e..9b77f81 100644 --- a/app/web/src/components/pages/WatchBody.tsx +++ b/app/web/src/components/pages/WatchBody.tsx @@ -24,6 +24,7 @@ type DanmukuPlugin = { load: (danmuku: unknown) => Promise | unknown; show: () => unknown; hide: () => unknown; + config: (option: { opacity?: number }) => unknown; }; function danmukuPlugin(art: Artplayer): DanmukuPlugin | null { @@ -89,6 +90,14 @@ function writeLocalXml(mediaItemId: string, xml: string) { } } +function clearLocalXml(mediaItemId: string) { + try { + localStorage.removeItem(DANMAKU_XML_PREFIX + mediaItemId); + } catch { + /* noop */ + } +} + /** 页面卸载时 fetch 可能被取消,用 keepalive 走与 httpBatchLink 相同的批格式兜底 */ function sendProgressBeacon(input: ProgressInput) { const url = "/api/trpc/media.playbackReport?batch=1"; @@ -188,6 +197,23 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { setDanmakuDensity(prefs.density); }, []); + // 服务端 prefs 到达后覆盖显示参数;localStorage 仅作 query 失败回退 + useEffect(() => { + const data = prefs.data; + if (!data) return; + const next: DanmakuPrefs = { + enabled: data.enabled, + opacity: Math.min(1, Math.max(0, data.opacity)), + density: Math.min(1, Math.max(0.1, data.density)), + }; + prefsRef.current = next; + setDanmakuOn(next.enabled); + setDanmakuDensity(next.density); + const art = artRef.current; + const dm = art ? danmukuPlugin(art) : null; + if (dm) dm.config({ opacity: next.opacity }); + }, [prefs.data]); + useEffect(() => { if (progress.data) { progressStartRef.current = progress.data.positionMs; @@ -416,10 +442,14 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { { mediaItemId, episodeId }, { onSuccess: (data) => { + // 手选成功即离开本地源:清内存与 localStorage,避免再粘滞 + if (data.ok) { + setLocalXml(""); + clearLocalXml(mediaItemId); + } if (data.ok && data.xml) { setMatchOpen(false); setMatchXml(data.xml); - setLocalXml(""); void openDanmaku.refetch(); toast.success(t(locale, "watch.matchManual")); return; @@ -542,11 +572,12 @@ function WatchInner(props: { locale: Locale; mediaItemId: string }) { const isLocalSource = Boolean(localXml) || openDanmaku.data?.source === "local-xml"; const fetchMessage = openDanmaku.data?.message ?? ""; + // 本地源也可重新匹配:手选后切回开放网络(服务端/本地 local-xml 同步清除) const showRematch = danmakuOn && - !localXml && - openDanmaku.data?.source === "none" && - (fetchMessage.includes("手选") || fetchMessage.includes("未匹配")); + (isLocalSource || + (openDanmaku.data?.source === "none" && + (fetchMessage.includes("手选") || fetchMessage.includes("未匹配")))); const candidates = matchMut.data?.candidates ?? []; const mediaTitle = detail.data.title; const sameNameEpisodes = new Set( diff --git a/packages/danmaku/src/browser.ts b/packages/danmaku/src/browser.ts index 1f1f740..153fa1d 100644 --- a/packages/danmaku/src/browser.ts +++ b/packages/danmaku/src/browser.ts @@ -3,5 +3,5 @@ export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.j export { filterComments, modeToKind } from "./filter.js"; export type { FilterOptions } from "./filter.js"; export { parseMatchOutcome, rankCandidates } from "./match.js"; -export type { OpenMatchOutcome } from "./match.js"; +export type { OpenMatchOutcome, RankHint } from "./match.js"; export type { DanmakuComment, MatchCandidate } from "./types.js"; diff --git a/packages/danmaku/src/index.ts b/packages/danmaku/src/index.ts index 7a438a6..59cfa9b 100644 --- a/packages/danmaku/src/index.ts +++ b/packages/danmaku/src/index.ts @@ -4,7 +4,7 @@ export { openCommentsToXml, parseDanmakuXml, serializeDanmakuXml } from "./xml.j export { filterComments, modeToKind } from "./filter.js"; export type { FilterOptions } from "./filter.js"; export { parseMatchOutcome, rankCandidates } from "./match.js"; -export type { OpenMatchOutcome } from "./match.js"; +export type { OpenMatchOutcome, RankHint } from "./match.js"; export { hasOpenCredentials, openApiBase, diff --git a/packages/danmaku/src/match.test.ts b/packages/danmaku/src/match.test.ts index ab1c299..45adf47 100644 --- a/packages/danmaku/src/match.test.ts +++ b/packages/danmaku/src/match.test.ts @@ -76,4 +76,83 @@ describe("rankCandidates", () => { const r = rankCandidates([low, mid, hi]); expect(r.candidates.map((c) => c.episodeId)).toEqual([1, 2, 3]); }); + + it("本地标题包含命中加分:多候选时命中者高置信", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null }, + { episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null }, + ]; + const r = rankCandidates(list, { hint: { title: "葬送的芙莉莲" } }); + expect(r.highConfidence).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(2); + }); + + it("本地标题完全相等加分高于部分包含", () => { + const list = [ + { + episodeId: 1, + animeId: 1, + animeTitle: "命运石之门", + episodeTitle: "01", + imageUrl: null, + }, + { + episodeId: 2, + animeId: 2, + animeTitle: "命运石之门 负荷领域", + episodeTitle: "01", + imageUrl: null, + }, + ]; + // 本地标题「命运石之门」:候选 1 完全相等(+1),候选 2 为包含(+0.6) + const r = rankCandidates(list, { hint: { title: "命运石之门" } }); + expect(r.highConfidence).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(1); + }); + + it("rawName 包含候选标题也加分", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null }, + { episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null }, + ]; + const r = rankCandidates(list, { + hint: { title: "本地标题", rawName: "[Sub] Sousou no Frieren - 01.mkv" }, + }); + // rawName 不含任一候选,无加分 → 同分不高置信 + expect(r.highConfidence).toBe(false); + const r2 = rankCandidates(list, { + hint: { title: "x", rawName: "[Sub] 芙莉莲 - 01.mkv" }, + }); + expect(r2.highConfidence).toBe(true); + expect(r2.candidates[0]?.episodeId).toBe(2); + }); + + it("单字符候选名不参与包含命中,避免噪声加分", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "T", episodeTitle: "E", imageUrl: null }, + { episodeId: 2, animeId: 2, animeTitle: "T2", episodeTitle: "E2", imageUrl: null }, + ]; + const r = rankCandidates(list, { + hint: { title: "Title", rawName: "Title - 01.mkv", epNumber: 1 }, + }); + expect(r.highConfidence).toBe(false); + }); + + it("集号相等加分拉开同标题候选差距", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "Show", episodeTitle: "02", imageUrl: null }, + { episodeId: 2, animeId: 1, animeTitle: "Show", episodeTitle: "01", imageUrl: null }, + ]; + const r = rankCandidates(list, { hint: { title: "Show", epNumber: 1 } }); + expect(r.highConfidence).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(2); + }); + + it("无 hint 时保持字段存在性打分(默认 lead 0.15)", () => { + const list = [ + { episodeId: 1, animeId: 1, animeTitle: "迷宫饭", episodeTitle: "01", imageUrl: null }, + { episodeId: 2, animeId: 2, animeTitle: "芙莉莲", episodeTitle: "01", imageUrl: null }, + ]; + expect(rankCandidates(list).highConfidence).toBe(false); + }); }); diff --git a/packages/danmaku/src/match.ts b/packages/danmaku/src/match.ts index 67d4b0f..e05e2ba 100644 --- a/packages/danmaku/src/match.ts +++ b/packages/danmaku/src/match.ts @@ -15,6 +15,13 @@ type RawMatch = { imageUrl?: string | null; }; +/** 本地媒体上下文,用于候选打分(标题包含关系 / 集号相等加分) */ +export type RankHint = { + title?: string | undefined; + rawName?: string | undefined; + epNumber?: number | null | undefined; +}; + export function parseMatchOutcome(json: unknown): { isMatched: boolean; candidates: MatchCandidate[]; @@ -42,20 +49,61 @@ export function parseMatchOutcome(json: unknown): { return { isMatched: body.isMatched === true, candidates }; } +/** 归一化标题:小写并去掉空白/标点,便于包含关系比对 */ +function normalizeText(s: string): string { + return s.toLowerCase().replace(/[\s\-_·・.。!!??:::、,,'"「」『』()()【】[\]]/g, ""); +} + +/** + * 标题命中加分:完全相等 > 相互包含。 + * 双方归一化后至少 2 字符才算命中,避免 "T" 命中 "Title" 这类噪声。 + */ +function titleBonus(local: string | undefined, candidate: string | null): number { + if (!local || !candidate) return 0; + const a = normalizeText(local); + const b = normalizeText(candidate); + if (a.length < 2 || b.length < 2) return 0; + if (a === b) return 1; + if (a.includes(b) || b.includes(a)) return 0.6; + return 0; +} + +function firstInt(s: string | null): number | null { + if (!s) return null; + const m = /\d+/.exec(s); + const raw = m?.[0]; + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) ? n : null; +} + +/** 集号相等加分(episodeTitle 中首个数字与本地 epNumber 相等) */ +function episodeBonus(hint: RankHint | undefined, episodeTitle: string | null): number { + const ep = hint?.epNumber; + if (ep == null || !episodeTitle) return 0; + return firstInt(episodeTitle) === ep ? 0.5 : 0; +} + +function scoreCandidate(c: MatchCandidate, hint: RankHint | undefined): number { + const base = (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0); + if (!hint) return base; + const matched = Math.max( + titleBonus(hint.title, c.animeTitle), + titleBonus(hint.rawName, c.animeTitle), + ); + return base + matched + episodeBonus(hint, c.episodeTitle); +} + /** 高置信:仅 1 条,或按「标题/集号重合度」打分后最高分领先第二名 ≥ lead */ export function rankCandidates( candidates: MatchCandidate[], - opts?: { lead?: number } | undefined, + opts?: { lead?: number; hint?: RankHint } | undefined, ): { candidates: MatchCandidate[]; highConfidence: boolean } { const lead = opts?.lead ?? 0.15; if (candidates.length === 0) return { candidates: [], highConfidence: false }; if (candidates.length === 1) return { candidates, highConfidence: true }; const scored = candidates - .map((c) => ({ - c, - score: - (c.animeTitle ? 1 : 0) * 0.5 + (c.episodeTitle ? 0.5 : 0) + (c.imageUrl ? 0.1 : 0), - })) + .map((c) => ({ c, score: scoreCandidate(c, opts?.hint) })) .sort((a, b) => b.score - a.score); const top = scored[0]; const second = scored[1]; diff --git a/packages/dao/src/danmaku-docs.ts b/packages/dao/src/danmaku-docs.ts index be09455..4d5a653 100644 --- a/packages/dao/src/danmaku-docs.ts +++ b/packages/dao/src/danmaku-docs.ts @@ -39,4 +39,15 @@ export const danmakuDocsDao = { if (!row) throw new Error("Failed to insert danmaku doc"); return row; }, + async remove(userId: string, mediaItemId: string, source: string): Promise { + await db + .delete(danmakuDocs) + .where( + and( + eq(danmakuDocs.userId, userId), + eq(danmakuDocs.mediaItemId, mediaItemId), + eq(danmakuDocs.source, source), + ), + ); + }, }; diff --git a/packages/trpc/src/services/danmaku.service.test.ts b/packages/trpc/src/services/danmaku.service.test.ts index 441b0f7..453d077 100644 --- a/packages/trpc/src/services/danmaku.service.test.ts +++ b/packages/trpc/src/services/danmaku.service.test.ts @@ -13,7 +13,7 @@ import { createWebdav } from "./webdav-client.js"; vi.mock("@app/dao", () => ({ danmakuCacheDao: { getValid: vi.fn(), upsert: vi.fn() }, - danmakuDocsDao: { get: vi.fn(), upsert: vi.fn() }, + danmakuDocsDao: { get: vi.fn(), upsert: vi.fn(), remove: vi.fn() }, danmakuPrefsDao: { getByUser: vi.fn(), upsert: vi.fn() }, mediaItemDao: { getByIdForUser: vi.fn(), @@ -181,6 +181,18 @@ describe("danmakuService.importXml", () => { code: "BAD_REQUEST", }); }); + + it("按 UTF-8 字节限 20MB:code unit 未超但字节超限拒绝", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + // 7_000_000 个汉字 = 7M code units(旧实现放行)但 21MB UTF-8 + const huge = `${"中".repeat(7_000_000)}`; + expect(huge.length).toBeLessThanOrEqual(20_000_000); + await expect(danmakuService.importXml("u1", "m1", huge)).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "XML 过大", + }); + expect(danmakuDocsDao.upsert).not.toHaveBeenCalled(); + }); }); describe("danmakuService.fetch", () => { @@ -310,6 +322,13 @@ describe("danmakuService.selectMatch", () => { ); }); + it("成功后清除 local-xml,避免本地源粘滞", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(openCommentXml).mockResolvedValue("open"); + await danmakuService.selectMatch("u1", "m1", 4242); + expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml"); + }); + it("拉取失败仍保留 manual 绑定,返回 none", async () => { vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); vi.mocked(openCommentXml).mockResolvedValue(null); @@ -318,6 +337,8 @@ describe("danmakuService.selectMatch", () => { expect(r.message).toContain("拉取失败"); expect(mediaItemDao.setDanmakuMatch).toHaveBeenCalled(); expect(danmakuCacheDao.upsert).not.toHaveBeenCalled(); + // 手选即离开本地源:即便拉取失败也清 local-xml + expect(danmakuDocsDao.remove).toHaveBeenCalledWith("u1", "m1", "local-xml"); }); }); diff --git a/packages/trpc/src/services/danmaku.service.ts b/packages/trpc/src/services/danmaku.service.ts index 8a65c7c..0d2a5cd 100644 --- a/packages/trpc/src/services/danmaku.service.ts +++ b/packages/trpc/src/services/danmaku.service.ts @@ -166,7 +166,13 @@ export const danmakuService = { if (episodeId == null) { const hash = await remoteFileHash(userId, mediaItemId); const outcome = await openMatch(matchFileName(item.path), item.size, hash); - const ranked = rankCandidates(outcome.candidates); + const ranked = rankCandidates(outcome.candidates, { + hint: { + title: item.title, + rawName: item.rawName, + epNumber: item.epNumber ?? null, + }, + }); if (!outcome.ok || !outcome.isMatched || ranked.candidates.length === 0) { return { ok: true, @@ -226,7 +232,13 @@ export const danmakuService = { } const hash = await remoteFileHash(userId, mediaItemId); const outcome = await openMatch(matchFileName(item.path), item.size, hash); - const ranked = rankCandidates(outcome.candidates); + const ranked = rankCandidates(outcome.candidates, { + hint: { + title: item.title, + rawName: item.rawName, + epNumber: item.epNumber ?? null, + }, + }); return { ok: outcome.ok, matched: outcome.isMatched && ranked.candidates.length > 0, @@ -248,6 +260,8 @@ export const danmakuService = { matchedHash: item.matchedHash, source: "manual", }); + // 手选即切换到开放网络:清掉 local-xml,避免 fetch 仍优先进本地源 + await danmakuDocsDao.remove(userId, mediaItemId, "local-xml"); const xml = await openCommentXml(episodeId); if (xml === null) { return { ok: true, source: "none", xml: "", message: "弹幕库拉取失败" }; @@ -264,7 +278,8 @@ export const danmakuService = { ): Promise<{ ok: true; count: number }> { const item = await mediaItemDao.getByIdForUser(mediaItemId, userId); if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "媒体不存在" }); - if (xml.length > 20_000_000) { + // 按 UTF-8 字节限流(Zod 同口径),而非 UTF-16 code unit + if (Buffer.byteLength(xml, "utf8") > 20_000_000) { throw new TRPCError({ code: "BAD_REQUEST", message: "XML 过大" }); } if (!xml.includes(" { expect.objectContaining({ dandanplayEpisodeId: 22 }), ); }); + + it("本地标题命中拉大差距 → 多候选仍高置信自动绑", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue(mediaItem()); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { + episodeId: 11, + animeId: 1, + animeTitle: "Other", + episodeTitle: "X", + imageUrl: null, + }, + { + episodeId: 22, + animeId: 2, + animeTitle: "Show", + episodeTitle: "X", + imageUrl: null, + }, + ], + }); + const r = await scrapeService.matchDandanplay("u1", "m1"); + expect(r.highConfidence).toBe(true); + expect(r.candidates[0]?.episodeId).toBe(22); + expect(mediaItemDao.updateScrape).toHaveBeenCalledWith( + "m1", + "u1", + expect.objectContaining({ dandanplayEpisodeId: 22 }), + ); + }); + + it("低置信多候选:不写库(不覆盖标题/海报),返回候选供手选", async () => { + vi.mocked(mediaItemDao.getByIdForUser).mockResolvedValue( + mediaItem({ title: "Keep Me", posterUrl: "https://img/keep.jpg" }), + ); + vi.mocked(openMatch).mockResolvedValue({ + ok: true, + errorMessage: null, + isMatched: true, + candidates: [ + { + episodeId: 11, + animeId: 1, + animeTitle: "AAA", + episodeTitle: "X", + imageUrl: "a.jpg", + }, + { + episodeId: 22, + animeId: 2, + animeTitle: "BBB", + episodeTitle: "Y", + imageUrl: "b.jpg", + }, + ], + }); + const r = await scrapeService.matchDandanplay("u1", "m1"); + expect(r.highConfidence).toBe(false); + expect(r.matched).toBe(false); + expect(r.candidates).toHaveLength(2); + expect(r.message).toContain("手选"); + expect(mediaItemDao.updateScrape).not.toHaveBeenCalled(); + }); }); diff --git a/packages/trpc/src/services/scrape.service.ts b/packages/trpc/src/services/scrape.service.ts index a4af10e..5eab7ea 100644 --- a/packages/trpc/src/services/scrape.service.ts +++ b/packages/trpc/src/services/scrape.service.ts @@ -233,9 +233,16 @@ export const scrapeService = { highConfidence: false, }; } - const ranked = rankCandidates(outcome.candidates); + const ranked = rankCandidates(outcome.candidates, { + hint: { + title: row.title, + rawName: row.rawName, + epNumber: row.epNumber ?? null, + }, + }); const best = ranked.candidates[0]; - if (best) { + // 与 fetch 对齐:仅高置信才自动绑并写元数据;低置信不落库,返回候选供手选 + if (best && ranked.highConfidence) { await mediaItemDao.updateScrape(mediaItemId, userId, { dandanplayEpisodeId: best.episodeId, matchedHash: hash, @@ -252,6 +259,15 @@ export const scrapeService = { highConfidence: ranked.highConfidence, }; } + if (best) { + return { + ok: true, + matched: false, + message: "匹配不明确,请手选弹幕集", + candidates: ranked.candidates, + highConfidence: false, + }; + } if (row.bangumiId != null) { const bridged = await bridgeDandanEpisode( diff --git a/packages/types/src/media-schemas.test.ts b/packages/types/src/media-schemas.test.ts index 39e2a1c..5214f3a 100644 --- a/packages/types/src/media-schemas.test.ts +++ b/packages/types/src/media-schemas.test.ts @@ -84,6 +84,19 @@ describe("danmakuSchemas.import", () => { false, ); }); + it("按 UTF-8 字节限 20MB:多字节字符 code unit 未超但字节超限拒绝", () => { + // 7_000_000 个汉字 = 7M code units(旧 .max(20_000_000) 放行)但 21MB UTF-8 + const huge = `${"中".repeat(7_000_000)}`; + expect(huge.length).toBeLessThanOrEqual(20_000_000); + expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: huge }).success).toBe( + false, + ); + }); + it("多字节字符字节未超 20MB 时放行", () => { + // 6_000_000 个汉字 ≈ 18MB UTF-8,code units 仅 6M + const ok = `${"中".repeat(6_000_000)}`; + expect(danmakuSchemas.import.safeParse({ mediaItemId: "m1", xml: ok }).success).toBe(true); + }); it("fileName 超 255 拒绝", () => { expect( danmakuSchemas.import.safeParse({ diff --git a/packages/types/src/media-schemas.ts b/packages/types/src/media-schemas.ts index e4d8451..9e5d4b6 100644 --- a/packages/types/src/media-schemas.ts +++ b/packages/types/src/media-schemas.ts @@ -2,6 +2,24 @@ import { z } from "zod"; const id = z.string().min(1); +/** UTF-8 字节数(等价 TextEncoder/Buffer.byteLength,纯实现,浏览器/Node 均可用) */ +function utf8ByteLength(s: string): number { + let bytes = 0; + for (let i = 0; i < s.length; i++) { + const code = s.charCodeAt(i); + if (code < 0x80) bytes += 1; + else if (code < 0x800) bytes += 2; + else if (code >= 0xd800 && code <= 0xdbff) { + bytes += 4; + i += 1; + } else bytes += 3; + } + return bytes; +} + +/** 导入/侧车 XML 全文上限:20MB(UTF-8 字节) */ +const XML_MAX_BYTES = 20_000_000; + export const mountSchemas = { create: z.object({ name: z.string().min(1, "名称不能为空").max(100), @@ -161,7 +179,7 @@ export const danmakuSchemas = { xml: z .string() .min(1) - .max(20_000_000) + .refine((v) => utf8ByteLength(v) <= XML_MAX_BYTES, "XML 超过 20MB") .refine((v) => v.includes("