/** * MediaInfo / BDInfo 文本 → 技术规格概要(DETAIL-1,纯函数)。原文另存,这里只取展示需要的字段: * 概要(容器、时长、总码率、大小)、视频轨、音频轨、字幕轨。认不出的字段留 null,不猜。 */ export interface MediaGeneral { fileName: string | null; container: string | null; duration: string | null; overallBitrate: string | null; size: string | null; } export interface MediaVideoTrack { codec: string | null; resolution: string | null; frameRate: string | null; bitrate: string | null; bitDepth: string | null; /** HDR10 / HDR10+ / Dolby Vision P5 / HLG … 空数组 = SDR 或未知 */ hdr: string[]; } export interface MediaAudioTrack { language: string | null; codec: string | null; channels: string | null; bitrate: string | null; title: string | null; isDefault: boolean | null; } export interface MediaTextTrack { language: string | null; format: string | null; title: string | null; isDefault: boolean | null; forced: boolean | null; } export interface MediaSummary { kind: "mediainfo" | "bdinfo"; general: MediaGeneral; video: MediaVideoTrack[]; audio: MediaAudioTrack[]; subtitles: MediaTextTrack[]; } const LANG: Record = { chinese: "中文", mandarin: "国语", cantonese: "粤语", zh: "中文", chi: "中文", zho: "中文", "zh-cn": "简体中文", "zh-hans": "简体中文", "zh-tw": "繁体中文", "zh-hk": "繁体中文", "zh-hant": "繁体中文", english: "英语", en: "英语", eng: "英语", japanese: "日语", ja: "日语", jpn: "日语", korean: "韩语", ko: "韩语", kor: "韩语", french: "法语", fr: "法语", german: "德语", de: "德语", spanish: "西班牙语", es: "西班牙语", italian: "意大利语", russian: "俄语", thai: "泰语", portuguese: "葡萄牙语", hindi: "印地语", }; function language(v: string | null | undefined): string | null { if (!v) return null; const t = v.trim(); return LANG[t.toLowerCase()] ?? t; } function yesNo(v: string | undefined): boolean | null { if (!v) return null; if (/^(yes|是)$/i.test(v.trim())) return true; if (/^(no|否)$/i.test(v.trim())) return false; return null; } /** 「3 840 pixels」→ 3840 */ const num = (v: string | undefined): number | null => { const m = v ? /([\d\s]+)/.exec(v.replace(/(\d)\s+(?=\d)/g, "$1")) : null; return m ? Number.parseInt(m[1]!.replace(/\s/g, ""), 10) || null : null; }; /** 码率:去掉数字里的千分位空格 「4 561 kb/s」→「4561 kb/s」 */ const rate = (v: string | undefined): string | null => (v ? v.replace(/(\d)\s+(?=\d)/g, "$1").trim() : null); function frameRate(v: string | undefined): string | null { const m = v ? /([\d.]+)/.exec(v) : null; if (!m) return null; const n = Number.parseFloat(m[1]!); return Number.isFinite(n) ? `${Number.isInteger(n) ? n : Number(n.toFixed(3))} fps` : null; } /** 声道数 + 布局 → 2.0 / 5.1 / 7.1 */ function channels(count: string | undefined, layout: string | undefined): string | null { const n = num(count); if (!n) return null; const lfe = layout ? /\bLFE\b/.test(layout) : n === 6 || n === 8; return lfe ? `${n - 1}.1` : `${n}.0`; } function hdrOf(fields: Record): string[] { const out: string[] = []; const f = `${fields["HDR format"] ?? ""} ${fields.HDR_Format ?? ""}`; const dv = /Dolby Vision[^,]*,[^,]*,\s*Profile\s*([\d.]+)/i.exec(f); if (dv) out.push(`Dolby Vision P${dv[1]}`); else if (/Dolby Vision/i.test(f)) out.push("Dolby Vision"); if (/HDR10\+|SMPTE ST 2094/i.test(f)) out.push("HDR10+"); if ( /HDR10(?!\+)|SMPTE ST 2086/i.test(f) || (!out.length && /PQ|SMPTE ST 2084/.test(fields["Transfer characteristics"] ?? "")) ) out.push("HDR10"); if (/HLG/i.test(`${f} ${fields["Transfer characteristics"] ?? ""}`)) out.push("HLG"); return [...new Set(out)]; } /** 音频编码短名:MLP FBA → TrueHD,E-AC-3 → DDP,DTS XLL → DTS-HD MA;商业名带 Atmos 的加上 Atmos */ function audioCodec(f: Record): string | null { const fmt = f.Format?.trim(); if (!fmt) return f["Commercial name"] ?? null; const commercial = f["Commercial name"] ?? ""; let name = fmt; if (/^MLP FBA/i.test(fmt)) name = "TrueHD"; else if (/^E-AC-3/i.test(fmt)) name = "DDP"; else if (/^AC-3/i.test(fmt)) name = "AC-3"; else if (/^DTS/i.test(fmt)) name = /XLL X|DTS:X/i.test(`${fmt} ${commercial}`) ? "DTS:X" : /XLL|Master Audio/i.test(`${fmt} ${commercial}`) ? "DTS-HD MA" : "DTS"; else if (/^AAC/i.test(fmt)) name = "AAC"; else if (/^PCM/i.test(fmt)) name = "LPCM"; if (/Atmos/i.test(`${fmt} ${commercial}`) && !/Atmos/i.test(name)) name += " Atmos"; return name; } /** 中文版 MediaInfo 键 → 英文键 */ const ZH_KEYS: Record = { 完整名称: "Complete name", 格式: "Format", 文件大小: "File size", 时长: "Duration", 总体码率: "Overall bit rate", 码率: "Bit rate", 宽度: "Width", 高度: "Height", 帧率: "Frame rate", 位深: "Bit depth", 声道数: "Channel(s)", 声道布局: "Channel layout", 语言: "Language", 标题: "Title", 默认: "Default", 强制: "Forced", 商业名称: "Commercial name", "HDR 格式": "HDR format", 传输特质: "Transfer characteristics", }; type Section = { type: "general" | "video" | "audio" | "text" | "other"; fields: Record }; const SECTION_HEAD = /^(General|Video|Audio|Text|Menu|Chapters|Image|Other|概览|常规|概要|视频|音频|文本|字幕|菜单|章节)(?:\s*#\s*\d+)?\s*$/i; function sectionType(name: string): Section["type"] { const n = name.toLowerCase(); if (/^(general|概览|常规|概要)/.test(n)) return "general"; if (/^(video|视频)/.test(n)) return "video"; if (/^(audio|音频)/.test(n)) return "audio"; if (/^(text|文本|字幕)/.test(n)) return "text"; return "other"; } function parseSections(text: string): Section[] { const out: Section[] = []; let cur: Section | null = null; for (const raw of text.replace(/\r/g, "").split("\n")) { const line = raw.trim(); if (!line) continue; const head = SECTION_HEAD.exec(line); if (head) { cur = { type: sectionType(head[1]!), fields: {} }; out.push(cur); continue; } if (!cur) continue; const m = /^(.+?)\s*[::]\s(.*)$/.exec(line) ?? /^(.+?)\s{2,}[::]\s*(.*)$/.exec(line); if (!m) continue; const key = ZH_KEYS[m[1]!.trim()] ?? m[1]!.trim(); if (!(key in cur.fields)) cur.fields[key] = m[2]!.trim(); } return out; } function baseName(p: string | undefined): string | null { return p ? (p.split(/[\\/]/).pop() ?? p).trim() || null : null; } function parseMediaInfoText(text: string): MediaSummary | null { const sections = parseSections(text); const g = sections.find((s) => s.type === "general")?.fields; const videos = sections.filter((s) => s.type === "video"); if (!g && !videos.length) return null; const general: MediaGeneral = { fileName: baseName(g?.["Complete name"] ?? g?.["Movie name"]), container: g?.Format ?? null, duration: g?.Duration ?? null, overallBitrate: rate(g?.["Overall bit rate"]), size: g?.["File size"] ?? null, }; const video = videos.map(({ fields: f }): MediaVideoTrack => { const w = num(f.Width); const h = num(f.Height); return { codec: f.Format ?? null, resolution: w && h ? `${w}×${h}` : null, frameRate: frameRate(f["Frame rate"] ?? f["Original frame rate"]), bitrate: rate(f["Bit rate"] ?? f["Nominal bit rate"]), bitDepth: num(f["Bit depth"]) ? `${num(f["Bit depth"])} bit` : null, hdr: hdrOf(f), }; }); const audio = sections .filter((s) => s.type === "audio") .map( ({ fields: f }): MediaAudioTrack => ({ language: language(f.Language), codec: audioCodec(f), channels: channels(f["Channel(s)"], f["Channel layout"] ?? f["Channel positions"]), bitrate: rate(f["Bit rate"]), title: f.Title ?? null, isDefault: yesNo(f.Default), }), ); const subtitles = sections .filter((s) => s.type === "text") .map( ({ fields: f }): MediaTextTrack => ({ language: language(f.Language), format: f.Format ?? null, title: f.Title ?? null, isDefault: yesNo(f.Default), forced: yesNo(f.Forced), }), ); return { kind: "mediainfo", general, video, audio, subtitles }; } // ---------------- BDInfo ---------------- function bdVideo(codec: string, desc: string, bitrate: string | null): MediaVideoTrack { const parts = desc.split(/\s+\/\s+/).map((s) => s.trim()); const res = parts.find((p) => /^\d{3,4}[pi]$/i.test(p)) ?? null; const hdr: string[] = []; for (const p of parts) { if (/^Dolby Vision/i.test(p)) hdr.push(p.replace(/^Dolby Vision\s*/i, "Dolby Vision ").trim()); else if (/^HDR10\+?$/i.test(p) || /^HLG$/i.test(p)) hdr.push(p.toUpperCase()); } return { codec: codec.replace(/\s*Video$/i, "").trim() || null, resolution: res, frameRate: frameRate(parts.find((p) => /fps$/i.test(p))), bitrate: bitrate ?? parts.find((p) => /bps$/i.test(p)) ?? null, bitDepth: parts.find((p) => /^\d+ bits?$/i.test(p))?.replace(/bits?$/i, "bit") ?? null, hdr, }; } function bdAudioCodec(codec: string): string | null { const c = codec.replace(/\s*Audio$/i, "").trim(); if (!c) return null; if (/TrueHD\/Atmos/i.test(c)) return "TrueHD Atmos"; if (/TrueHD/i.test(c)) return "TrueHD"; if (/Digital Plus/i.test(c)) return /Atmos/i.test(c) ? "DDP Atmos" : "DDP"; if (/^Dolby Digital$/i.test(c)) return "AC-3"; if (/DTS:X/i.test(c)) return "DTS:X"; if (/DTS-HD Master/i.test(c)) return "DTS-HD MA"; return c; } function bdAudio(codec: string, lang: string | null, desc: string, bitrate: string | null): MediaAudioTrack { const parts = desc.split(/\s+\/\s+/).map((s) => s.trim()); return { language: language(lang), codec: bdAudioCodec(codec), channels: parts.find((p) => /^\d\.\d(?:\s|$|-)/.test(p))?.slice(0, 3) ?? null, bitrate: bitrate ?? parts.find((p) => /kbps$/i.test(p)) ?? null, title: null, isDefault: null, }; } /** 「61,812,345,678 bytes」→「57.57 GiB」;认不出原样返回 */ function bdSize(v: string | null): string | null { const m = v ? /^([\d,]+)\s*bytes$/i.exec(v.trim()) : null; if (!m) return v; const n = Number(m[1]!.replace(/,/g, "")); const units = ["B", "KiB", "MiB", "GiB", "TiB"]; let x = n; let i = 0; while (x >= 1024 && i < units.length - 1) { x /= 1024; i++; } return `${i ? x.toFixed(2) : x} ${units[i]}`; } /** 「2:22:48.521」→「2 h 22 min」,与 MediaInfo 写法一致 */ function bdDuration(v: string | null): string | null { const m = v ? /^(\d+):(\d{2}):(\d{2})/.exec(v.trim()) : null; if (!m) return v; const h = Number(m[1]); const min = Number(m[2]); return h ? `${h} h ${min} min` : `${min} min ${Number(m[3])} s`; } function parseBdInfoText(text: string): MediaSummary | null { const lines = text.replace(/\r/g, "").split("\n"); const kv = (label: RegExp) => { for (const l of lines) { const m = new RegExp(`^\\s*${label.source}\\s*:\\s*(.+)$`, "i").exec(l); if (m) return m[1]!.trim(); } return null; }; const general: MediaGeneral = { fileName: kv(/(?:Disc Title|Disc Label)/), container: "Blu-ray", duration: bdDuration(kv(/Length/)), overallBitrate: kv(/Total Bitrate/), size: bdSize(kv(/(?:Disc Size|Size)/)), }; const video: MediaVideoTrack[] = []; const audio: MediaAudioTrack[] = []; const subtitles: MediaTextTrack[] = []; // 简版:「Video: codec / bitrate / 2160p / …」「Audio: lang / codec / 7.1 / …」「Subtitle: lang / bitrate」 for (const l of lines) { const m = /^\s*(Video|Audio|Subtitle)\s*:\s*(.+)$/i.exec(l); if (!m) continue; const parts = m[2]!.split(/\s+\/\s+/).map((s) => s.trim()); const kind = m[1]!.toLowerCase(); if (kind === "video") video.push(bdVideo(parts[0] ?? "", parts.slice(1).join(" / "), parts[1] ?? null)); else if (kind === "audio") audio.push(bdAudio(parts[1] ?? "", parts[0] ?? null, parts.slice(2).join(" / "), null)); else subtitles.push({ language: language(parts[0]), format: "PGS", title: null, isDefault: null, forced: null }); } // 完整报告:VIDEO: / AUDIO: / SUBTITLES: 下的表格,列之间两个以上空格 if (!video.length && !audio.length) { let section: "video" | "audio" | "subs" | null = null; for (const l of lines) { const head = /^\s*(VIDEO|AUDIO|SUBTITLES|FILES|CHAPTERS|STREAM DIAGNOSTICS|PLAYLIST REPORT|DISC INFO)\s*:\s*$/.exec(l); if (head) { section = head[1] === "VIDEO" ? "video" : head[1] === "AUDIO" ? "audio" : head[1] === "SUBTITLES" ? "subs" : null; continue; } if (!section || !l.trim() || /^\s*(Codec|-----)/.test(l)) continue; const cols = l.trim().split(/\s{2,}/); if (section === "video") video.push(bdVideo(cols[0] ?? "", cols[2] ?? "", cols[1] ?? null)); else if (section === "audio") audio.push(bdAudio(cols[0] ?? "", cols[1] ?? null, cols[3] ?? "", cols[2] ?? null)); else subtitles.push({ language: language(cols[1]), format: /Presentation Graphics/i.test(cols[0] ?? "") ? "PGS" : (cols[0] ?? null), title: null, isDefault: null, forced: null, }); } } if (!video.length && !audio.length) return null; return { kind: "bdinfo", general, video, audio, subtitles }; } const isBdInfo = (t: string) => /(^|\n)\s*(DISC INFO:|Disc Title\s*:|Disc Label\s*:|BDInfo\s*:|PLAYLIST REPORT:)/i.test(t); /** MediaInfo 或 BDInfo 原文 → 概要;两种都认不出返回 null(前端只显示原文) */ export function parseMediaSummary(text: string | null | undefined): MediaSummary | null { if (!text?.trim()) return null; if (isBdInfo(text)) return parseBdInfoText(text) ?? parseMediaInfoText(text); return parseMediaInfoText(text); }