Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,406 @@
#!/usr/bin/env python3
"""
PPT Master - Final Video Subtitles
Align the exact narration text frozen in page-local narration SRT files against
the audio track of a finished PowerPoint-exported or slideshow-captured video.
This produces a delivery SRT from the actual video timeline without rewriting
speaker notes or relying on theoretical slide offsets.
Usage:
python3 scripts/video_subtitles.py <project_path> --video <video> --language <language>
Examples:
python3 scripts/video_subtitles.py projects/demo --video exports/demo.mp4 --language zh --force
Dependencies:
ffmpeg
python3 -m pip install stable-ts
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from console_encoding import configure_utf8_stdio
configure_utf8_stdio()
DEFAULT_MAX_CHARS = 20
_TIMING_RE = re.compile(
r"^(?P<start>\d+:\d{2}:\d{2},\d{3})\s+-->\s+"
r"(?P<end>\d+:\d{2}:\d{2},\d{3})(?:\s+.*)?$"
)
_CLAUSE_END = frozenset(",,;;::")
@dataclass(frozen=True)
class SubtitleCue:
"""One validated SRT cue."""
start_ms: int
end_ms: int
text: str
def _timestamp_to_ms(value: str) -> int:
hours_text, minutes_text, remainder = value.split(":")
seconds_text, milliseconds_text = remainder.split(",")
hours = int(hours_text)
minutes = int(minutes_text)
seconds = int(seconds_text)
milliseconds = int(milliseconds_text)
if minutes >= 60 or seconds >= 60:
raise ValueError(f"Invalid SRT timestamp: {value}")
return (((hours * 60) + minutes) * 60 + seconds) * 1000 + milliseconds
def _parse_srt(path: Path) -> list[SubtitleCue]:
"""Read one strict, non-overlapping SRT file."""
text = path.read_text(encoding="utf-8-sig")
blocks = re.split(r"\r?\n\s*\r?\n", text.strip())
cues: list[SubtitleCue] = []
previous_end = -1
for block_number, block in enumerate(blocks, 1):
lines = block.splitlines()
if len(lines) < 3:
raise ValueError(f"{path}: malformed SRT block {block_number}")
try:
cue_number = int(lines[0].strip())
except ValueError as exc:
raise ValueError(
f"{path}: invalid cue number in block {block_number}"
) from exc
if cue_number != block_number:
raise ValueError(
f"{path}: cue numbers must be consecutive from 1; "
f"block {block_number} is numbered {cue_number}"
)
timing_match = _TIMING_RE.match(lines[1].strip())
if timing_match is None:
raise ValueError(
f"{path}: invalid cue timing in block {block_number}"
)
start_ms = _timestamp_to_ms(timing_match.group("start"))
end_ms = _timestamp_to_ms(timing_match.group("end"))
cue_text = re.sub(r"\s+", " ", " ".join(lines[2:])).strip()
if not cue_text:
raise ValueError(f"{path}: empty cue text in block {block_number}")
if end_ms <= start_ms:
raise ValueError(
f"{path}: cue {block_number} must end after it starts"
)
if start_ms < previous_end:
raise ValueError(
f"{path}: cue {block_number} overlaps the preceding cue"
)
cues.append(SubtitleCue(start_ms, end_ms, cue_text))
previous_end = end_ms
if not cues:
raise ValueError(f"No subtitle cues found: {path}")
return cues
def _display_length(text: str) -> int:
return sum(not character.isspace() for character in text)
def _hard_split(text: str, max_chars: int) -> list[str]:
"""Split an overlong clause without dropping characters."""
output: list[str] = []
remaining = text.strip()
while _display_length(remaining) > max_chars:
visible = 0
split_at = 0
whitespace_split = 0
for index, character in enumerate(remaining, 1):
if character.isspace():
whitespace_split = index
continue
visible += 1
if visible > max_chars:
break
split_at = index
if whitespace_split and whitespace_split <= split_at:
split_at = whitespace_split
if split_at <= 0:
raise ValueError("Unable to split an overlong subtitle clause")
output.append(remaining[:split_at].strip())
remaining = remaining[split_at:].strip()
if remaining:
output.append(remaining)
return output
def _split_sentence(text: str, max_chars: int) -> list[str]:
"""Keep one sentence unless its display length requires clause splitting."""
sentence = re.sub(r"\s+", " ", text).strip()
if _display_length(sentence) <= max_chars:
return [sentence]
clauses: list[str] = []
start = 0
for index, character in enumerate(sentence):
if character not in _CLAUSE_END:
continue
clause = sentence[start:index + 1].strip()
if clause:
clauses.append(clause)
start = index + 1
tail = sentence[start:].strip()
if tail:
clauses.append(tail)
atoms = [
part
for clause in clauses
for part in _hard_split(clause, max_chars)
]
output: list[str] = []
for atom in atoms:
if not output:
output.append(atom)
continue
candidate = f"{output[-1]}{atom}"
if _display_length(candidate) <= max_chars:
output[-1] = candidate
else:
output.append(atom)
return output
def _page_subtitle_paths(subtitle_dir: Path) -> list[Path]:
"""Resolve the ordered page-local SRT set without reading notes."""
paths = [
path
for path in sorted(subtitle_dir.glob("*.srt"))
if path.stem != "total"
]
if not paths:
raise FileNotFoundError(
f"No page-local narration SRT files found under {subtitle_dir}"
)
return paths
def _frozen_transcript_lines(
subtitle_dir: Path,
max_chars: int,
) -> list[str]:
"""Return display lines derived from the exact text used for TTS."""
lines: list[str] = []
for path in _page_subtitle_paths(subtitle_dir):
for cue in _parse_srt(path):
lines.extend(_split_sentence(cue.text, max_chars))
if not lines:
raise ValueError("The page-local SRT set contains no narration text")
return lines
def _require_stable_whisper() -> Any:
try:
import stable_whisper
except ImportError as exc:
raise RuntimeError(
"Final-video subtitle alignment requires stable-ts. "
"Install it with: python3 -m pip install stable-ts"
) from exc
return stable_whisper
def _text_key(text: str) -> str:
return "".join(character for character in text if not character.isspace())
def align_video_subtitles(
*,
video_path: Path,
subtitle_dir: Path,
output_path: Path,
language: str,
model_name: str,
device: str | None,
max_chars: int,
force: bool,
) -> tuple[int, int]:
"""Align frozen narration text to the final video's actual audio track."""
if not video_path.is_file():
raise FileNotFoundError(f"Finished video does not exist: {video_path}")
if shutil.which("ffmpeg") is None:
raise RuntimeError(
"Final-video subtitle alignment requires ffmpeg on PATH"
)
if max_chars < 1:
raise ValueError("max_chars must be at least 1")
if output_path.resolve() == video_path.resolve():
raise ValueError("Subtitle output must not overwrite the finished video")
if output_path.exists() and not force:
raise FileExistsError(
f"Output already exists: {output_path}; pass --force to replace it"
)
transcript_lines = _frozen_transcript_lines(
subtitle_dir,
max_chars,
)
transcript = "\n".join(transcript_lines)
stable_whisper = _require_stable_whisper()
load_options: dict[str, Any] = {}
if device:
load_options["device"] = device
model = stable_whisper.load_model(model_name, **load_options)
result = model.align(
str(video_path),
transcript,
language=language,
original_split=True,
)
if result is None:
raise RuntimeError("stable-ts could not align the narration transcript")
output_path.parent.mkdir(parents=True, exist_ok=True)
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{output_path.stem}.",
suffix=".srt",
dir=str(output_path.parent),
)
os.close(descriptor)
temporary_path = Path(temporary_name)
try:
result.to_srt_vtt(
str(temporary_path),
segment_level=True,
word_level=False,
)
output_cues = _parse_srt(temporary_path)
output_text = _text_key("".join(cue.text for cue in output_cues))
expected_text = _text_key("".join(transcript_lines))
if output_text != expected_text:
raise RuntimeError(
"Aligned subtitle text differs from the frozen TTS transcript; "
"the final SRT was not published"
)
if len(output_cues) != len(transcript_lines):
raise RuntimeError(
"stable-ts did not preserve the requested sentence/line "
"boundaries; the final SRT was not published"
)
os.replace(temporary_path, output_path)
finally:
temporary_path.unlink(missing_ok=True)
return len(transcript_lines), output_cues[-1].end_ms
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_path", type=Path, help="Project directory")
parser.add_argument(
"--video",
required=True,
help=(
"Finished PowerPoint-exported or slideshow-captured video; "
"relative paths are project-relative"
),
)
parser.add_argument(
"--language",
required=True,
help="Narration language passed to stable-ts, e.g. zh, en, ja, or ko",
)
parser.add_argument(
"--subtitle-dir",
default=None,
help="Page-local narration SRT directory; default: <project>/audio",
)
parser.add_argument(
"-o",
"--output",
default=None,
help="Final SRT path; default: beside the video with the same stem",
)
parser.add_argument(
"--model",
default="base",
help="Whisper model used only for forced alignment (default: base)",
)
parser.add_argument(
"--device",
default=None,
help="Optional stable-ts device, e.g. cpu or cuda",
)
parser.add_argument(
"--max-chars",
type=int,
default=DEFAULT_MAX_CHARS,
help="Maximum non-space characters per final subtitle cue (default: 20)",
)
parser.add_argument(
"--force",
action="store_true",
help="Replace an existing final SRT",
)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
project_path = args.project_path.resolve()
if not project_path.is_dir():
parser.error(f"Project path does not exist: {project_path}")
video_path = Path(args.video)
if not video_path.is_absolute():
video_path = project_path / video_path
video_path = video_path.resolve()
subtitle_dir = (
Path(args.subtitle_dir)
if args.subtitle_dir
else Path("audio")
)
if not subtitle_dir.is_absolute():
subtitle_dir = project_path / subtitle_dir
output_path = Path(args.output) if args.output else video_path.with_suffix(".srt")
if not output_path.is_absolute():
output_path = project_path / output_path
try:
cue_count, final_end_ms = align_video_subtitles(
video_path=video_path,
subtitle_dir=subtitle_dir.resolve(),
output_path=output_path.resolve(),
language=args.language,
model_name=args.model,
device=args.device,
max_chars=args.max_chars,
force=args.force,
)
except (OSError, RuntimeError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
print(output_path.resolve())
print(
f"Aligned {cue_count} final-video subtitle cue(s); "
f"last cue ends at {final_end_ms / 1000:.3f}s",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())