From 28114d80ba08dcf18e8b8eda05aee64cbcc4c43d Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:46:23 +0900 Subject: [PATCH 1/8] Add versioned transcript exchange format --- transcript_exchange.py | 294 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 transcript_exchange.py diff --git a/transcript_exchange.py b/transcript_exchange.py new file mode 100644 index 0000000..b0282b0 --- /dev/null +++ b/transcript_exchange.py @@ -0,0 +1,294 @@ +"""Machine-readable transcript exchange for VibeCoding_VideoAnalyzer. + +This module is intentionally standard-library only. It can be tested without +loading VLC, Torch, Faster-Whisper, or any GUI dependency. +""" + +from __future__ import annotations + +import json +import math +import os +import tempfile +from dataclasses import asdict, is_dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional + +SCHEMA_ID = "vibe-video-analyzer/transcript" +SCHEMA_VERSION = 1 +MAX_SEGMENTS = 100_000 +MAX_WORDS = 1_000_000 + + +class TranscriptExchangeError(ValueError): + """Raised when transcript data cannot be represented safely.""" + + +_MISSING = object() + + +def _field(value: Any, *names: str, default: Any = _MISSING) -> Any: + if is_dataclass(value): + value = asdict(value) + if isinstance(value, Mapping): + for name in names: + if name in value: + return value[name] + else: + for name in names: + if hasattr(value, name): + return getattr(value, name) + if default is not _MISSING: + return default + raise TranscriptExchangeError(f"Missing required field: {' or '.join(names)}") + + +def _number(value: Any, label: str, *, minimum: float = 0.0) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TranscriptExchangeError(f"{label} must be a number.") + result = float(value) + if not math.isfinite(result): + raise TranscriptExchangeError(f"{label} must be finite.") + if result < minimum: + raise TranscriptExchangeError(f"{label} must be at least {minimum}.") + return result + + +def _text(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise TranscriptExchangeError(f"{label} must be a non-empty string.") + return value.strip() + + +def normalize_word(value: Any, *, label: str = "word") -> Dict[str, Any]: + """Normalize TranscriptWord/dict/object into the exchange word shape.""" + + text = _text(_field(value, "text", "word"), f"{label}.text") + start = _number(_field(value, "startSeconds", "s", "start"), f"{label}.startSeconds") + end = _number(_field(value, "endSeconds", "e", "end"), f"{label}.endSeconds") + if end <= start: + raise TranscriptExchangeError(f"{label}.endSeconds must be greater than startSeconds.") + + normalized: Dict[str, Any] = { + "text": text, + "startSeconds": round(start, 6), + "endSeconds": round(end, 6), + } + confidence = _field(value, "confidence", default=None) + if confidence is not None: + confidence_value = _number(confidence, f"{label}.confidence") + if confidence_value > 1: + raise TranscriptExchangeError(f"{label}.confidence must be between 0 and 1.") + normalized["confidence"] = round(confidence_value, 6) + return normalized + + +def normalize_segment(value: Any, *, index: int) -> Dict[str, Any]: + """Normalize TranscriptSegment/dict/object into the exchange segment shape.""" + + label = f"segments[{index}]" + start = _number(_field(value, "startSeconds", "s", "start"), f"{label}.startSeconds") + end = _number(_field(value, "endSeconds", "e", "end"), f"{label}.endSeconds") + if end <= start: + raise TranscriptExchangeError(f"{label}.endSeconds must be greater than startSeconds.") + text = _text(_field(value, "text", "t"), f"{label}.text") + + raw_words = _field(value, "words", default=[]) or [] + if not isinstance(raw_words, (list, tuple)): + raise TranscriptExchangeError(f"{label}.words must be a list.") + + words: List[Dict[str, Any]] = [] + previous_word_start = -math.inf + for word_index, raw_word in enumerate(raw_words): + word = normalize_word(raw_word, label=f"{label}.words[{word_index}]") + if word["startSeconds"] < previous_word_start: + raise TranscriptExchangeError(f"{label}.words must be sorted by startSeconds.") + previous_word_start = word["startSeconds"] + words.append(word) + + return { + "id": f"segment-{index + 1}", + "startSeconds": round(start, 6), + "endSeconds": round(end, 6), + "durationSeconds": round(end - start, 6), + "text": text, + "words": words, + } + + +def normalize_segments(values: Iterable[Any]) -> List[Dict[str, Any]]: + if not isinstance(values, (list, tuple)): + values = list(values) + if len(values) > MAX_SEGMENTS: + raise TranscriptExchangeError(f"Transcript exceeds the {MAX_SEGMENTS}-segment limit.") + + normalized: List[Dict[str, Any]] = [] + previous_start = -math.inf + total_words = 0 + for index, value in enumerate(values): + segment = normalize_segment(value, index=index) + if segment["startSeconds"] < previous_start: + raise TranscriptExchangeError("Segments must be sorted by startSeconds.") + previous_start = segment["startSeconds"] + total_words += len(segment["words"]) + if total_words > MAX_WORDS: + raise TranscriptExchangeError(f"Transcript exceeds the {MAX_WORDS}-word limit.") + normalized.append(segment) + return normalized + + +def build_transcript_document( + media_path: str, + segments: Iterable[Any], + *, + duration_seconds: Optional[float] = None, + engine: Optional[Mapping[str, Any]] = None, + created_at: Optional[str] = None, +) -> Dict[str, Any]: + """Build a versioned JSON-compatible transcript document.""" + + if not isinstance(media_path, str) or not media_path.strip(): + raise TranscriptExchangeError("media_path must be a non-empty string.") + resolved_media = str(Path(media_path).expanduser().resolve()) + normalized_segments = normalize_segments(segments) + + if duration_seconds is None: + duration = normalized_segments[-1]["endSeconds"] if normalized_segments else 0.0 + else: + duration = _number(duration_seconds, "duration_seconds") + + created = created_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + if not isinstance(created, str) or not created.strip(): + raise TranscriptExchangeError("created_at must be a non-empty string.") + + document: Dict[str, Any] = { + "schema": SCHEMA_ID, + "schemaVersion": SCHEMA_VERSION, + "createdAt": created, + "media": { + "path": resolved_media, + "fileName": Path(resolved_media).name, + "durationSeconds": round(duration, 6), + }, + "segments": normalized_segments, + "summary": { + "segmentCount": len(normalized_segments), + "wordCount": sum(len(segment["words"]) for segment in normalized_segments), + }, + } + if engine: + try: + json.dumps(engine, ensure_ascii=False) + except (TypeError, ValueError) as exc: + raise TranscriptExchangeError(f"engine metadata must be JSON serializable: {exc}") from exc + document["engine"] = dict(engine) + return document + + +def validate_transcript_document(document: Any) -> Dict[str, Any]: + """Validate and normalize a previously serialized exchange document.""" + + if not isinstance(document, Mapping): + raise TranscriptExchangeError("Transcript document must be an object.") + if document.get("schema") != SCHEMA_ID: + raise TranscriptExchangeError(f"Unsupported transcript schema: {document.get('schema')!r}.") + if document.get("schemaVersion") != SCHEMA_VERSION: + raise TranscriptExchangeError( + f"Unsupported transcript schemaVersion: {document.get('schemaVersion')!r}." + ) + + media = document.get("media") + if not isinstance(media, Mapping): + raise TranscriptExchangeError("media must be an object.") + media_path = _text(media.get("path"), "media.path") + duration = media.get("durationSeconds") + engine = document.get("engine") if isinstance(document.get("engine"), Mapping) else None + return build_transcript_document( + media_path, + document.get("segments", []), + duration_seconds=duration, + engine=engine, + created_at=_text(document.get("createdAt"), "createdAt"), + ) + + +def _serialize(document: Mapping[str, Any]) -> str: + return json.dumps(document, ensure_ascii=False, indent=2, sort_keys=False) + "\n" + + +def write_transcript_json( + output_path: str, + document: Mapping[str, Any], + *, + overwrite: bool = False, +) -> str: + """Atomically write a transcript JSON document. + + Existing files are preserved unless overwrite=True is explicit. + """ + + normalized = validate_transcript_document(document) + destination = Path(output_path).expanduser().resolve() + if destination.suffix.lower() != ".json": + raise TranscriptExchangeError("output_path must end in .json.") + destination.parent.mkdir(parents=True, exist_ok=True) + payload = _serialize(normalized) + + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="\n", + prefix=f".{destination.name}.", + suffix=".tmp", + dir=str(destination.parent), + delete=False, + ) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + temp_path = Path(handle.name) + + if overwrite: + os.replace(str(temp_path), str(destination)) + temp_path = None + else: + try: + os.link(str(temp_path), str(destination)) + except FileExistsError as exc: + raise TranscriptExchangeError( + f"Output already exists: {destination}. Use --overwrite to replace it." + ) from exc + except OSError: + try: + with destination.open("x", encoding="utf-8", newline="\n") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + except FileExistsError as exc: + raise TranscriptExchangeError( + f"Output already exists: {destination}. Use --overwrite to replace it." + ) from exc + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + temp_path = None + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + return str(destination) + + +def load_transcript_json(input_path: str) -> Dict[str, Any]: + path = Path(input_path).expanduser().resolve() + try: + with path.open("r", encoding="utf-8") as handle: + document = json.load(handle) + except FileNotFoundError as exc: + raise TranscriptExchangeError(f"Transcript file does not exist: {path}") from exc + except json.JSONDecodeError as exc: + raise TranscriptExchangeError(f"Invalid transcript JSON at line {exc.lineno}: {exc.msg}") from exc + return validate_transcript_document(document) From bd745821504d82f45b411b2b34862f635effc470 Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:46:54 +0900 Subject: [PATCH 2/8] Add headless transcript JSON CLI --- transcribe_cli.py | 167 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 transcribe_cli.py diff --git a/transcribe_cli.py b/transcribe_cli.py new file mode 100644 index 0000000..d288694 --- /dev/null +++ b/transcribe_cli.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Headless transcript exporter for VibeCoding_VideoAnalyzer. + +Heavy project dependencies are imported only after argument parsing, so +`python transcribe_cli.py --help` works even before the runtime is restored. +""" + +from __future__ import annotations + +import argparse +import gc +import sys +import threading +from pathlib import Path +from typing import Any, Iterable, List + +from transcript_exchange import ( + TranscriptExchangeError, + build_transcript_document, + write_transcript_json, +) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "Transcribe a local video/audio file with the existing Vibe analyzer " + "engine and preserve segment/word timestamps in versioned JSON." + ) + ) + parser.add_argument("input", help="Local video or audio path.") + parser.add_argument( + "-o", + "--output", + help="Output JSON path. Defaults to .vibe-transcript.json.", + ) + parser.add_argument("--language", default="ko", help="Whisper language code or auto.") + parser.add_argument("--device", default="auto", help="Existing engine device mode.") + parser.add_argument("--model", default="large-v3-turbo", help="Model id or local model directory.") + parser.add_argument("--beam-size", type=int, default=5) + parser.add_argument("--min-silence-ms", type=int, default=2000) + parser.add_argument("--speech-pad-ms", type=int, default=250) + parser.add_argument("--vad-threshold", type=float, default=0.35) + parser.add_argument("--denoise", action="store_true") + parser.add_argument("--dominant-speaker", action="store_true") + parser.add_argument("--whisper-vad", action="store_true") + parser.add_argument("--silero-vad", action="store_true") + parser.add_argument("--whisperx-align", action="store_true") + parser.add_argument("--remove-punctuation", action="store_true") + parser.add_argument("--overwrite", action="store_true") + return parser + + +def _default_output_path(input_path: Path) -> Path: + return input_path.with_name(f"{input_path.stem}.vibe-transcript.json") + + +def _collect_segments(generator: Iterable[Any]) -> List[Any]: + segments: List[Any] = [] + for item in generator: + if isinstance(item, dict) and item.get("is_heartbeat"): + progress = item.get("progress") + if isinstance(progress, (int, float)): + print(f"[transcribe] {progress:5.1f}%", file=sys.stderr) + continue + segments.append(item) + return segments + + +def run(args: argparse.Namespace) -> str: + input_path = Path(args.input).expanduser().resolve() + if not input_path.is_file(): + raise TranscriptExchangeError(f"Input media does not exist: {input_path}") + output_path = Path(args.output).expanduser().resolve() if args.output else _default_output_path(input_path) + + # Keep heavy dependencies out of import-time paths and pure exchange tests. + try: + from config_models import AnalysisSettings + from engine_core import HAS_WHISPERX, HyperTranscriptionEngine + except ImportError as exc: + raise RuntimeError( + "Vibe analyzer runtime dependencies are missing. Restore the project virtual " + "environment and install VLC/Python media and model packages before transcribing. " + f"Original import error: {exc}" + ) from exc + + if args.whisperx_align and not HAS_WHISPERX: + raise RuntimeError("--whisperx-align was requested, but whisperx is not installed.") + if args.beam_size < 1: + raise TranscriptExchangeError("--beam-size must be at least 1.") + if args.min_silence_ms < 0 or args.speech_pad_ms < 0: + raise TranscriptExchangeError("Silence and pad values must be non-negative.") + if not 0.0 < args.vad_threshold < 1.0: + raise TranscriptExchangeError("--vad-threshold must be between 0 and 1.") + + engine = HyperTranscriptionEngine() + engine.set_model_id(args.model) + audio_data = None + try: + print(f"[transcribe] loading audio: {input_path}", file=sys.stderr) + audio_data, media_duration = engine.load_audio_to_memory(str(input_path)) + settings = AnalysisSettings( + device_mode=args.device, + beam_size=args.beam_size, + use_denoise=args.denoise, + use_dominant=args.dominant_speaker, + language=args.language, + vad_threshold=args.vad_threshold, + min_silence_ms=args.min_silence_ms, + speech_pad_ms=args.speech_pad_ms, + use_word_timestamps=True, + use_whisper_vad=args.whisper_vad, + use_silero_vad=args.silero_vad, + use_whisperx_align=args.whisperx_align, + remove_punctuation=args.remove_punctuation, + ) + generator, reported_duration = engine.transcribe_stream_raw( + audio_data, + threading.Event(), + settings, + ) + segments = _collect_segments(generator) + duration = reported_duration if reported_duration is not None else media_duration + document = build_transcript_document( + str(input_path), + segments, + duration_seconds=duration, + engine={ + "application": "VibeCoding_VideoAnalyzer", + "modelId": args.model, + "language": args.language, + "deviceMode": args.device, + "wordTimestamps": True, + "whisperXAlign": bool(args.whisperx_align), + "sileroVad": bool(args.silero_vad), + "whisperVad": bool(args.whisper_vad), + "denoise": bool(args.denoise), + }, + ) + return write_transcript_json( + str(output_path), + document, + overwrite=bool(args.overwrite), + ) + finally: + if audio_data is not None: + del audio_data + gc.collect() + + +def main(argv: Any = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + output_path = run(args) + except (TranscriptExchangeError, RuntimeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("error: transcription interrupted", file=sys.stderr) + return 130 + print(output_path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From eef8341bdf106e4dc9f79859b428f4900db694d9 Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:47:21 +0900 Subject: [PATCH 3/8] Test transcript JSON exchange without media dependencies --- test_transcript_exchange.py | 108 ++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 test_transcript_exchange.py diff --git a/test_transcript_exchange.py b/test_transcript_exchange.py new file mode 100644 index 0000000..be99f4d --- /dev/null +++ b/test_transcript_exchange.py @@ -0,0 +1,108 @@ +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from config_models import TranscriptSegment, TranscriptWord +from transcript_exchange import ( + SCHEMA_ID, + TranscriptExchangeError, + build_transcript_document, + load_transcript_json, + write_transcript_json, +) + + +class TranscriptExchangeTests(unittest.TestCase): + def sample_segments(self): + return [ + TranscriptSegment( + s=1.25, + e=2.5, + t="안녕하세요", + words=[ + TranscriptWord(word="안녕", s=1.25, e=1.8), + TranscriptWord(word="하세요", s=1.81, e=2.5), + ], + ), + { + "s": 3.0, + "e": 4.25, + "t": "두 번째 문장", + "words": [ + {"word": "두", "s": 3.0, "e": 3.2}, + {"word": "번째", "s": 3.21, "e": 3.6}, + {"word": "문장", "s": 3.61, "e": 4.25}, + ], + }, + ] + + def test_builds_versioned_document_with_word_timestamps(self): + document = build_transcript_document( + "/tmp/interview.mov", + self.sample_segments(), + duration_seconds=10, + engine={"modelId": "large-v3-turbo", "language": "ko"}, + created_at="2026-08-16T00:00:00Z", + ) + self.assertEqual(document["schema"], SCHEMA_ID) + self.assertEqual(document["schemaVersion"], 1) + self.assertEqual(document["summary"], {"segmentCount": 2, "wordCount": 5}) + self.assertEqual(document["segments"][0]["words"][1], { + "text": "하세요", + "startSeconds": 1.81, + "endSeconds": 2.5, + }) + + def test_round_trip_preserves_existing_output_by_default(self): + document = build_transcript_document( + "/tmp/interview.mov", + self.sample_segments(), + duration_seconds=10, + created_at="2026-08-16T00:00:00Z", + ) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "interview.vibe-transcript.json" + written = write_transcript_json(str(output), document) + self.assertEqual(Path(written), output.resolve()) + loaded = load_transcript_json(str(output)) + self.assertEqual(loaded["summary"]["wordCount"], 5) + with self.assertRaisesRegex(TranscriptExchangeError, "already exists"): + write_transcript_json(str(output), document) + + document["segments"][0]["text"] = "수정된 문장" + write_transcript_json(str(output), document, overwrite=True) + with output.open("r", encoding="utf-8") as handle: + raw = json.load(handle) + self.assertEqual(raw["segments"][0]["text"], "수정된 문장") + + def test_rejects_unsorted_words(self): + segments = [{ + "s": 0, + "e": 2, + "t": "bad order", + "words": [ + {"word": "later", "s": 1, "e": 1.5}, + {"word": "earlier", "s": 0.2, "e": 0.8}, + ], + }] + with self.assertRaisesRegex(TranscriptExchangeError, "sorted"): + build_transcript_document("/tmp/input.mov", segments) + + def test_cli_help_does_not_require_heavy_runtime_dependencies(self): + project_root = Path(__file__).resolve().parent + completed = subprocess.run( + [sys.executable, str(project_root / "transcribe_cli.py"), "--help"], + cwd=project_root, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("vibe-transcript.json", completed.stdout) + + +if __name__ == "__main__": + unittest.main() From 7401919b004d7af472aa3a848145b8a130647d38 Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:47:27 +0900 Subject: [PATCH 4/8] Add lightweight transcript exchange CI --- .github/workflows/transcript-exchange.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/transcript-exchange.yml diff --git a/.github/workflows/transcript-exchange.yml b/.github/workflows/transcript-exchange.yml new file mode 100644 index 0000000..db8dc08 --- /dev/null +++ b/.github/workflows/transcript-exchange.yml @@ -0,0 +1,22 @@ +name: Transcript exchange + +on: + push: + branches: [main, "agent/**"] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: macos-latest + strategy: + matrix: + python-version: ["3.9", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: python -m unittest -v test_transcript_exchange.py From 5c8ac9b363b88b4315dbee338bcad018cccf1643 Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:47:48 +0900 Subject: [PATCH 5/8] Document transcript exchange and headless export --- docs/TRANSCRIPT_EXCHANGE.md | 112 ++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/TRANSCRIPT_EXCHANGE.md diff --git a/docs/TRANSCRIPT_EXCHANGE.md b/docs/TRANSCRIPT_EXCHANGE.md new file mode 100644 index 0000000..82a8358 --- /dev/null +++ b/docs/TRANSCRIPT_EXCHANGE.md @@ -0,0 +1,112 @@ +# Transcript JSON exchange + +`transcript_exchange.py` provides a standard-library-only interchange format that preserves the segment and word timestamps produced by VibeCoding_VideoAnalyzer. + +It is intentionally separate from the GUI, playback, and word-highlight paths. Existing application behavior is unchanged. + +## Schema + +```json +{ + "schema": "vibe-video-analyzer/transcript", + "schemaVersion": 1, + "createdAt": "2026-08-16T00:00:00Z", + "media": { + "path": "/Users/me/Movies/interview.mov", + "fileName": "interview.mov", + "durationSeconds": 120.5 + }, + "engine": { + "application": "VibeCoding_VideoAnalyzer", + "modelId": "large-v3-turbo", + "language": "ko", + "wordTimestamps": true + }, + "segments": [ + { + "id": "segment-1", + "startSeconds": 1.25, + "endSeconds": 2.5, + "durationSeconds": 1.25, + "text": "안녕하세요", + "words": [ + { + "text": "안녕", + "startSeconds": 1.25, + "endSeconds": 1.8 + } + ] + } + ], + "summary": { + "segmentCount": 1, + "wordCount": 1 + } +} +``` + +The exporter accepts the existing `TranscriptSegment(s, e, t, words)` and `TranscriptWord(word, s, e)` data classes as well as their legacy dictionary equivalents. + +## Headless transcription + +The CLI uses the existing `HyperTranscriptionEngine` but does not launch Tkinter or VLC. + +```bash +python transcribe_cli.py "/Users/me/Movies/interview.mov" \ + --language ko \ + --model large-v3-turbo \ + --silero-vad \ + --whisperx-align \ + --output "/Users/me/Desktop/interview.vibe-transcript.json" +``` + +`--whisperx-align` requires the optional WhisperX dependency. Without it, Faster-Whisper or the existing MPS backend still emits word timestamps when supported. + +Useful options: + +```text +--device auto +--beam-size 5 +--min-silence-ms 2000 +--speech-pad-ms 250 +--vad-threshold 0.35 +--denoise +--dominant-speaker +--whisper-vad +--silero-vad +--whisperx-align +--remove-punctuation +--overwrite +``` + +Existing output is preserved unless `--overwrite` is explicit. + +## Runtime boundary + +The exchange module and its tests do not require media dependencies. Actual transcription still requires the same runtime as the main application, including the configured Whisper model, FFmpeg tooling, NumPy/Torch stack, and optional WhisperX/VAD packages. + +The repository currently has no dependency manifest and the macOS runtime remains unverified until the project environment and models are restored. + +## Apple Pro Video MCP handoff + +The generated JSON is designed to be consumed without losing word timing: + +```text +VibeCoding_VideoAnalyzer + → *.vibe-transcript.json + → Apple Pro Video MCP vibe_transcript_import + → highlight_rank + → edit_plan_build + ├─ fcpxml_create_project + └─ subtitle_segment → subtitle_write_srt +``` + +The analyzer supplies transcription and timestamps. Candidate quality ratings and actual Final Cut Pro compatibility remain separate evidence layers. + +## Tests + +The lightweight suite verifies normalization, schema round trips, protected writes, ordering validation, and that CLI help works without importing heavy runtime dependencies. + +```bash +python -m unittest -v test_transcript_exchange.py +``` From e80e9d83779e7cd54692c144fa7c0e48db3a5d41 Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:48:07 +0900 Subject: [PATCH 6/8] Document timestamp-preserving JSON export --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/README.md b/README.md index c99fa09..236a56d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,45 @@ Local video transcription and subtitle editing app built with Tkinter, VLC, Fast - Playback wrapper: `video_player.py` - Transcription engine: `engine_core.py` - Timeline state: `timeline_manager.py` +- Headless transcript export: `transcribe_cli.py` +- Transcript interchange: `transcript_exchange.py` + +## Timestamp-Preserving JSON Export + +The analyzer already produces `TranscriptSegment(s, e, t, words)` values with word-level `TranscriptWord(word, s, e)` timing. The new isolated exchange path preserves that data in a versioned JSON document without changing the GUI, playback, or word-highlight behavior. + +```bash +python transcribe_cli.py "/Users/me/Movies/interview.mov" \ + --language ko \ + --silero-vad \ + --output "/Users/me/Desktop/interview.vibe-transcript.json" +``` + +Optional precise alignment: + +```bash +python transcribe_cli.py input.mov --whisperx-align +``` + +This requires the existing runtime dependencies and optional WhisperX package. Existing JSON output is preserved unless `--overwrite` is explicit. + +The JSON can be handed to Apple Pro Video MCP without losing word timing: + +```text +Vibe transcript JSON + → vibe_transcript_import + → highlight_rank + → edit_plan_build + → FCPXML + SRT +``` + +See [`docs/TRANSCRIPT_EXCHANGE.md`](docs/TRANSCRIPT_EXCHANGE.md) for the schema, options, and validation boundary. + +Lightweight exchange tests do not import VLC, Torch, or Whisper: + +```bash +python -m unittest -v test_transcript_exchange.py +``` ## Current Platform Status - Windows packaging flow exists via `build_release.bat` and `VibeAnalyzer.spec`. @@ -30,6 +69,7 @@ Verified on 2026-03-20 in this workspace: 4. Restore or download the local speech models into `models/` if local bundled models are expected. 5. Run `python main.py` from the project root. 6. Verify video preview, subtitle preview, analysis start, and export paths. +7. Run `python transcribe_cli.py --help`, then produce one `.vibe-transcript.json` fixture from a short clip. ## Recommended macOS Smoke Test 1. Launch the app from the project root. @@ -39,6 +79,7 @@ Verified on 2026-03-20 in this workspace: 5. Start a basic transcription run using CPU mode. 6. Confirm subtitle rows populate and preview subtitles appear in VLC playback. 7. Export `SRT` and confirm the file is created correctly. +8. Run the headless CLI on the same clip and confirm segment/word timestamps exist in the JSON. ## Notes - `build_release.bat` is Windows-only. From e4265e5fb931e32e7db600b692cd783633894c64 Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:48:35 +0900 Subject: [PATCH 7/8] Record isolated transcript exchange integration --- HANDOFF.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index c2871ed..bdfa793 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -12,6 +12,8 @@ - Word/block editor: `ui_block_editor.py` - Playback wrapper: `video_player.py` - Timeline state: `timeline_manager.py` +- Headless transcript exporter: `transcribe_cli.py` +- Versioned transcript exchange: `transcript_exchange.py` ## Rules To Read First - `CODEX.md` @@ -33,6 +35,42 @@ - `VibeAnalyzer.spec` was recreated so the Windows build script has a real target again. - Deployment notes are documented in `DEPLOYMENT.md`. +## Transcript Exchange Addition + +Branch: `agent/transcript-exchange` + +The new integration is deliberately isolated from GUI, playback, block highlighting, and core word-timing algorithms. + +- `transcript_exchange.py` + - standard-library only; + - normalizes existing `TranscriptSegment(s,e,t,words)` and legacy dictionaries; + - preserves word-level timestamps in `vibe-video-analyzer/transcript` schema version 1; + - validates ordering and finite time values; + - writes JSON without replacing an existing file unless overwrite is explicit. +- `transcribe_cli.py` + - launches no GUI or VLC; + - lazily imports the existing transcription engine after argument parsing; + - always requests word timestamps; + - supports existing Faster-Whisper, VAD, denoise, and optional WhisperX settings; + - emits `.vibe-transcript.json` by default. +- `test_transcript_exchange.py` + - runs without media/model dependencies; + - covers dataclass/dict normalization, word preservation, round-trip JSON, ordering rejection, protected writes, and dependency-free CLI help. +- `.github/workflows/transcript-exchange.yml` + - runs only the lightweight exchange test on Python 3.9 and 3.12. + +The intended downstream consumer is Apple Pro Video MCP: + +```text +Vibe transcript JSON + → vibe_transcript_import + → highlight_rank + → edit_plan_build + → FCPXML + SRT +``` + +Do not claim actual transcription runtime verification until the project environment and models are restored on the Mac. + ## Current macOS Status - Source-level macOS fixes were applied for VLC embedding and a few UI behaviors. - Runtime is still blocked in this workspace because there is no `.venv` and no installed Python dependencies. @@ -63,6 +101,7 @@ Checked on 2026-03-20: - user requested full rollback. - Result: those sync experiments were reverted. - Current guidance: do not re-apply word timing changes directly in core paths without a clearly isolated experimental toggle. +- The transcript exchange addition does not alter those paths; it serializes their current outputs. ## Known Active Concerns - Word-level highlight sync is still imperfect. @@ -74,6 +113,7 @@ Checked on 2026-03-20: - use an experimental on/off toggle, - avoid changing default behavior first, - test against real sample clips before keeping changes. +- The headless CLI runtime has not yet been exercised on the target Mac. ## User Preferences - Functionality breakage is unacceptable. @@ -85,10 +125,14 @@ Checked on 2026-03-20: ## Suggested Workflow For Next Session 1. Read `CODEX.md` and `GEMINI.md`. 2. Read this file. -3. Check `git status`. -4. If working on macOS runtime, restore a usable Python environment first. -5. Install missing dependencies and restore `models/` before judging runtime behavior. -6. Keep risky sync logic behind toggles. +3. Check `git status` and use `agent/transcript-exchange` for the new exchange work. +4. Run `python -m unittest -v test_transcript_exchange.py` before restoring heavy dependencies. +5. If working on macOS runtime, restore a usable Python environment first. +6. Install missing dependencies and restore `models/` before judging runtime behavior. +7. Run `python transcribe_cli.py --help`. +8. Transcribe one short clip to a new JSON path without overwrite. +9. Inspect segment and word timestamps and hand the file to Apple Pro Video MCP. +10. Keep risky sync logic behind toggles. ## Files Worth Inspecting For Future Work - `main.py`: startup splash and boot flow @@ -96,6 +140,8 @@ Checked on 2026-03-20: - `engine_core.py`: ASR pipeline and word timestamp generation - `ui_block_editor.py`: active word highlighting logic - `video_player.py`: VLC timing behavior and platform embedding +- `transcript_exchange.py`: stable interchange contract +- `transcribe_cli.py`: headless ASR entry point ## Testing Notes - For startup behavior, verify the splash appears before heavy import work and reaches 100%. @@ -103,6 +149,7 @@ Checked on 2026-03-20: - For macOS runtime, verify VLC video renders inside the Tk window and subtitle preview still works. - For sync work, always test on a real problematic sample, not just synthetic assumptions. - If sync work is experimental, provide a rollback path and default it to off. +- For transcript exchange, preserve existing JSON by default and compare the exported word times to the in-memory transcript. ## Current Non-Code Diffs - Temporary preview subtitle files like `temp_preview_A.ass` and `temp_preview_B.ass` may be modified during app usage. From 078f4c66308069c2564c03a28dca43db1043227e Mon Sep 17 00:00:00 2001 From: kwd421 <66898122+kwd421@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:54:54 +0900 Subject: [PATCH 8/8] Make CLI help test insensitive to terminal wrapping --- test_transcript_exchange.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test_transcript_exchange.py b/test_transcript_exchange.py index be99f4d..38d01fe 100644 --- a/test_transcript_exchange.py +++ b/test_transcript_exchange.py @@ -101,7 +101,8 @@ def test_cli_help_does_not_require_heavy_runtime_dependencies(self): check=False, ) self.assertEqual(completed.returncode, 0, completed.stderr) - self.assertIn("vibe-transcript.json", completed.stdout) + self.assertIn("Transcribe a local video/audio file", completed.stdout) + self.assertIn("--whisperx-align", completed.stdout) if __name__ == "__main__":