Compare commits

...
6 Commits
5 changed files with 134 additions and 89 deletions
+25 -31
View File
@@ -1,39 +1,33 @@
# VidEnc # Video Encoding App
This is a command-line application that analyzes video files using `ffprobe` to extract stream information (video, audio, subtitles). It then uses the OMDb API to fetch additional movie details such as title and release date, given an IMDB ID. ## Overview
This app takes a video file and an IMDb ID as input, generates a shell script to encode the video streams to AV1 and audio streams to Opus format, converts anamorphic video to SAR 1:1, and adds metadata from OMDB while stripping all other metadata.
## Workflow ## Input Requirements
1. **Video File**: A valid video file in a supported format.
2. **IMDb ID**: A valid IMDb ID to fetch metadata from OMDB.
1. **Input:** The application takes a video file path as input, along with an optional IMDB ID and a flag to disable subtitle processing. ## Optional CLI Arguments
1. **`-ns`/`--nosubtitles`**: Disables subtitle processing.
2. **`-p`/`--preset`**: Sets the encoding preset for the video. Default is `2`.
3. **`-c`/`--crf`**: Sets the CRF value for the video. Default is `30`.
4. **`-o`/`--original-media-type`**: Sets the ORIGINAL_MEDIA_TYPE metadata. Default is `DVD`.
2. **FFprobe Analysis:** It runs `ffprobe` on the input video file to extract stream information in JSON format. ## Output Details
The app generates a shell script that performs the following tasks:
1. Encodes the video to AV1 format.
2. Encodes the audio to Opus format.
3. Converts anamorphic video to SAR 1:1.
4. Adds metadata from OMDB using the provided IMDb ID.
5. Strips all other metadata from the video.
3. **Stream Parsing:** The JSON output is parsed to identify video, audio, and subtitle streams. ## Metadata Handling
- **Added Metadata**: Metadata fetched from OMDB using the IMDb ID.
- **Stripped Metadata**: All other metadata is removed from the video.
4. **OMDb API Lookup (Optional):** If an IMDB ID is provided, the application queries the OMDb API to retrieve movie title and release date. ## Anamorphic Video Conversion
Anamorphic videos are converted to SAR (Sample Aspect Ratio) 1:1 to ensure proper display on all devices.
5. **Output:** Finally, it prints a command string based on the extracted information. This command string is likely intended for further video processing or encoding.
- Every audio stream that is side loaded, must be converted to 5.1 channel layout with `channelmap=channel_layout=5.1`
## Usage
## Example Usage
```bash ```bash
python src/app.py <video_file> [-i <imdb_id>] [-ns] ./encode_video.sh input_video.mp4 -i tt1234567
```
* `<video_file>`: Path to the video file.
* `-i <imdb_id>`: (Optional) IMDB ID of the movie.
* `-ns`: (Optional) Flag to disable subtitle processing.
## Example
```bash
python src/app.py my_movie.mp4 -i tt1234567
```
This would analyze `my_movie.mp4`, fetch details from OMDb using IMDB ID `tt1234567`, and print a command string.
## Unit Tests
There are no unit tests included in this project.
+1
View File
@@ -10,4 +10,5 @@ dependencies = [
"python-dotenv>=1.0.1", "python-dotenv>=1.0.1",
"requests>=2.32.3", "requests>=2.32.3",
"rich>=13.9.4", "rich>=13.9.4",
"typer>=0.15.2",
] ]
+16 -13
View File
@@ -1,13 +1,13 @@
import json import json
import subprocess import subprocess
import click import typer
from pathlib import Path from pathlib import Path
# from rich import print
from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream, OriginalMediaType
from movie_details import get_movie_details from movie_details import get_movie_details
from lib.fs import write_shell_script from lib.fs import write_shell_script
app = typer.Typer()
def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile: def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
for stream in stream_data["streams"]: for stream in stream_data["streams"]:
@@ -23,15 +23,16 @@ def parse_streams(stream_data: dict, VideoFile: VideoFile) -> VideoFile:
return VideoFile return VideoFile
@click.command() @app.command()
@click.argument("filename", type=click.Path(exists=True)) def main(
@click.option("-i", "--imdb", type=str, help="IMDB ID") filename: Path = typer.Argument(..., exists=True, help="Input video file"),
@click.option("-ns", "--nosubtitles", is_flag=True, help="No subtitles") imdb: str = typer.Option(None, "-i", "--imdb", help="IMDB ID"),
@click.option("-p", "--preset", type=int, default=2, help="Encoding preset (default: 2)") nosubtitles: bool = typer.Option(False, "-ns", "--nosubtitles", help="Remove subtitles"),
@click.option("-c", "--crf", type=int, default=30, help="CRF value (default: 30)") preset: int = typer.Option(2, "-p", "--preset", help="Encoding preset"),
@click.option('-o', '--original-media-type', default='DVD', help='Set the ORIGINAL_MEDIA_TYPE metadata') crf: int = typer.Option(30, "-c", "--crf", help="CRF value"),
def main(filename: Path, imdb: str, nosubtitles: bool, preset: int, crf: int, original_media_type: str): original_media_type: OriginalMediaType = typer.Option(OriginalMediaType.DVD, "-o", "--original-media-type", case_sensitive=False, help="Source of the original media."),
"""Run ffprobe on a file and print the output in JSON format.""" film_grain: int = typer.Option(20, "-fg", "--film-grain", help="Film grain amount (default: 20)")
):
video_details = VideoFile( video_details = VideoFile(
imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles
) )
@@ -49,10 +50,12 @@ def main(filename: Path, imdb: str, nosubtitles: bool, preset: int, crf: int, or
] ]
result = subprocess.run(cmd, capture_output=True, check=True) result = subprocess.run(cmd, capture_output=True, check=True)
data = parse_streams(json.loads(result.stdout), video_details) data = parse_streams(json.loads(result.stdout), video_details)
data.original_media_type = original_media_type
for stream in data.video_streams: for stream in data.video_streams:
stream.preset = preset stream.preset = preset
stream.crf = crf stream.crf = crf
stream.original_media_type = original_media_type stream.original_media_type = original_media_type
stream.film_grain = film_grain
export_data = " ".join(data.cmd()) export_data = " ".join(data.cmd())
export_path = Path(filename).parent / "encode.sh" export_path = Path(filename).parent / "encode.sh"
write_shell_script(export_data, export_path) write_shell_script(export_data, export_path)
@@ -62,4 +65,4 @@ def main(filename: Path, imdb: str, nosubtitles: bool, preset: int, crf: int, or
if __name__ == "__main__": if __name__ == "__main__":
main() app()
+64 -44
View File
@@ -1,15 +1,22 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from enum import Enum
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from functools import cached_property from functools import cached_property
from lib.fs import sanitize_filename from lib.fs import sanitize_filename
class OriginalMediaType(str, Enum):
DVD = "DVD"
BluRay = "BluRay"
TVRip = "TVRip"
WebRip = "WebRip"
Other = "Other"
class Tags(BaseModel): class Tags(BaseModel):
title: Optional[str] = Field(None) title: str | None = Field(None)
language: Optional[str] = Field(None) language: str | None = Field(None)
class Disposition(BaseModel): class Disposition(BaseModel):
@@ -18,22 +25,31 @@ class Disposition(BaseModel):
class VideoStream(BaseModel): class VideoStream(BaseModel):
index: int = Field(None) index: int | None = Field(None)
width: int = Field(None) width: int | None = Field(None)
height: int = Field(None) height: int | None = Field(None)
avg_frame_rate: str = Field(None, alias="avg_frame_rate") avg_frame_rate: str | None = Field(None, alias="avg_frame_rate")
codec_name: str = Field(None) codec_name: str | None = Field(None)
disposition: Disposition disposition: Disposition
tags: Tags | None = Field(None) tags: Tags | None = Field(None)
preset: int = Field(2) preset: int = Field(2)
crf: int = Field(30) crf: int = Field(30)
original_media_type: str | None = Field(None)
film_grain: int = Field(20)
@cached_property @cached_property
def fps(self) -> float: def fps(self) -> float:
frame_numbers = [int(x) for x in self.avg_frame_rate.split("/")] """Calculate frames per second from avg_frame_rate string."""
return frame_numbers[0] / frame_numbers[1] try:
if not self.avg_frame_rate:
return 0.0
num, denom = (int(x) for x in self.avg_frame_rate.split("/"))
return num / denom if denom else 0.0
except Exception:
return 0.0
def cmd_flags(self) -> list[str]: def cmd_flags(self) -> list[str]:
"""Generate ffmpeg command flags for this video stream."""
return [ return [
"-map", "-map",
f"0:{self.index}", f"0:{self.index}",
@@ -44,11 +60,11 @@ class VideoStream(BaseModel):
"-preset", "-preset",
str(self.preset), str(self.preset),
"-svtav1-params", "-svtav1-params",
"tune=0:film-grain=20:scd=1", f"tune=0:film-grain={self.film_grain}:scd=1",
"-vf", "-vf",
"'scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'", "'scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'",
"-g", "-g",
f"{round(self.fps) * 5}", str(round(self.fps) * 5),
] ]
@@ -61,20 +77,24 @@ class AudioStream(BaseModel):
tags: Tags | None = Field(None) tags: Tags | None = Field(None)
def cmd_flags(self, index: int) -> list[str]: def cmd_flags(self, index: int) -> list[str]:
"""Generate ffmpeg command flags for this audio stream."""
side_loaded = [] side_loaded = []
if "side" in self.channel_layout:
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"]
default_disposition = ( default_disposition = (
[f"-disposition:a:{index}", "default"] if self.disposition.default else [] [f"-disposition:a:{index}", "default"] if self.disposition.default else []
) )
if "side" in self.channel_layout: language = getattr(self.tags, 'language', None) if self.tags else None
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"] title = getattr(self.tags, 'title', None) if self.tags else None
meta_flags = []
if language:
meta_flags += [f"-metadata:s:a:{index}", f"language={language}"]
if title:
meta_flags += [f"-metadata:s:a:{index}", f"title='{title}'"]
return ( return (
["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"] ["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"]
+ side_loaded + side_loaded
+ [f"-metadata:s:a:{index}", f"language={self.tags.language}"] + meta_flags
+ [
f"-metadata:s:a:{index}",
f"title='{self.tags.title}'",
]
+ default_disposition + default_disposition
) )
@@ -86,23 +106,27 @@ class SubtitleStream(BaseModel):
tags: Tags | None = Field(None) tags: Tags | None = Field(None)
def cmd_flags(self, index: int) -> list[str]: def cmd_flags(self, index: int) -> list[str]:
return ["-map", f"0:s:{index}", f"-c:s:{index}", "copy"] + [ """Generate ffmpeg command flags for this subtitle stream."""
f"-metadata:s:s:{index}", language = getattr(self.tags, 'language', None) if self.tags else None
f"language={self.tags.language}", meta_flags = []
] if language:
meta_flags += [f"-metadata:s:s:{index}", f"language={language}"]
return ["-map", f"0:s:{index}", f"-c:s:{index}", "copy"] + meta_flags
class VideoFile(BaseModel): class VideoFile(BaseModel):
video_path: Path video_path: Path
title: str = Field(None) title: str | None = Field(None)
date_released: Optional[datetime] = Field(None) date_released: datetime | None = Field(None)
imdb: Optional[str] = Field(None) imdb: str | None = Field(None)
video_streams: list[VideoStream] = Field([]) video_streams: list[VideoStream] = Field(default_factory=list)
audio_streams: list[AudioStream] = Field([]) audio_streams: list[AudioStream] = Field(default_factory=list)
subtitle_streams: list[SubtitleStream] = Field([]) subtitle_streams: list[SubtitleStream] = Field(default_factory=list)
allow_subtitle: bool = Field(True) allow_subtitle: bool = Field(True)
original_media_type: OriginalMediaType = Field(OriginalMediaType.DVD)
def cmd(self) -> list[str]: def cmd(self) -> list[str]:
"""Generate the full ffmpeg command for this video file."""
cmd = [ cmd = [
"ffmpeg", "ffmpeg",
"-hide_banner", "-hide_banner",
@@ -112,17 +136,15 @@ class VideoFile(BaseModel):
f"'{str(self.video_path.absolute())}'", f"'{str(self.video_path.absolute())}'",
"-map_metadata", "-map_metadata",
"-1", "-1",
"-metadata",
f"title='{self.title}'",
"-metadata",
f"date_released={self.date_released.strftime('%Y-%m-%d')}",
"-metadata",
f"imdb={self.imdb}",
"-metadata",
"original_media_type=DVD",
"-pix_fmt",
"yuv420p10le",
] ]
if self.title:
cmd += ["-metadata", f"title='{self.title}'"]
if self.date_released:
cmd += ["-metadata", f"date_released={self.date_released.strftime('%Y-%m-%d')}"]
if self.imdb:
cmd += ["-metadata", f"imdb={self.imdb}"]
cmd += ["-metadata", f"original_media_type={self.original_media_type.value}"]
cmd += ["-pix_fmt", "yuv420p10le"]
for stream in self.video_streams: for stream in self.video_streams:
cmd.extend(stream.cmd_flags()) cmd.extend(stream.cmd_flags())
for i, stream in enumerate(self.audio_streams): for i, stream in enumerate(self.audio_streams):
@@ -130,12 +152,10 @@ class VideoFile(BaseModel):
if self.allow_subtitle: if self.allow_subtitle:
for i, stream in enumerate(self.subtitle_streams): for i, stream in enumerate(self.subtitle_streams):
cmd.extend(stream.cmd_flags(i)) cmd.extend(stream.cmd_flags(i))
year = self.date_released.year if self.date_released else "unknown"
output_file = ( output_file = (
self.video_path.absolute().parent self.video_path.absolute().parent
/ f"{sanitize_filename(self.title)}.{self.date_released.year}.mkv" / f"{sanitize_filename(self.title or 'untitled')}.{year}.mkv"
) )
cmd.append(f"'{str(output_file)}'")
cmd.extend([f"'{str(output_file)}'"])
return cmd return cmd
Generated
+28 -1
View File
@@ -1,4 +1,5 @@
version = 1 version = 1
revision = 1
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version < '3.13'", "python_full_version < '3.13'",
@@ -67,7 +68,7 @@ name = "click"
version = "8.1.7" version = "8.1.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
wheels = [ wheels = [
@@ -208,6 +209,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 },
] ]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
]
[[package]]
name = "typer"
version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/6f/3991f0f1c7fcb2df31aef28e0594d8d54b05393a0e4e34c65e475c2a5d41/typer-0.15.2.tar.gz", hash = "sha256:ab2fab47533a813c49fe1f16b1a370fd5819099c00b119e0633df65f22144ba5", size = 100711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/fc/5b29fea8cee020515ca82cc68e3b8e1e34bb19a3535ad854cac9257b414c/typer-0.15.2-py3-none-any.whl", hash = "sha256:46a499c6107d645a9c13f7ee46c5d5096cae6f5fc57dd11eccbbb9ae3e44ddfc", size = 45061 },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
@@ -236,6 +261,7 @@ dependencies = [
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "requests" }, { name = "requests" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" },
] ]
[package.metadata] [package.metadata]
@@ -245,4 +271,5 @@ requires-dist = [
{ name = "python-dotenv", specifier = ">=1.0.1" }, { name = "python-dotenv", specifier = ">=1.0.1" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
{ name = "rich", specifier = ">=13.9.4" }, { name = "rich", specifier = ">=13.9.4" },
{ name = "typer", specifier = ">=0.15.2" },
] ]