改善 AV Lag 進行中存檔與分享檔並新增診斷檢視器
.github/workflows/unit-tests.ymlDocs/ChangeHistory.mdDocs/README.mdDocs/development/AV_LAG.mdDocs/development/DIAGNOSTIC_LOG_VIEWER.mdScripts/diagnostic_viewer.cmdScripts/diagnostic_viewer.htmlScripts/diagnostic_viewer.pyScripts/test_diagnostic_viewer.pyTODO.mdliveAPP/Capture/AVLagModel.swiftliveAPP/Capture/AVLagReportStore.swiftliveAPP/Capture/AVLagReportView.swiftliveAPP/Capture/AVLagStoredSessionView.swiftliveAPP/Localizable.xcstrings
commit e574f3ffcb3c1b26e31e74c0eb733cf244415a8dAuthor: Nuclear0709 <agp05432@gmail.com>Date: Fri Nov 13 15:31:28 2026 +0800 改善 AV Lag 進行中存檔與分享檔並新增診斷檢視器 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml--- a/.github/workflows/unit-tests.yml+++ b/.github/workflows/unit-tests.yml@@ -157,6 +157,9 @@ jobs: - name: Verify release classifier run: python3 Scripts/test_classify_release.py + - name: Verify diagnostic viewer+ run: python3 -m unittest Scripts.test_diagnostic_viewer+ - name: Run liveAPP unit tests timeout-minutes: 20 env:diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,13 @@ # 變更歷史 +## 2026.11.13 AV Lag 進行中存檔與診斷檢視器++- AV Lag 在 `recentSessions` 模式新增每 60 秒的進行中 segment checkpoint,不再只在進入背景時寫一次。先前長時間推流後若 App 被中止,磁碟上的 segment 可能停在進入背景當下的少量樣本;長場次實際已累積 1,775 筆,但分享出的 JSON 只有 3 筆。+- AV Lag 的儲存任務改為依序串接,避免較舊的 checkpoint 晚於 final save 完成而覆蓋較新的文件;完成時會記錄 `[AVLagStore] saved ... complete=true`。+- AV Lag 目前報告與已保存 segment 的分享改為真正的 `.json` 檔案 URL,不再只分享一大段文字;報告與分享檔改存到 `Documents/AVLagReports`,透過既有 `UIFileSharingEnabled` 可直接在「檔案」App → 我的 iPhone → 松鼠推流 → AVLagReports 取得。舊的 Application Support 報告會在啟動時搬移。+- 新增 `Scripts/diagnostic_viewer.py` 與 `Scripts/diagnostic_viewer.html`:本機網頁檢視 `diagnostic-*.txt`,依 `[Tag]`/session/嚴重度分類,支援搜尋與 `key=value` 欄位展開。`failed=0`、`lastError=none` 與累積 `dropped` 不會被誤標成錯誤。+- 新增 `Scripts/test_diagnostic_viewer.py`(5 個測試)與 `Docs/development/DIAGNOSTIC_LOG_VIEWER.md`;指定 port 被占用時會自動往後找可用 port,不會直接開不起來。+ ## 2026.11.10 音訊主時鐘與 RTMP 診斷可讀性 - 錄影 manifest(sidecar)只在開始、完成或中斷等 phase 轉換時持久化 `sourceStartPTS`/`sourceEndPTS`;進度回報不把尾端累積在記憶體等待落盤,避免長錄影產生額外的狀態寫入。失敗收尾會主動釋放 `AVAssetWriter` 與檔案 URL,不讓失敗片段持有 handle。diff --git a/Docs/README.md b/Docs/README.md--- a/Docs/README.md+++ b/Docs/README.md@@ -89,4 +89,5 @@ Pages 沿用既有 `documentation.yml` 單一部署:DocC 模組保留原路徑 | ------ | ------ | | [`crash_trace.py`](crash_trace.py) / [`crash_trace.sh`](crash_trace.sh) | `.ips` 崩潰符號化、死因判定、跨執行緒死鎖分析 | | [`crashlog_analyzer.py`](crashlog_analyzer.py) | 跨平台日誌/IPS 快速瀏覽工具 |+| [`../Scripts/diagnostic_viewer.py`](../Scripts/diagnostic_viewer.py) | `diagnostic-*.txt` 本機網頁檢視器:分類、session、嚴重度、搜尋與欄位展開([說明](development/DIAGNOSTIC_LOG_VIEWER.md)) | | [`../Scripts/change_log.py`](../Scripts/change_log.py) / [`../Scripts/change_log.cmd`](../Scripts/change_log.cmd) | 變更歷史工具:本機網頁 GUI(新增/搜尋/就地編輯/刪除、深淺色切換、GitHub alert 樣式預覽)+ CLI 子命令 |diff --git a/Docs/development/AV_LAG.md b/Docs/development/AV_LAG.md--- a/Docs/development/AV_LAG.md+++ b/Docs/development/AV_LAG.md@@ -3,7 +3,7 @@ ## 快速入口 - 裝置資訊頁 → **A/V Lag** → **查看目前 AV Lag 報告**-- 報告頁可複製/分享 JSON,給回放 retime 工具做交叉比對+- 報告頁可複製/分享 JSON,給回放 retime 工具做交叉比對;分享的是實際 `.json` 檔案,可直接交給分析工具 - 日誌關鍵字:`[AVLag]` ## 這份報告回答什麼@@ -46,11 +46,14 @@ videoContentAge = arrival - videoPTS ## 保存與長場次分段 - 預設**僅本次記憶體**;關閉 App 即清除。-- 可選**保留最近 5 場**:停止時寫入 Application Support/AVLagReports,最多 5 個 session、總量約 50 MiB。+- 可選**保留最近 5 場**:停止時寫入 `Documents/AVLagReports`,最多 5 個 session、總量約 50 MiB。App 的 `Info.plist` 已啟用 `UIFileSharingEnabled`,因此可在「檔案」App → 我的 iPhone → 松鼠推流 → `AVLagReports` 直接看到。 - 可選**長場次分段**:每段長度 1/2/4/8 小時(預設 4);每段仍受 2 MiB 上限。8 小時以上會自然拆成多段,但報告頁以 `sessionID` 分組,不會當成多場。 - 分段之間保留 `segmentIndex`/`segmentStartedAt`/`segmentEndedAt`;最後一段可標記 `inProgress`,App 中途被殺仍可辨識。+- `complete=false` 的檔案是進行中 checkpoint,不是最終報告。若只有 3 筆樣本且時間只涵蓋開始後十幾秒,先看它的 `startedAt`/`segmentStartedAt`,不要直接當成長場次結論;目前報告頁的「目前 session」應在停止後再分享。 - 未開分段而超過上限:保留最新區間,並在報告標記 `truncatedBefore`。-- 停止、分段與進背景時會嘗試保存;寫檔為原子寫入,不阻塞擷取。+- `recentSessions` 模式每 60 秒會 checkpoint 進行中的 segment;停止、分段與進背景時也會保存。長場次被中止時,檔案最多落後一個 checkpoint 週期,不會只剩進入背景當下的少量樣本。+- 儲存任務依呼叫順序串接;較舊的 checkpoint 不會覆蓋較新的 final 文件。寫檔為原子寫入,不阻塞擷取。+- 目前報告與每個已保存 segment 都可分享成 `.json` 檔案;文字複製仍保留給快速檢查。報告檔同時寫在 `Documents/AVLagReports`,可直接從「檔案」App 取得。 ## JSON 用途 diff --git a/Docs/development/DIAGNOSTIC_LOG_VIEWER.md b/Docs/development/DIAGNOSTIC_LOG_VIEWER.md--- /dev/null+++ b/Docs/development/DIAGNOSTIC_LOG_VIEWER.md@@ -0,0 +1,42 @@+# 診斷日誌檢視器++把 `diagnostic-*.txt` 依 `[Tag]`、session 與嚴重度拆開,提供可搜尋的本機網頁;日誌不會上傳,也不需要外部套件。++## 啟動++```bash+python Scripts/diagnostic_viewer.py "E:\Video5\crash-symbols\diagnostic-20261113-0224-20261113-1458.txt"+```++預設開 `http://127.0.0.1:8766/`,並自動開啟瀏覽器。++```bash+# 不自動開瀏覽器+python Scripts/diagnostic_viewer.py <log.txt> --no-browser++# 指定起始 port;若已被占用會自動往下找(最多 20 個)+python Scripts/diagnostic_viewer.py <log.txt> --port 8777+```++按 `Ctrl+C` 停止。++## 功能++- 左側分類:`AVLag`、`CaptureAudioPipeline`、`CaptureTransport`、`CaptureVideoProcessing`、`Memory`、`IdleResources`、`RecordingFrame` 等,顯示各類行數。+- Session 篩選:從 `session=<UUID>` 自動抽取。+- 嚴重度:`error`/`warn`/`info`。`failed=0`、`lastError=none` 與累積的 `dropped=1` 不會被誤標成錯誤。+- 搜尋:訊息、分類、session 與 `key=value` 欄位一起搜尋。+- 欄位展開:每行可展開 `key=value`,不用在一大串文字裡找數字。+- 分頁:預設 300 筆,按「載入更多」繼續。++## 判讀邊界++- 這是「分類與搜尋」工具,不會自動判斷因果或修正時間軸。+- `dropped`、`overflow`、`rebase` 是累積計數;看到非零不代表整個後續區段都在出錯,要搭配同一行的時間與其他欄位。+- 真正的錯誤以 `error`/`failed`/`Reconnect` 等事件為準;工具只做初步分級,最後仍需回到原始事件與量測。++## 測試++```bash+python -m unittest Scripts.test_diagnostic_viewer -v+```diff --git a/Scripts/diagnostic_viewer.cmd b/Scripts/diagnostic_viewer.cmd--- /dev/null+++ b/Scripts/diagnostic_viewer.cmd@@ -0,0 +1,2 @@+@echo off+python "%~dp0diagnostic_viewer.py" %*diff --git a/Scripts/diagnostic_viewer.html b/Scripts/diagnostic_viewer.html--- /dev/null+++ b/Scripts/diagnostic_viewer.html@@ -0,0 +1,261 @@+<!doctype html>+<html lang="zh-Hant">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>ReplyKit 診斷檢視器</title>+<style>+ :root {+ color-scheme: dark;+ --bg: #111318;+ --panel: #191c23;+ --panel-2: #20242d;+ --border: #303641;+ --text: #e7eaf0;+ --muted: #9aa3b2;+ --accent: #6ea8fe;+ --warn: #f0b429;+ --error: #ff6b6b;+ --ok: #4ade80;+ }+ * { box-sizing: border-box; }+ body { margin: 0; background: var(--bg); color: var(--text);+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }+ header { padding: 18px 22px; border-bottom: 1px solid var(--border);+ background: linear-gradient(180deg, #1b2029, #141820); }+ header h1 { margin: 0 0 4px; font-size: 18px; font-weight: 600; }+ header .meta { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }+ .layout { display: grid; grid-template-columns: 260px minmax(0, 1fr); min-height: calc(100vh - 82px); }+ aside { border-right: 1px solid var(--border); padding: 14px; background: var(--panel); }+ aside h2 { margin: 0 0 10px; font-size: 12px; letter-spacing: .06em;+ text-transform: uppercase; color: var(--muted); }+ button.cat { display: flex; justify-content: space-between; width: 100%;+ padding: 7px 9px; margin: 2px 0; border: 0; border-radius: 7px;+ background: transparent; color: var(--text); cursor: pointer; text-align: left; }+ button.cat:hover { background: var(--panel-2); }+ button.cat.active { background: #2a3a55; color: #dbe8ff; }+ button.cat .count { color: var(--muted); font-variant-numeric: tabular-nums; }+ main { padding: 16px; min-width: 0; }+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));+ gap: 10px; margin-bottom: 14px; }+ .card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;+ padding: 10px 12px; }+ .card .label { color: var(--muted); font-size: 11px; }+ .card .value { font-size: 18px; font-weight: 600; margin-top: 3px;+ overflow-wrap: anywhere; }+ .toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center;+ margin-bottom: 10px; }+ input[type="search"], select { background: var(--panel-2); color: var(--text);+ border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; }+ input[type="search"] { min-width: 260px; flex: 1 1 320px; }+ .pill { border: 1px solid var(--border); background: var(--panel-2);+ color: var(--muted); border-radius: 999px; padding: 5px 10px; cursor: pointer; }+ .pill.active { border-color: var(--accent); color: var(--accent); }+ .status { color: var(--muted); font-size: 12px; margin: 8px 0; }+ table { width: 100%; border-collapse: collapse; table-layout: fixed; }+ th, td { padding: 8px 9px; border-bottom: 1px solid var(--border);+ vertical-align: top; text-align: left; }+ th { position: sticky; top: 0; background: var(--panel); color: var(--muted);+ font-size: 11px; font-weight: 600; z-index: 1; }+ td.time { white-space: nowrap; color: var(--muted); font-size: 12px; width: 150px; }+ td.cat { width: 170px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace;+ font-size: 12px; color: var(--accent); overflow-wrap: anywhere; }+ td.msg { overflow-wrap: anywhere; }+ tr.error td.msg { color: #ffc2c2; }+ tr.warn td.msg { color: #ffe2a3; }+ .fields { margin-top: 4px; }+ .fields summary { color: var(--muted); cursor: pointer; font-size: 11px; }+ .fields pre { margin: 6px 0 0; padding: 8px; background: #0d1015;+ border: 1px solid var(--border); border-radius: 6px;+ white-space: pre-wrap; font-size: 11px; color: #c7d2e4; }+ .load { margin: 12px 0; padding: 8px 16px; border-radius: 8px;+ border: 1px solid var(--border); background: var(--panel-2);+ color: var(--text); cursor: pointer; }+ .empty { color: var(--muted); padding: 24px 0; text-align: center; }+ @media (max-width: 820px) {+ .layout { grid-template-columns: 1fr; }+ aside { border-right: 0; border-bottom: 1px solid var(--border);+ max-height: 220px; overflow: auto; }+ td.cat { width: 120px; }+ td.time { width: 110px; }+ }+</style>+</head>+<body>+<header>+ <h1>ReplyKit 診斷檢視器</h1>+ <div class="meta" id="fileMeta">載入中…</div>+</header>+<div class="layout">+ <aside>+ <h2>分類</h2>+ <div id="categories"></div>+ </aside>+ <main>+ <div class="cards" id="cards"></div>+ <div class="toolbar">+ <input id="search" type="search" placeholder="搜尋訊息、session、數值…" autocomplete="off">+ <select id="severity" title="嚴重度">+ <option value="">全部嚴重度</option>+ <option value="error">錯誤</option>+ <option value="warn">警告</option>+ <option value="info">一般</option>+ </select>+ <select id="session" title="Session"><option value="">全部 Session</option></select>+ <button class="pill active" id="reset" type="button">清除篩選</button>+ </div>+ <div class="status" id="status"></div>+ <table>+ <thead><tr><th>時間</th><th>分類</th><th>內容</th></tr></thead>+ <tbody id="rows"></tbody>+ </table>+ <div id="empty" class="empty" hidden>沒有符合條件的紀錄。</div>+ <button class="load" id="more" type="button" hidden>載入更多</button>+ </main>+</div>+<script>+const state = { category: "", q: "", severity: "", session: "", offset: 0, limit: 300, total: 0 };+const $ = (id) => document.getElementById(id);+let searchTimer = 0;++function text(tag, value) {+ const el = document.createElement(tag);+ el.textContent = value == null ? "" : String(value);+ return el;+}++function card(label, value, color) {+ const box = document.createElement("div");+ box.className = "card";+ box.appendChild(text("div", label)).className = "label";+ const val = text("div", value);+ val.className = "value";+ if (color) val.style.color = color;+ box.appendChild(val);+ return box;+}++function renderSummary(s) {+ $("fileMeta").textContent = `${s.file} · ${s.range} · ${s.lines} 行`;+ const cards = $("cards");+ cards.replaceChildren(+ card("總行數", s.lines),+ card("時間範圍", s.range),+ card("Session", s.sessions.length),+ card("警告", s.warn, "var(--warn)"),+ card("錯誤", s.error, "var(--error)")+ );+ const categories = $("categories");+ categories.replaceChildren();+ const all = document.createElement("button");+ all.className = "cat" + (state.category === "" ? " active" : "");+ all.dataset.category = "";+ all.appendChild(text("span", "全部"));+ all.appendChild(text("span", String(s.lines))).className = "count";+ all.onclick = () => { state.category = ""; state.offset = 0; load(); };+ categories.appendChild(all);+ for (const item of s.categories) {+ const button = document.createElement("button");+ button.className = "cat" + (state.category === item.name ? " active" : "");+ button.dataset.category = item.name;+ button.appendChild(text("span", item.name));+ const count = text("span", item.count);+ count.className = "count";+ button.appendChild(count);+ button.onclick = () => { state.category = item.name; state.offset = 0; load(); };+ categories.appendChild(button);+ }+ const session = $("session");+ const current = state.session;+ session.replaceChildren(new Option("全部 Session", ""));+ for (const id of s.sessions) session.appendChild(new Option(id, id));+ session.value = current;+}++function syncActiveCategory() {+ for (const button of document.querySelectorAll(".cat")) {+ button.classList.toggle("active", (button.dataset.category || "") === state.category);+ }+}++function renderRows(data, append) {+ const body = $("rows");+ if (!append) body.replaceChildren();+ for (const line of data.lines) {+ const tr = document.createElement("tr");+ tr.className = line.severity;+ const time = text("td", line.time);+ time.className = "time";+ const cat = text("td", line.category);+ cat.className = "cat";+ const msg = document.createElement("td");+ msg.className = "msg";+ msg.appendChild(text("div", line.message));+ if (line.fields && Object.keys(line.fields).length) {+ const details = document.createElement("details");+ details.className = "fields";+ details.appendChild(text("summary", `${Object.keys(line.fields).length} 個欄位`));+ const pre = text("pre", Object.entries(line.fields)+ .map(([k, v]) => `${k} = ${v}`).join("\n"));+ details.appendChild(pre);+ msg.appendChild(details);+ }+ tr.append(time, cat, msg);+ body.appendChild(tr);+ }+ $("empty").hidden = data.lines.length !== 0;+ $("more").hidden = !data.hasMore;+ $("more").textContent = `載入更多(目前 ${data.offset + data.lines.length} / ${data.total})`;+ syncActiveCategory();+}++async function load(append = false) {+ if (!append) {+ state.offset = 0;+ $("status").textContent = "載入中…";+ }+ const params = new URLSearchParams({+ category: state.category, q: state.q, severity: state.severity,+ session: state.session, offset: String(state.offset), limit: String(state.limit)+ });+ const response = await fetch("/api/lines?" + params);+ if (!response.ok) {+ $("status").textContent = "讀取失敗:" + response.status;+ return;+ }+ const data = await response.json();+ state.total = data.total;+ renderRows(data, append);+ $("status").textContent = `${data.total} 筆符合 · 顯示 ${data.offset + data.lines.length} 筆`+ + (state.category ? ` · 分類 ${state.category}` : "");+ if (data.hasMore) state.offset = data.offset + data.lines.length;+}++function scheduleSearch() {+ clearTimeout(searchTimer);+ searchTimer = setTimeout(() => { state.q = $("search").value.trim(); load(); }, 220);+}++$("search").addEventListener("input", scheduleSearch);+$("severity").addEventListener("change", () => { state.severity = $("severity").value; load(); });+$("session").addEventListener("change", () => { state.session = $("session").value; load(); });+$("more").addEventListener("click", () => load(true));+$("reset").addEventListener("click", () => {+ state.category = ""; state.q = ""; state.severity = ""; state.session = ""; state.offset = 0;+ $("search").value = ""; $("severity").value = ""; $("session").value = "";+ load();+});++(async function boot() {+ const response = await fetch("/api/summary");+ if (!response.ok) {+ $("fileMeta").textContent = "讀取失敗:" + response.status;+ return;+ }+ const summary = await response.json();+ renderSummary(summary);+ await load();+})();+</script>+</body>+</html>diff --git a/Scripts/diagnostic_viewer.py b/Scripts/diagnostic_viewer.py--- /dev/null+++ b/Scripts/diagnostic_viewer.py@@ -0,0 +1,305 @@+#!/usr/bin/env python3+"""ReplyKit 診斷日誌本機檢視器。++把 `diagnostic-*.txt` 依 `[Tag]`、session 與嚴重度拆開,提供可搜尋的+本機網頁;不依賴外部套件,也不會上傳日誌。++用法:+ python Scripts/diagnostic_viewer.py "E:\\Video5\\crash-symbols\\diagnostic-....txt"+ python Scripts/diagnostic_viewer.py <log.txt> --port 8766 --no-browser+"""++from __future__ import annotations++import argparse+import errno+import json+import re+import sys+import webbrowser+from collections import Counter+from dataclasses import dataclass, field+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+from pathlib import Path+from urllib.parse import parse_qs, urlparse++ROOT = Path(__file__).resolve().parent+TEMPLATE = ROOT / "diagnostic_viewer.html"+DEFAULT_PORT = 8766++TIMESTAMP = re.compile(r"^(\d{4}/\d{2}/\d{2}\s+\S*\d{1,2}:\d{2}:\d{2}):\s*(.*)$")+TAG = re.compile(r"\[([^\[\]]+)\]")+SESSION = re.compile(r"\bsession=([0-9A-Fa-f-]{8,}|[A-Za-z0-9_.-]+)")+FIELD = re.compile(r"\b([A-Za-z_][A-Za-z0-9_.-]*)=([^\s]+)")+ERROR_WORDS = re.compile(+ r"\b(failed|failure|fatal|panic|crash|jetsam|watchdog|abort|exception)\b",+ re.IGNORECASE,+)+WARN_WORDS = re.compile(+ r"(\[!\]|warning|memory\.warning|save failed|incomplete|"+ r"syncState=(drifting|jumped)|lastError=(?!none\b|0\b)\S+)",+ re.IGNORECASE,+)+++@dataclass+class LogLine:+ number: int+ time: str+ category: str+ severity: str+ session: str | None+ message: str+ fields: dict[str, str] = field(default_factory=dict)++ def as_dict(self) -> dict:+ return {+ "n": self.number,+ "time": self.time,+ "category": self.category,+ "severity": self.severity,+ "session": self.session,+ "message": self.message,+ "fields": self.fields,+ }+++def classify_severity(message: str) -> str:+ """只把真正的失敗與警示拉出來。++ `failed=0`、`lastError=none`、累積的 `dropped=1` 都不是錯誤;+ 後者只代表總數,應該在欄位裡看,不該讓整段後續紀錄都變紅。+ """+ lowered = message.lower()+ for match in ERROR_WORDS.finditer(lowered):+ word = match.group(1)+ suffix = lowered[match.end():]+ value_match = re.match(r"\s*=\s*([^\s]+)", suffix)+ if value_match and value_match.group(1) in {"0", "none", "false", "no"}:+ continue+ return "error"+ for match in re.finditer(r"\berror\s*=\s*([^\s]+)", lowered):+ value = match.group(1)+ if value not in {"none", "0", "false", "no"}:+ return "error"+ if WARN_WORDS.search(lowered):+ return "warn"+ return "info"+++def parse_line(number: int, raw: str) -> LogLine:+ raw = raw.rstrip("\r\n")+ match = TIMESTAMP.match(raw)+ if match:+ time_text, body = match.group(1), match.group(2)+ else:+ time_text, body = "", raw++ if body.startswith("liveApp:"):+ body = body[len("liveApp:"):].lstrip()++ tag = TAG.search(body)+ category = tag.group(1) if tag else "Other"+ if not time_text and body.startswith("[BuildInfo]"):+ category = "BuildInfo"++ session_match = SESSION.search(body)+ session = session_match.group(1) if session_match else None++ fields: dict[str, str] = {}+ for key, value in FIELD.findall(body):+ # 只保留短值,避免把整段 JSON 或路徑拆成欄位。+ if len(value) <= 160:+ fields[key] = value++ return LogLine(+ number=number,+ time=time_text,+ category=category,+ severity=classify_severity(body),+ session=session,+ message=body,+ fields=fields,+ )+++def parse_log(path: Path) -> list[LogLine]:+ text = path.read_text(encoding="utf-8-sig", errors="replace")+ return [parse_line(index + 1, line) for index, line in enumerate(text.splitlines())]+++def summarize(path: Path, lines: list[LogLine]) -> dict:+ categories = Counter(line.category for line in lines)+ sessions = sorted({line.session for line in lines if line.session})+ times = [line.time for line in lines if line.time]+ warnings = sum(1 for line in lines if line.severity == "warn")+ errors = sum(1 for line in lines if line.severity == "error")+ return {+ "file": str(path),+ "range": f"{times[0]} ~ {times[-1]}" if times else "無時間戳",+ "lines": len(lines),+ "categories": [+ {"name": name, "count": count}+ for name, count in categories.most_common()+ ],+ "sessions": sessions,+ "warn": warnings,+ "error": errors,+ }+++class ViewerHandler(BaseHTTPRequestHandler):+ lines: list[LogLine] = []+ summary: dict = {}++ def log_message(self, format: str, *args) -> None: # noqa: A002,A003+ # 避免每個 API 請求都洗版;真正錯誤仍可從主控台看到。+ return++ def send_json(self, value: dict, status: int = 200) -> None:+ body = json.dumps(value, ensure_ascii=False).encode("utf-8")+ self.send_response(status)+ self.send_header("Content-Type", "application/json; charset=utf-8")+ self.send_header("Content-Length", str(len(body)))+ self.send_header("Cache-Control", "no-store")+ self.end_headers()+ self.wfile.write(body)++ def send_html(self) -> None:+ try:+ body = TEMPLATE.read_bytes()+ except OSError as error:+ self.send_error(500, f"找不到 diagnostic_viewer.html: {error}")+ return+ self.send_response(200)+ self.send_header("Content-Type", "text/html; charset=utf-8")+ self.send_header("Content-Length", str(len(body)))+ self.send_header("Cache-Control", "no-store")+ self.end_headers()+ self.wfile.write(body)++ def do_GET(self) -> None: # noqa: N802+ parsed = urlparse(self.path)+ if parsed.path in ("/", "/index.html"):+ self.send_html()+ return+ if parsed.path == "/favicon.ico":+ self.send_response(204)+ self.end_headers()+ return+ if parsed.path == "/api/summary":+ self.send_json(self.summary)+ return+ if parsed.path == "/api/lines":+ self.handle_lines(parse_qs(parsed.query))+ return+ self.send_error(404)++ def handle_lines(self, query: dict[str, list[str]]) -> None:+ def first(name: str) -> str:+ return (query.get(name) or [""])[0]++ category = first("category")+ severity = first("severity")+ session = first("session")+ search = first("q").lower()+ try:+ offset = max(0, int(first("offset") or "0"))+ limit = min(1000, max(1, int(first("limit") or "300")))+ except ValueError:+ self.send_json({"error": "offset/limit 必須是整數"}, 400)+ return++ def matches(line: LogLine) -> bool:+ if category and line.category != category:+ return False+ if severity and line.severity != severity:+ return False+ if session and line.session != session:+ return False+ if search:+ haystack = " ".join(+ [line.message, line.category, line.session or ""]+ + [f"{key}={value}" for key, value in line.fields.items()]+ ).lower()+ if search not in haystack:+ return False+ return True++ selected = [line for line in self.lines if matches(line)]+ page = selected[offset:offset + limit]+ self.send_json({+ "total": len(selected),+ "offset": offset,+ "limit": limit,+ "hasMore": offset + len(page) < len(selected),+ "lines": [line.as_dict() for line in page],+ })+++def bind_server(host: str, start_port: int, attempts: int = 20) -> ThreadingHTTPServer:+ """綁定指定 port;使用中時往下一個 port 嘗試,不讓小工具開不起來。"""+ last_error: OSError | None = None+ for offset in range(attempts):+ candidate = 0 if start_port == 0 else start_port + offset+ try:+ server = ThreadingHTTPServer((host, candidate), ViewerHandler)+ if offset:+ print(f"Port {start_port} 已被使用,改用 {server.server_address[1]}。")+ return server+ except OSError as error:+ last_error = error+ # Windows 對已占用 port 可能回 WinError 10013(WSAEACCES),+ # 而不是 Unix 常見的 EADDRINUSE。+ in_use = error.errno in (errno.EADDRINUSE, errno.EACCES, 10048)+ in_use = in_use or getattr(error, "winerror", None) == 10013+ if start_port == 0 or not in_use:+ raise+ raise SystemExit(+ f"Port {start_port}~{start_port + attempts - 1} 都無法使用:{last_error}"+ )+++def serve(path: Path, host: str, port: int, open_browser: bool) -> None:+ if not path.is_file():+ raise SystemExit(f"找不到日誌檔:{path}")+ lines = parse_log(path)+ ViewerHandler.lines = lines+ ViewerHandler.summary = summarize(path, lines)+ template = TEMPLATE+ if not template.is_file():+ raise SystemExit(f"找不到網頁模板:{template}")++ server = bind_server(host, port)+ actual_port = server.server_address[1]+ url = f"http://{host}:{actual_port}/"+ print(f"診斷檢視器:{url}")+ print(f"檔案:{path}({len(lines)} 行)")+ print("按 Ctrl+C 停止。")+ if open_browser:+ webbrowser.open(url)+ try:+ server.serve_forever()+ except KeyboardInterrupt:+ print("\n已停止。")+ finally:+ server.server_close()+++def main() -> None:+ if sys.platform == "win32":+ for stream in (sys.stdout, sys.stderr):+ reconfigure = getattr(stream, "reconfigure", None)+ if reconfigure is not None:+ reconfigure(encoding="utf-8")+ parser = argparse.ArgumentParser(description="ReplyKit 診斷日誌本機檢視器")+ parser.add_argument("log", type=Path, help="diagnostic-*.txt")+ parser.add_argument("--host", default="127.0.0.1")+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)+ parser.add_argument("--no-browser", action="store_true", help="不要自動開啟瀏覽器")+ args = parser.parse_args()+ serve(args.log.expanduser(), args.host, args.port, not args.no_browser)+++if __name__ == "__main__":+ main()diff --git a/Scripts/test_diagnostic_viewer.py b/Scripts/test_diagnostic_viewer.py--- /dev/null+++ b/Scripts/test_diagnostic_viewer.py@@ -0,0 +1,66 @@+import sys+import unittest+from pathlib import Path++sys.path.insert(0, str(Path(__file__).resolve().parent))++import diagnostic_viewer as viewer+++class DiagnosticViewerTests(unittest.TestCase):+ def parse(self, lines):+ return [viewer.parse_line(index + 1, line) for index, line in enumerate(lines)]++ def test_splits_category_session_and_fields(self):+ line = viewer.parse_line(+ 1,+ "2026/11/13 中午12:29:30: liveApp:[AVLag] "+ "session=C3EE5722-74BF-4529-A5AE-8E5B1CB27D98 "+ "sourcePTSDelta=-0.062865 syncState=drifting frames=510345/445933",+ )+ self.assertEqual(line.category, "AVLag")+ self.assertEqual(line.session, "C3EE5722-74BF-4529-A5AE-8E5B1CB27D98")+ self.assertEqual(line.severity, "warn")+ self.assertEqual(line.fields["sourcePTSDelta"], "-0.062865")+ self.assertEqual(line.fields["syncState"], "drifting")++ def test_zero_failures_are_not_errors(self):+ line = viewer.parse_line(+ 1,+ "2026/11/13 中午12:29:30: liveApp:[CaptureVideoProcessing] "+ "attempts=510345 processed=510345 failed=0 lastError=none",+ )+ self.assertEqual(line.severity, "info")++ def test_nonzero_errors_are_errors(self):+ line = viewer.parse_line(+ 1,+ "2026/11/13 下午2:58:17: liveApp:[CaptureTransport] "+ "Reconnect attempt failed attempt 1/5 error=endOfStream",+ )+ self.assertEqual(line.severity, "error")++ def test_cumulative_dropped_counter_does_not_mark_every_line(self):+ line = viewer.parse_line(+ 1,+ "2026/11/13 中午12:29:30: liveApp:[CaptureSource] "+ "capturePhase=streaming publishPhase=published "+ "video{accepted=170425 dropped=1 queued=0}",+ )+ self.assertEqual(line.severity, "info")++ def test_summary_counts_categories_and_sessions(self):+ lines = self.parse([+ "2026/11/13 中午12:29:30: liveApp:[AVLag] session=C3EE sourcePTSDelta=0",+ "2026/11/13 中午12:29:31: liveApp:[Memory] footprint=1",+ "2026/11/13 中午12:29:32: liveApp:[AVLag] session=C3EE syncState=drifting",+ ])+ summary = viewer.summarize(Path("sample.txt"), lines)+ self.assertEqual(summary["lines"], 3)+ self.assertEqual(summary["warn"], 1)+ self.assertEqual(summary["sessions"], ["C3EE"])+ self.assertEqual(summary["categories"][0]["name"], "AVLag")+++if __name__ == "__main__":+ unittest.main()diff --git a/TODO.md b/TODO.md--- a/TODO.md+++ b/TODO.md@@ -6,6 +6,14 @@ > 新待辦請寫在這裡,不要在各文件的角落另開 TODO 區塊;完成後改為 `[x]`(整批清理時可移除已完成項)。 +## 2026-11-13 核對摘要++- [x] AV Lag 進行中 segment 每 60 秒 checkpoint;停止時的 final save 會依序排在 checkpoint 後,避免舊文件覆蓋新文件。+- [x] AV Lag 目前報告與已保存 segment 改為分享 `.json` 檔案;不再只提供文字。報告改存 `Documents/AVLagReports`,可直接在「檔案」App → 我的 iPhone → 松鼠推流 → AVLagReports 取得;舊 Application Support 檔會自動搬移。+- [x] 新增 `Scripts/diagnostic_viewer.py`:把 `diagnostic-*.txt` 依分類/session/嚴重度拆開,支援搜尋與欄位展開;指定 port 被占用時自動往後找。+- [ ] 待實機驗證:長場次停止後分享的 JSON 應有完整 sampleCount;若只看到 `complete=false` 的檔案,先確認它是進行中 checkpoint,不是最終報告。+- [ ] 待實機驗證:診斷檢視器在 Windows PowerShell 與檔案路徑含空白/中文時可啟動。+ ## 2026-11-09 核對摘要 - 已實作並 push fork:音訊 main-clock fallback、active clock/PTS 重建、`AudioRingBuffer` Float32/Int32 指標修正、AVAudioConverter track 診斷;Keyframe automatic/PTS 重置;BufferCount 明確 policy;動態範圍錯誤回傳;相機 FPS 與 offscreen FPS 分離;VT Encoder Health(pending frames/drop 原因);Release 自動分類;2ch mixer 交錯格式的平台差異修正;每軌輸出 RMS/align 診斷;CI 摘要不再把測試斷言誤標 runner failure。diff --git a/liveAPP/Capture/AVLagModel.swift b/liveAPP/Capture/AVLagModel.swift--- a/liveAPP/Capture/AVLagModel.swift+++ b/liveAPP/Capture/AVLagModel.swift@@ -17,6 +17,14 @@ final class AVLagModel: ObservableObject { private var truncatedBefore = false private var estimatedSampleBytes = 0 private let maxSampleBytes = 2 * 1024 * 1024+ /// 長時間擷取時定期更新進行中 segment,避免 App 在背景被中止後只剩+ /// 進入背景當下的少量樣本。+ private let checkpointInterval: TimeInterval = 60+ private var lastCheckpointAt: Date?+ private var recordedSampleCount = 0+ private var lastCheckpointRecordedCount = 0+ /// 儲存任務必須依序完成;較舊的 checkpoint 不能覆蓋較新的 final 文件。+ private var persistTask: Task<Void, Never>? private var maxSamples: Int { max(1, AVLagSettings.maxSamplesPerSegment)@@ -48,6 +56,9 @@ final class AVLagModel: ObservableObject { segmentIndex = 0 segmentStartedAt = Date() truncatedBefore = false+ lastCheckpointAt = Date()+ recordedSampleCount = 0+ lastCheckpointRecordedCount = 0 } func record(_ observation: AVLagObservation) {@@ -58,16 +69,32 @@ final class AVLagModel: ObservableObject { } latest = observation samples.append(observation)+ recordedSampleCount += 1 estimatedSampleBytes += estimatedBytes(observation) trimLocked()+ checkpointIfNeeded(now: now) } - /// 背景時保存目前未完成 segment;畫面恢復後若繼續累積,停止時會覆寫為 complete。+ /// 進行中的檔案最多落後一個 checkpoint 週期;只在有新增樣本時寫入。+ private func checkpointIfNeeded(now: Date) {+ guard AVLagSettings.mode == .recentSessions,+ !samples.isEmpty,+ recordedSampleCount != lastCheckpointRecordedCount,+ now.timeIntervalSince(lastCheckpointAt ?? now) >= checkpointInterval else { return }+ persistInProgress(force: false, now: now)+ }++ /// 背景或定期 checkpoint 保存目前未完成 segment;停止時再覆寫為 complete。 func persistInProgress() {+ persistInProgress(force: true, now: Date())+ }++ private func persistInProgress(force: Bool, now: Date) { guard endedAt == nil, AVLagSettings.mode == .recentSessions, let session, let startedAt, let segmentStartedAt, !samples.isEmpty else { return }+ if !force, recordedSampleCount == lastCheckpointRecordedCount { return } persist( session: session, startedAt: startedAt,@@ -78,6 +105,8 @@ final class AVLagModel: ObservableObject { sessionComplete: false, truncatedBefore: truncatedBefore, samples: samples)+ lastCheckpointAt = now+ lastCheckpointRecordedCount = recordedSampleCount } func end(session: String? = nil) {@@ -105,6 +134,11 @@ final class AVLagModel: ObservableObject { } /// 供人工檢查與回放修復工具讀取的穩定 JSON;只含時間與量測值,不含串流金鑰。+ func reportFileURL() -> URL? {+ guard let session else { return nil }+ return AVLagReportExport.url(text: reportJSON(), session: session, segmentIndex: segmentIndex)+ }+ func reportJSON() -> String { guard let session else { return "{\"schema\":1,\"samples\":[]}" } let started = startedAt ?? Date()@@ -153,6 +187,8 @@ final class AVLagModel: ObservableObject { truncatedBefore = false segmentIndex += 1 self.segmentStartedAt = now+ lastCheckpointAt = now+ lastCheckpointRecordedCount = recordedSampleCount } private func trimLocked() {@@ -187,9 +223,18 @@ final class AVLagModel: ObservableObject { truncatedBefore: truncatedBefore, sampleCount: samples.count, samples: samples)- Task {- do { try await AVLagReportStore.shared.save(document) }- catch { sendlog(message: "[AVLagStore] save failed session=\(session) segment=\(segmentIndex) error=\(error)") }+ // 依呼叫順序串接,避免背景 checkpoint 晚於 final save 完成而蓋掉較新的內容。+ let previous = persistTask+ persistTask = Task {+ _ = await previous?.value+ do {+ try await AVLagReportStore.shared.save(document)+ if complete {+ sendlog(message: "[AVLagStore] saved session=\(session) segment=\(segmentIndex) samples=\(samples.count) complete=true")+ }+ } catch {+ sendlog(message: "[AVLagStore] save failed session=\(session) segment=\(segmentIndex) error=\(error)")+ } } } diff --git a/liveAPP/Capture/AVLagReportStore.swift b/liveAPP/Capture/AVLagReportStore.swift--- a/liveAPP/Capture/AVLagReportStore.swift+++ b/liveAPP/Capture/AVLagReportStore.swift@@ -55,6 +55,33 @@ private struct AVLagReportIndex: Codable { var sessions: [AVLagSessionSummary] = [] } +/// 產生可直接分享的 JSON 檔案;分享的是檔案 URL,不再只分享一大段文字。+enum AVLagReportExport {+ static func url(for document: AVLagReportDocument) -> URL? {+ url(text: document.jsonText, session: document.session,+ segmentIndex: document.segmentIndex)+ }++ static func url(text: String, session: String, segmentIndex: Int) -> URL? {+ let directory = FileManager.default.urls(for: .documentDirectory,+ in: .userDomainMask)[0]+ .appendingPathComponent("AVLagReports", isDirectory: true)+ let safeSession = session.map { character in+ character.isLetter || character.isNumber || character == "-" || character == "_"+ ? String(character) : "_"+ }.joined()+ let file = directory.appendingPathComponent("AVLag-\(safeSession)-\(segmentIndex).json")+ do {+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+ try Data(text.utf8).write(to: file, options: .atomic)+ return file+ } catch {+ sendlog(message: "[AVLagExport] write failed session=\(session) segment=\(segmentIndex) error=\(error)")+ return nil+ }+ }+}+ /// 只在停止、分段或背景時寫入;以 session 為保留單位,子 segment 一起刪除。 actor AVLagReportStore { static let shared = AVLagReportStore()@@ -66,10 +93,36 @@ actor AVLagReportStore { private let maxTotalBytes = AVLagSettings.maxTotalBytes private init() {- let base = FileManager.default.urls(for: .applicationSupportDirectory,- in: .userDomainMask)[0]- root = base.appendingPathComponent("AVLagReports", isDirectory: true)+ // 放在 Documents 才能透過 Info.plist 的 UIFileSharingEnabled 在+ // 「檔案」App 直接看到;Application Support 不會暴露給使用者。+ let documents = FileManager.default.urls(for: .documentDirectory,+ in: .userDomainMask)[0]+ root = documents.appendingPathComponent("AVLagReports", isDirectory: true) indexURL = root.appendingPathComponent("index.json")+ migrateLegacyReportsIfNeeded()+ }++ /// 舊版寫在 Application Support;搬到 Documents 後才看得到。+ private nonisolated func migrateLegacyReportsIfNeeded() {+ let legacy = FileManager.default.urls(for: .applicationSupportDirectory,+ in: .userDomainMask)[0]+ .appendingPathComponent("AVLagReports", isDirectory: true)+ guard legacy.path != root.path,+ FileManager.default.fileExists(atPath: legacy.path) else { return }+ do {+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)+ let items = try FileManager.default.contentsOfDirectory(+ at: legacy, includingPropertiesForKeys: nil)+ for item in items {+ let destination = root.appendingPathComponent(item.lastPathComponent)+ if !FileManager.default.fileExists(atPath: destination.path) {+ try FileManager.default.moveItem(at: item, to: destination)+ }+ }+ try? FileManager.default.removeItem(at: legacy)+ } catch {+ sendlog(message: "[AVLagStore] migrate failed error=\(error)")+ } } private func ensureDirectory() throws {diff --git a/liveAPP/Capture/AVLagReportView.swift b/liveAPP/Capture/AVLagReportView.swift--- a/liveAPP/Capture/AVLagReportView.swift+++ b/liveAPP/Capture/AVLagReportView.swift@@ -6,6 +6,8 @@ import UIKit struct AVLagReportView: View { @ObservedObject private var model = AVLagModel.shared @State private var reportText = ""+ @State private var reportFileURL: URL?+ @State private var lastFileRefresh = Date.distantPast @State private var copied = false @State private var summaries: [AVLagSessionSummary] = [] @State private var pendingDelete: AVLagSessionSummary?@@ -97,11 +99,19 @@ struct AVLagReportView: View { copied ? AppLanguage.localized("avLag.copied") : AppLanguage.localized("avLag.copy"), systemImage: copied ? "checkmark" : "doc.on.doc") }- ShareLink(item: reportText) {- Label(AppLanguage.localized("avLag.share"), systemImage: "square.and.arrow.up")+ if let reportFileURL {+ ShareLink(item: reportFileURL) {+ Label(AppLanguage.localized("avLag.share"), systemImage: "square.and.arrow.up")+ }+ } else {+ ShareLink(item: reportText) {+ Label(AppLanguage.localized("avLag.share"), systemImage: "square.and.arrow.up")+ } } Text(String(format: AppLanguage.localized("avLag.sampleCount"), model.samples.count)) .font(.caption).foregroundStyle(.secondary)+ Text(AppLanguage.localized("avLag.storage.location"))+ .font(.caption).foregroundStyle(.secondary) DisclosureGroup("JSON") { Text(reportText) .font(.caption2.monospaced())@@ -144,6 +154,12 @@ struct AVLagReportView: View { private func refreshReport() { reportText = model.reportJSON()+ // 分享檔不需要跟著每 5 秒的畫面刷新重寫;最多落後一分鐘,避免長場次+ // 在報告頁開著時持續寫入數百 KB。+ if reportFileURL == nil || Date().timeIntervalSince(lastFileRefresh) >= 60 {+ reportFileURL = model.reportFileURL()+ lastFileRefresh = Date()+ } copied = false } diff --git a/liveAPP/Capture/AVLagStoredSessionView.swift b/liveAPP/Capture/AVLagStoredSessionView.swift--- a/liveAPP/Capture/AVLagStoredSessionView.swift+++ b/liveAPP/Capture/AVLagStoredSessionView.swift@@ -6,6 +6,7 @@ struct AVLagStoredSessionView: View { let summary: AVLagSessionSummary @ObservedObject private var model = AVLagModel.shared @State private var documents: [AVLagReportDocument] = []+ @State private var shareURLs: [Int: URL] = [:] @State private var isLoading = true private var isActiveSession: Bool {@@ -56,9 +57,16 @@ struct AVLagStoredSessionView: View { Text("\(document.sampleCount) samples") .font(.caption) .foregroundStyle(.secondary)- ShareLink(item: document.jsonText) {- Label(AppLanguage.localized("avLag.stored.share"),- systemImage: "square.and.arrow.up")+ if let url = shareURLs[document.segmentIndex] {+ ShareLink(item: url) {+ Label(AppLanguage.localized("avLag.stored.share"),+ systemImage: "square.and.arrow.up")+ }+ } else {+ ShareLink(item: document.jsonText) {+ Label(AppLanguage.localized("avLag.stored.share"),+ systemImage: "square.and.arrow.up")+ } } } }@@ -68,6 +76,9 @@ struct AVLagStoredSessionView: View { .navigationTitle(AppLanguage.localized("avLag.stored.title")) .task { documents = await AVLagReportStore.shared.documents(for: summary.id)+ shareURLs = Dictionary(uniqueKeysWithValues: documents.compactMap { document in+ AVLagReportExport.url(for: document).map { (document.segmentIndex, $0) }+ }) isLoading = false } }diff --git a/liveAPP/Localizable.xcstrings b/liveAPP/Localizable.xcstrings--- a/liveAPP/Localizable.xcstrings+++ b/liveAPP/Localizable.xcstrings@@ -13,6 +13,7 @@ "appIcon.timeout": {"localizations": {"zh-Hant": {"stringUnit": {"state": "translated", "value": "系統沒有回應圖示切換。請稍後再試;若主畫面圖示已改變但 App 狀態未更新,請重新啟動裝置或重新安裝。"}}, "en": {"stringUnit": {"state": "translated", "value": "The system did not respond to the icon change. Try again later; if the Home Screen icon changed but the app state did not, restart the device or reinstall."}}, "ja": {"stringUnit": {"state": "translated", "value": "システムがアイコン変更に応答しませんでした。時間をおいて再試行してください。ホーム画面のアイコンが変わったのにアプリの状態が更新されない場合は、端末を再起動するか再インストールしてください。"}}}}, "avLag.title": {"localizations": {"zh-Hant": {"stringUnit": {"state": "translated", "value": "AV Lag 報告"}}, "en": {"stringUnit": {"state": "translated", "value": "AV Lag Report"}}, "ja": {"stringUnit": {"state": "translated", "value": "AV Lag レポート"}}}}, "avLag.current": {"localizations": {"zh-Hant": {"stringUnit": {"state": "translated", "value": "目前"}}, "en": {"stringUnit": {"state": "translated", "value": "Current"}}, "ja": {"stringUnit": {"state": "translated", "value": "現在"}}}},+ "avLag.storage.location": {"localizations": {"zh-Hant": {"stringUnit": {"state": "translated", "value": "報告檔案在「檔案」App → 我的 iPhone → 松鼠推流 → AVLagReports,可直接開啟或上傳分析。"}}, "en": {"stringUnit": {"state": "translated", "value": "Reports are in Files → On My iPhone → ReplyKit → AVLagReports, ready to open or upload."}}, "ja": {"stringUnit": {"state": "translated", "value": "レポートはファイル App → このiPhone内 → ReplyKit → AVLagReports にあり、直接開いたりアップロードできます。"}}}}, "avLag.purpose": {"localizations": {"zh-Hant": {"stringUnit": {"state": "translated", "value": "用途"}}, "en": {"stringUnit": {"state": "translated", "value": "Purpose"}}, "ja": {"stringUnit": {"state": "translated", "value": "用途"}}}}, "avLag.report": {"localizations": {"zh-Hant": {"stringUnit": {"state": "translated", "value": "報告"}}, "en": {"stringUnit": {"state": "translated", "value": "Report"}}, "ja": {"stringUnit": {"state": "translated", "value": "レポート"}}}}, "avLag.open": {"localizations": {"zh-Hant": {"stringUnit": {"state": "translated", "value": "查看目前 AV Lag 報告"}}, "en": {"stringUnit": {"state": "translated", "value": "View Current AV Lag Report"}}, "ja": {"stringUnit": {"state": "translated", "value": "現在の AV Lag レポートを表示"}}}},