diff --git a/tools/transcript-editor/README.md b/tools/transcript-editor/README.md new file mode 100644 index 000000000..0dc0bd8c4 --- /dev/null +++ b/tools/transcript-editor/README.md @@ -0,0 +1,56 @@ +# Transcript Editor + +A tiny zero-dependency helper for manually correcting the **auto-transcribed subtitle text** that OpenScreen generates. + +> Whisper transcribes on-device, but it often mis-recognizes spoken words (especially Chinese names and colloquial terms), and those errors land directly in the subtitles. OpenScreen deliberately treats captions as a *derived view* of the transcript — there is no in-app UI to edit caption text directly. This helper fills that gap: it lets you edit the transcript words like a document, and the captions follow. + +![screenshot](docs/screenshot.png) + +## What it does + +- **Edit transcript words line by line.** Each token is a clean input box. Save, reopen OpenScreen, and the captions show your corrected text. +- **Auto-expanding inputs.** A long token (a whole sentence stored as one word) makes its box grow so you can read the full line without scrolling horizontally. +- **Clear junk tokens.** Whisper often emits placeholders like `(听不懂)` ("can't understand"). Click the ✕ to blank a token — it's then skipped in the captions. +- **Automatic backup.** Every save writes a `.bak-` beside the project file first, so a bad edit is always revertible. +- **Revert button.** Jump back to the on-disk version, discarding unsaved edits. +- **Multiple projects.** Auto-lists every project in your OpenScreen projects directory — pick one, edit, save. + +## Why it exists + +OpenScreen's captions are a **derived view** of the transcript (see [`src/lib/ai-edition/captions/settings.ts`](../../src/lib/ai-edition/captions/settings.ts): *"the transcript stays the single source of truth"*, *"nothing here stores caption text"*). To change caption text you change the underlying transcript words — `doc.transcripts[].words[].text`. That file (`.openscreen`) is structured JSON, and editing it by hand is both error-prone and tedious. This tool wraps that JSON in a clean per-line editing UI, so you never touch the JSON structure. + +This is a **standalone helper** — it reads and writes OpenScreen's own project JSON; it does **not** modify, bundle, or fork OpenScreen itself. + +## Usage + +1. **Fully quit OpenScreen** first (including the tray icon). Otherwise it will write stale data back over your edits on its next save. +2. Start the tool — a browser tab opens at `http://127.0.0.1:8765`: + + ```bash + python transcript_editor_server.py + ``` + +3. Pick a project in the dropdown, click **加载转写 (Load transcript)**. +4. Edit the boxes; click ✕ to blank a junk token. +5. Click **💾 保存修改 (Save)** (auto-backup) — then reopen OpenScreen to see the corrected captions. + +**Python standard library only.** No `pip install`, no Node, no npm. Works on Windows and macOS. + +### Overriding the projects directory + +By default the tool locates projects at `~/AppData/Roaming/openscreen/projects` (Windows) or `~/Library/Application Support/openscreen/projects` (macOS). Override with the env var `OPENSCREEN_PROJECTS_DIR` for a non-default install location: + +```bash +OPENSCREEN_PROJECTS_DIR="/path/to/projects" python transcript_editor_server.py +``` + +## Which file it edits + +- Each OpenScreen project is one `.openscreen` JSON file in its `projects/` directory. +- Caption text lives at `doc.transcripts[].words[].text` (one entry per word) and is the true source the captions render from. +- The tool edits those `words[].text` values and also syncs the matching `segments[].text`, keeping both layers consistent. + +## Notes + +- **Why subtitles look broken into few words?** That's OpenScreen's caption wrapping (`minWordsPerLine`/`maxWordsPerLine`), not this helper. This tool edits text only — line wrapping stays with OpenScreen's caption settings. +- The tool is intentionally narrow (YAGNI): it edits transcript text, nothing else. diff --git a/tools/transcript-editor/docs/screenshot.png b/tools/transcript-editor/docs/screenshot.png new file mode 100644 index 000000000..6763def3d Binary files /dev/null and b/tools/transcript-editor/docs/screenshot.png differ diff --git a/tools/transcript-editor/transcript_editor_server.py b/tools/transcript-editor/transcript_editor_server.py new file mode 100644 index 000000000..95c116c6a --- /dev/null +++ b/tools/transcript-editor/transcript_editor_server.py @@ -0,0 +1,613 @@ +# -*- coding: utf-8 -*- +"""OpenScreen 转写改字器 — 本地单文件工具 + +一个零依赖的本地网页小工具,用来编辑 OpenScreen 项目(.openscreen)里的 +自动转写(Whisper 转写的字幕文字)。它把一段段转写显示成干净的输入框, +改完保存会自动备份原文件并写回,重开 OpenScreen 即可看到修正后的字幕。 + +特点: +- 每个词一个可编辑输入框,长句子自动加宽,不用右滑就能看全。 +- 无效词(如 Whisper 的 "(听不懂)" 占位)可一键清空,字幕里会跳过它。 +- 保存前自动备份(.bak-<时间戳>),改坏了随时可回滚。 +- "撤销修改"按钮回到磁盘版本,放弃未保存的修改。 +- 零依赖:只用 Python 标准库,双击就能跑。 + +用法:python transcript_editor_server.py,浏览器打开 http://127.0.0.1:8765。 +也可以设置环境变量 OPENSCREEN_PROJECTS_DIR 覆盖 OpenScreen 项目目录 +(默认 ~/AppData/Roaming/openscreen/projects),用于非默认安装或其它系统。 +""" +import json +import os +import shutil +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse + +# OpenScreen 的项目文件目录。默认按平台惯例定位;可用环境变量覆盖, +# 这样非默认安装位置或其它平台(macOS ~/Library/Application Support/...)也能用。 +DEFAULT_PROJECTS_TRIES = [ + os.path.expanduser("~/AppData/Roaming/openscreen/projects"), + os.path.expanduser("~/Library/Application Support/openscreen/projects"), +] +PROJECTS_DIR = os.environ.get("OPENSCREEN_PROJECTS_DIR") or next( + (p for p in DEFAULT_PROJECTS_TRIES if os.path.isdir(p)), DEFAULT_PROJECTS_TRIES[0] +) +PORT = 8765 + +PAGE = r""" + + + +OpenScreen 转写改字器 + + + +
+

