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__":