mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-15 02:43:17 +02:00
Added MP4 audio embeds for Discord in AudioEmbed.py
This commit is contained in:
1 parent
2d5ab15f52
commit
d778be91cd
4 files changed
+174
-8
No files matched your search
@@ -0,0 +1,132 @@
|
||||
"""Audio as a video, for link embeds (GET /public/embed/...).
|
||||
|
||||
Discord only plays audio that was uploaded to it: a link's og:audio is
|
||||
ignored, and so is an og:video that points at an audio file. It does play an
|
||||
MP4 og:video. So a shared audio file is turned into one with ffmpeg: a still
|
||||
picture (the file's own cover art, or SaraView's icon) and the audio track.
|
||||
|
||||
A still frame encodes in seconds even for a long track. The video is made
|
||||
once per file version (path, size and change time), kept in the data
|
||||
directory's EmbedCache/, and started in the background as soon as an embed
|
||||
page is built, so it's usually ready by the time someone presses play."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from Settings import DefaultThemesDirectory, GetDataDirectory
|
||||
|
||||
ApplicationDirectory = Path(__file__).resolve().parent
|
||||
# The browser-tab icon (Frontend/public/Icons), when the audio has no cover art.
|
||||
IconPath = ApplicationDirectory.parent / "Frontend" / "dist" / "Icons" / "Icon.png"
|
||||
FrameSize = 480
|
||||
EncodeTimeout = 600 # seconds
|
||||
|
||||
_Locks: dict[str, threading.Lock] = {}
|
||||
_LocksGuard = threading.Lock()
|
||||
|
||||
|
||||
def IsAvailable() -> bool:
|
||||
return shutil.which("ffmpeg") is not None
|
||||
|
||||
|
||||
_EncoderArguments: list[str] | None = None
|
||||
|
||||
|
||||
def _VideoEncoder() -> list[str]:
|
||||
"""libx264 where ffmpeg has it (Debian's, in the Docker image), else
|
||||
OpenH264 (Fedora's ffmpeg-free has only that). Both make H.264, which
|
||||
Discord plays."""
|
||||
global _EncoderArguments
|
||||
if _EncoderArguments is None:
|
||||
try:
|
||||
Encoders = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"], capture_output=True, text=True,
|
||||
timeout=30).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
Encoders = ""
|
||||
_EncoderArguments = (["-c:v", "libx264", "-tune", "stillimage", "-preset", "veryfast"] if " libx264 " in Encoders
|
||||
else ["-c:v", "libopenh264"])
|
||||
return _EncoderArguments
|
||||
|
||||
|
||||
def _BackgroundColour() -> str:
|
||||
"""The default theme's Background, for the bars around non-square covers."""
|
||||
try:
|
||||
with (DefaultThemesDirectory / "CatppuccinMocha.toml").open("rb") as ThemeFile:
|
||||
return tomllib.load(ThemeFile)["Palette"]["Background"].lstrip("#")
|
||||
except (OSError, KeyError, tomllib.TOMLDecodeError):
|
||||
return "000000"
|
||||
|
||||
|
||||
def _CachePath(AudioPath: Path) -> Path:
|
||||
Stat = AudioPath.stat()
|
||||
Key = hashlib.sha256(f"{AudioPath}|{Stat.st_size}|{Stat.st_mtime_ns}".encode()).hexdigest()[:32]
|
||||
return GetDataDirectory() / "EmbedCache" / f"{Key}.mp4"
|
||||
|
||||
|
||||
def _ExtractCoverArt(AudioPath: Path, Target: Path) -> bool:
|
||||
"""Writes the file's own cover art (if it has any) to Target as a PNG."""
|
||||
try:
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(AudioPath), "-an", "-frames:v", "1", str(Target)],
|
||||
check=True, capture_output=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return Target.is_file() and Target.stat().st_size > 0
|
||||
|
||||
|
||||
def _Encode(AudioPath: Path, Target: Path) -> None:
|
||||
Target.parent.mkdir(parents=True, exist_ok=True)
|
||||
Partial = Target.with_suffix(".part.mp4")
|
||||
Cover = Target.with_suffix(".cover.png")
|
||||
Background = _BackgroundColour()
|
||||
Scaling = "neighbor" # pixel art stays sharp
|
||||
if _ExtractCoverArt(AudioPath, Cover):
|
||||
Picture = ["-loop", "1", "-framerate", "1", "-i", str(Cover)]
|
||||
Scaling = "lanczos" # album art is usually a photo
|
||||
elif IconPath.is_file():
|
||||
Picture = ["-loop", "1", "-framerate", "1", "-i", str(IconPath)]
|
||||
else:
|
||||
Picture = ["-f", "lavfi", "-i", f"color=c=0x{Background}:s={FrameSize}x{FrameSize}:r=1"]
|
||||
# A non-square cover gets bars in the theme's background colour.
|
||||
Fit = (f"scale={FrameSize}:{FrameSize}:force_original_aspect_ratio=decrease:flags={Scaling},"
|
||||
f"pad={FrameSize}:{FrameSize}:(ow-iw)/2:(oh-ih)/2:color=0x{Background},format=yuv420p")
|
||||
Command = [
|
||||
"ffmpeg", "-y", "-loglevel", "error", *Picture, "-i", str(AudioPath),
|
||||
"-map", "0:v:0", "-map", "1:a:0", "-vf", Fit, "-r", "1",
|
||||
*_VideoEncoder(),
|
||||
"-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", str(Partial),
|
||||
]
|
||||
try:
|
||||
subprocess.run(Command, check=True, capture_output=True, timeout=EncodeTimeout)
|
||||
Partial.replace(Target)
|
||||
finally:
|
||||
Partial.unlink(missing_ok=True)
|
||||
Cover.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def GetEmbedVideo(AudioPath: Path) -> Path | None:
|
||||
"""The embed video for an audio file, made now if needed; None if
|
||||
ffmpeg is missing or couldn't make it."""
|
||||
if not IsAvailable():
|
||||
return None
|
||||
Target = _CachePath(AudioPath)
|
||||
with _LocksGuard:
|
||||
Lock = _Locks.setdefault(str(Target), threading.Lock())
|
||||
with Lock:
|
||||
if not Target.is_file():
|
||||
try:
|
||||
_Encode(AudioPath, Target)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return Target
|
||||
|
||||
|
||||
def PrepareInBackground(AudioPath: Path) -> None:
|
||||
"""Starts making the video while the embed page is being read."""
|
||||
if IsAvailable() and not _CachePath(AudioPath).is_file():
|
||||
threading.Thread(target=GetEmbedVideo, args=(AudioPath,), daemon=True).start()
|
||||
Reference in new issue
Block a user