📝 OpenScreen 转写改字器

+ 改字 → 保存 → 重开 OpenScreen 即生效 +
+ +
⚠️ 编辑前请完全退出 OpenScreen(托盘图标也退出),否则它的旧数据会在下次保存时覆盖你的修改。保存会自动备份原文件。
+ +
+
+ + + + + + + +
+
+
加载项目后在这里显示转写,每个词一个输入框,直接改字即可。
+
+ +
+ + + + +""" + + +# 统一的 transcript key:有持久化 id 用 id,否则用 "transcript[]"。 +# 让 metadata 构建、get 选择、load 校验和 save 选择都走这一套,避免 key 不一致。 +def _transcript_key(tr, idx): + return tr.get("id") or ("transcript[%d]" % idx) + + +def load_project(path, active_id=None): + """返回 (doc, segments_view, word_map, title, language, transcripts_meta, active_id) + + transcripts_meta 列出项目里所有 transcript 的概览(用于前端下拉); + segments/word_map 对应 active_id 指定的那条 transcript(默认第一条)。 + """ + with open(path, "r", encoding="utf-8") as f: + doc = json.load(f) + transcripts = doc.get("transcripts") or [] + # 兼容旧字段 transcript(无 transcripts 数组时用顶层 transcript 包装) + if not transcripts and isinstance(doc.get("transcript"), dict): + transcripts = [doc["transcript"]] + title = (doc.get("project") or {}).get("title", os.path.basename(path)) + + transcripts_meta = [] + for idx, tr in enumerate(transcripts): + transcripts_meta.append({ + "index": idx, + "id": _transcript_key(tr, idx), + "assetId": tr.get("assetId") or "", + "language": tr.get("language") or "?", + "wordCount": len(tr.get("words") or []), + "segmentCount": len(tr.get("segments") or []), + }) + + # 按 active_id 选出目标;active_id 非空但找不到要报错,不静默回退第一个。 + active = None + active_idx = 0 + if active_id: + for idx, tr in enumerate(transcripts): + if _transcript_key(tr, idx) == active_id: + active = tr + active_idx = idx + break + if active is None: + raise ValueError("指定的转写不存在") + else: + active = transcripts[0] if transcripts else {} + active_id = _transcript_key(active, 0) if transcripts else "" + + language = active.get("language") or "" + word_map = {} + segments = [] + for w in active.get("words") or []: + word_map[w["id"]] = w + for s in active.get("segments") or []: + segments.append({ + "id": s["id"], + "startSec": s.get("startSec", 0), + "endSec": s.get("endSec", 0), + "wordIds": s.get("wordIds", []), + }) + return doc, segments, word_map, title, language, transcripts_meta, active_id + + +# 取语言的主 sub-tag(如 zh-CN -> zh),用于决定 join 策略。 +def _language_primary(language): + return (language or "").split("-")[0].strip().lower() + + +# 中文/日文的词之间不插空格("你好世界" 不能被 join 成 "你好 世界"); +# 其它语言保留空格。按主 sub-tag 判断,因此 zh-CN / zh-TW / ja-JP 也走无空格。 +def _join_segment_text(pieces, language): + kept = [p for p in pieces if p and p.strip()] + if not kept: + return "" + base = _language_primary(language) + return "".join(kept) if base in ("zh", "ja") else " ".join(kept) + + +def _select_transcript(transcripts, transcript_id): + """按统一 key 选出目标 transcript。 + + transcript_id 为空时回退到第一个;(非空) 找不到则抛错,绝不静默改到别的 transcript。 + 返回 (transcript, index)。 + """ + if transcript_id: + for idx, tr in enumerate(transcripts): + if _transcript_key(tr, idx) == transcript_id: + return tr, idx + raise ValueError("指定的转写不存在") + return (transcripts[0], 0) if transcripts else (None, -1) + + +def _same_transcript(a, b): + """判断两份 transcript 对象是否指向同一条。 + + 仅在两者是同一对象,或两者都有非空 persisted id 且匹配时才返回 True。 + 不依赖 assetId / 首个单词 id 等不稳定字段,避免把不同 transcript 误判为同一份。 + """ + if a is b: + return True + aid = a.get("id") or "" + bid = b.get("id") or "" + if aid and bid: + return aid == bid + return False + + +def save_words(path, words_by_id, transcript_id=None): + """把用户编辑的 words 写回;逐 segment 重建 text;备份原文件。 + + 只更新选定 transcript_id 对应的那条 transcript;transcript_id 为空时回退到第一个。 + """ + with open(path, "r", encoding="utf-8") as f: + doc = json.load(f) + transcripts = doc.get("transcripts") or [] + if not transcripts and isinstance(doc.get("transcript"), dict): + transcripts = [doc["transcript"]] + # 兼容旧字段:顶层 transcript 与 transcripts[] 可能是同一份(旧项目只有顶层) + legacy = doc.get("transcript") if isinstance(doc.get("transcript"), dict) else None + + tr, idx = _select_transcript(transcripts, transcript_id) + if tr is None: + raise ValueError("该项目没有 transcripts 数组") + language = tr.get("language") or (legacy or {}).get("language") or "" + new_words = {} + for item in words_by_id: + new_words[item["id"]] = item["text"] + touched = 0 + for w in tr.get("words") or []: + if w["id"] in new_words and new_words[w["id"]] != w.get("text", ""): + w["text"] = new_words[w["id"]] + touched += 1 + # 从更新后的 tr["words"] 重建 text map(保留未提交词的原始值,避免 partial payload 清空 segment) + text_by_id = {w["id"]: w.get("text", "") for w in tr.get("words") or []} + # 重建每个 segment 的 text(与 words 保持一致) + for s in tr.get("segments") or []: + parts = [text_by_id.get(wid, "") for wid in s.get("wordIds", [])] + s["text"] = _join_segment_text(parts, language) + # 仅当 legacy 字段确实就是 transcripts 里的那条时同步,避免误改不同内容。 + if legacy and _same_transcript(legacy, tr): + if legacy.get("words"): + for w in legacy["words"]: + if w["id"] in new_words and new_words[w["id"]] != w.get("text", ""): + w["text"] = new_words[w["id"]] + # legacy 也从更新后的 legacy["words"] 重建,保持与 words 一致 + legacy_text = {w["id"]: w.get("text", "") for w in legacy.get("words") or []} + for s in legacy.get("segments") or []: + parts = [legacy_text.get(wid, "") for wid in s.get("wordIds", [])] + s["text"] = _join_segment_text(parts, language) + # 备份:加纳秒时间戳防同一秒两次保存互相覆盖;先写临时文件再原子替换, + # 避免 json.dump 写到一半中断导致项目文件损坏。 + backup = path + ".bak-" + time.strftime("%Y%m%d-%H%M%S-") + str(time.time_ns()) + shutil.copy2(path, backup) + temp_path = path + ".tmp-" + str(os.getpid()) + "-" + str(time.time_ns()) + try: + # 先读原文件权限,保存后再还原,避免 os.replace 把 restrictive mode 覆盖掉 + original_mode = 0o600 + try: + original_mode = 0o777 & os.stat(path).st_mode + except OSError: + pass + # 临时文件先用 restrictive mode 创建 + fd = os.open(temp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(doc, f, ensure_ascii=False, indent=2) + f.flush() + os.fsync(f.fileno()) + # 把原文件 mode 应用到临时文件,确保 os.replace 后 live project 保持原权限 + try: + os.chmod(temp_path, original_mode) + except OSError: + pass + os.replace(temp_path, path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + return os.path.basename(backup), touched + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def _send(self, obj, code=200): + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_json(self): + length = int(self.headers.get("Content-Length") or 0) + if length <= 0: + return {} + return json.loads(self.rfile.read(length).decode("utf-8")) + + def _check_request(self): + # 只接受来自本工具页面的同源 JSON 请求,防止外部网页跨站调用。 + content_type = (self.headers.get("Content-Type") or "").split(";", 1)[0].strip() + origin = self.headers.get("Origin") + if content_type != "application/json": + raise ValueError("请使用 application/json 提交") + if origin and origin != "http://127.0.0.1:%d" % PORT: + raise ValueError("拒绝跨站请求") + + @staticmethod + def _resolve_project_path(raw_path): + # 归一化并限制在 PROJECTS_DIR 内,防止路径穿越。 + root = os.path.realpath(PROJECTS_DIR) + path = os.path.realpath(raw_path) + try: + inside = os.path.commonpath([root, path]) == root + except ValueError: + inside = False + if not inside or not path.endswith(".openscreen"): + raise ValueError("无效的项目路径") + return path + + def do_GET(self): + if urlparse(self.path).path in ("/", "/index.html"): + body = PAGE.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self._send({"ok": False, "error": "not found"}, 404) + + def do_POST(self): + try: + self._check_request() + data = self._read_json() + action = urlparse(self.path).path.rsplit("/", 1)[-1] + if action == "projects": + out = [] + if os.path.isdir(PROJECTS_DIR): + for fn in sorted(os.listdir(PROJECTS_DIR)): + if not fn.endswith(".openscreen"): + continue + p = os.path.join(PROJECTS_DIR, fn) + try: + with open(p, "r", encoding="utf-8") as f: + title = (json.load(f).get("project") or {}).get( + "title", fn + ) + except (OSError, ValueError): + title = fn + out.append({ + "path": p, + "name": fn, + "title": title, + "modified": time.strftime( + "%Y-%m-%d %H:%M", time.localtime(os.path.getmtime(p)) + ), + }) + self._send({"ok": True, "projects": out}) + elif action == "load": + path = self._resolve_project_path(data.get("path", "")) + if not os.path.isfile(path): + raise ValueError("文件不存在") + # 支持选定某条 transcript(默认第一条) + active_id = data.get("transcriptId") or "" + if active_id: + with open(path, "r", encoding="utf-8") as f: + raw_doc = json.load(f) + all_tr = raw_doc.get("transcripts") or [] + # 与 load_project 保持一致:legacy-only 项目把顶层 transcript 包装进去 + if not all_tr and isinstance(raw_doc.get("transcript"), dict): + all_tr = [raw_doc["transcript"]] + keys = [_transcript_key(t, i) for i, t in enumerate(all_tr)] + if active_id not in keys: + raise ValueError("指定的转写不存在") + _doc, segments, word_map, title, language, transcripts_meta, active_id = load_project(path, active_id) + self._send({ + "ok": True, + "title": title, + "language": language, + "segments": segments, + "words": word_map, + "transcripts": transcripts_meta, + "activeId": active_id, + }) + elif action == "save": + path = self._resolve_project_path(data.get("path", "")) + words = data.get("words", []) + if not words: + raise ValueError("没有收到修改内容") + transcript_id = data.get("transcriptId") or "" + backup, touched = save_words(path, words, transcript_id) + self._send({"ok": True, "backup": backup, "touched": touched}) + elif action == "export": + path = self._resolve_project_path(data.get("path", "")) + with open(path, "r", encoding="utf-8") as f: + raw = f.read() + import base64 + b64 = base64.b64encode(raw.encode("utf-8")).decode("ascii") + self._send({"ok": True, "name": os.path.basename(path), "b64": b64}) + else: + self._send({"ok": False, "error": "unknown action"}, 404) + except Exception as e: + self._send({"ok": False, "error": str(e)}, 500) + + +def main(): + os.makedirs(PROJECTS_DIR, exist_ok=True) + server = HTTPServer(("127.0.0.1", PORT), Handler) + webbrowser.open("http://127.0.0.1:%d" % PORT) + print("OpenScreen 转写改字器运行中: http://127.0.0.1:%d (Ctrl+C 退出)" % PORT) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +if __name__ == "__main__": + main() \ No newline at end of file