This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
"""Narrow WAV-to-MP3 narration attachment support for Labyricorn editors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
NARRATION_FILENAME = "narration.mp3"
|
||||
|
||||
|
||||
class NarrationImportError(Exception):
|
||||
"""Raised when a narration attachment cannot be safely changed."""
|
||||
|
||||
|
||||
def narration_path(entry_directory: Path) -> Path:
|
||||
"""Return the one supported narration attachment path for an entry."""
|
||||
return entry_directory / NARRATION_FILENAME
|
||||
|
||||
|
||||
def import_wav(
|
||||
entry_directory: Path,
|
||||
source: Path,
|
||||
*,
|
||||
ffmpeg: str | Path | None = None,
|
||||
fallback_ffmpeg: Path | None = None,
|
||||
) -> Path:
|
||||
"""Convert *source* WAV to an atomically replaced narration attachment."""
|
||||
source = source.expanduser()
|
||||
if source.suffix.lower() != ".wav":
|
||||
raise NarrationImportError("choose a WAV (.wav) file for narration")
|
||||
if not source.is_file():
|
||||
raise NarrationImportError(f"narration source is not a regular file: {source}")
|
||||
if not entry_directory.is_dir():
|
||||
raise NarrationImportError(f"entry directory does not exist: {entry_directory}")
|
||||
|
||||
executable = str(ffmpeg) if ffmpeg else shutil.which("ffmpeg")
|
||||
if not executable and fallback_ffmpeg is not None and fallback_ffmpeg.is_file():
|
||||
executable = str(fallback_ffmpeg)
|
||||
if not executable:
|
||||
raise NarrationImportError(
|
||||
"ffmpeg is required to convert WAV narration to narration.mp3; add it to PATH "
|
||||
"or provide support/ffmpeg.exe"
|
||||
)
|
||||
|
||||
destination = narration_path(entry_directory)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".narration-", suffix=".mp3", dir=entry_directory
|
||||
)
|
||||
os.close(descriptor)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
command = [
|
||||
executable, "-y", "-i", str(source), "-vn", "-ar", "44100", "-ac", "1",
|
||||
"-c:a", "libmp3lame", "-b:a", "96k", str(temporary),
|
||||
]
|
||||
run_options: dict[str, object] = {"capture_output": True, "text": True}
|
||||
if os.name == "nt":
|
||||
run_options["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
completed = subprocess.run(command, **run_options)
|
||||
if completed.returncode:
|
||||
detail = (completed.stderr or completed.stdout or "ffmpeg returned an error").strip()
|
||||
raise NarrationImportError(f"ffmpeg could not convert the narration WAV: {detail}")
|
||||
if not temporary.is_file() or temporary.stat().st_size == 0:
|
||||
raise NarrationImportError("ffmpeg completed without producing a usable MP3 file")
|
||||
os.replace(temporary, destination)
|
||||
except OSError as exc:
|
||||
raise NarrationImportError(f"cannot convert narration WAV: {exc}") from exc
|
||||
finally:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return destination
|
||||
|
||||
|
||||
def remove_narration(entry_directory: Path) -> bool:
|
||||
"""Remove only the canonical narration attachment, if it exists."""
|
||||
destination = narration_path(entry_directory)
|
||||
if not destination.exists():
|
||||
return False
|
||||
if not destination.is_file():
|
||||
raise NarrationImportError(f"narration attachment is not a regular file: {destination}")
|
||||
try:
|
||||
destination.unlink()
|
||||
except OSError as exc:
|
||||
raise NarrationImportError(f"cannot remove narration attachment: {exc}") from exc
|
||||
return True
|
||||
Reference in New Issue
Block a user