Add jinja templating to script writer
This commit is contained in:
@@ -6,6 +6,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
"jinja2>=3.1.6",
|
||||||
"pydantic>=2.9.2",
|
"pydantic>=2.9.2",
|
||||||
"python-dotenv>=1.0.1",
|
"python-dotenv>=1.0.1",
|
||||||
"selectolax>=0.3.31",
|
"selectolax>=0.3.31",
|
||||||
|
|||||||
+18
-10
@@ -11,22 +11,22 @@ The first priority is to refactor the existing codebase to improve its structure
|
|||||||
This phase will be broken down into the following steps:
|
This phase will be broken down into the following steps:
|
||||||
|
|
||||||
1. **Centralize Encoding Settings:**
|
1. **Centralize Encoding Settings:**
|
||||||
* Create a new Pydantic model, `EncodingSettings`, in a new file `src/models/settings.py`.
|
* **Create `EncodingSettings` Model:** In a new file, `src/models/settings.py`, create a new Pydantic model named `EncodingSettings`.
|
||||||
* This model will consolidate all encoding-related parameters (`preset`, `crf`, `film_grain`, etc.) that are currently passed individually into the `main` function in `app.py`.
|
* **Define Fields:** This model will have the following fields, corresponding to the Typer options in `app.py`:
|
||||||
* The `main` function will be updated to create an instance of this model.
|
* `preset: int`
|
||||||
|
* `crf: int`
|
||||||
|
* `original_media_type: OriginalMediaType`
|
||||||
|
* `film_grain: int`
|
||||||
|
* **Integrate into `app.py`:**
|
||||||
|
* In the `main` function, create an instance of `EncodingSettings` by passing the values from the Typer options.
|
||||||
|
* The `apply_stream_settings` function will be updated to accept this `EncodingSettings` object instead of individual parameters, simplifying its signature.
|
||||||
|
|
||||||
2. **Refactor IMDb Search:**
|
2. **Refactor IMDb Search:**
|
||||||
* Create a Pydantic model, `IMDbSearchResult`, to represent a single search result (e.g., with `title`, `year`, `imdb_id`).
|
* Create a Pydantic model, `IMDbSearchResult`, to represent a single search result (e.g., with `title`, `year`, `imdb_id`).
|
||||||
* Modify the `search_imdb` function in `src/lib/movie_details.py` to no longer print to the console. Instead, it will return a list of `IMDbSearchResult` objects.
|
* Modify the `search_imdb` function in `src/lib/movie_details.py` to no longer print to the console. Instead, it will return a list of `IMDbSearchResult` objects.
|
||||||
* The interactive selection logic will be handled separately in `app.py` after calling the refactored `search_imdb`.
|
* The interactive selection logic will be handled separately in `app.py` after calling the refactored `search_imdb`.
|
||||||
|
|
||||||
3. **Implement Template-Based Command Generation:**
|
3. **[x] Implement Template-Based Command Generation:** The `ffmpeg` command is now generated using a Jinja2 template, separating the command logic from the Python code.
|
||||||
* Add `Jinja2` as a project dependency in `pyproject.toml`.
|
|
||||||
* Create a new directory `src/templates/`.
|
|
||||||
* Create a template file, `ffmpeg_command.sh.j2`, inside this directory. This template will contain the full structure of the `ffmpeg` command, using Jinja2 syntax for loops, conditionals, and variables.
|
|
||||||
* Create a new function (e.g., `generate_ffmpeg_command`) that takes the necessary data models (`VideoDetails`, `EncodingSettings`) as input.
|
|
||||||
* This function will be responsible for loading the Jinja2 template, rendering it with the provided data, and returning the final command string.
|
|
||||||
* The `VideoDetails.cmd()` method will be removed and replaced with a call to this new function.
|
|
||||||
|
|
||||||
## Phase 2: Logging
|
## Phase 2: Logging
|
||||||
|
|
||||||
@@ -71,6 +71,14 @@ Add an option to execute the generated `ffmpeg` command directly from the applic
|
|||||||
|
|
||||||
Develop a graphical user interface (GUI) to make the application more accessible and user-friendly for a broader audience. This will be a significant undertaking and will be considered after the core CLI functionality is mature and stable. Research into the best GUI framework (e.g., Dear PyGui, CustomTkinter, PySide6) will be the first step.
|
Develop a graphical user interface (GUI) to make the application more accessible and user-friendly for a broader audience. This will be a significant undertaking and will be considered after the core CLI functionality is mature and stable. Research into the best GUI framework (e.g., Dear PyGui, CustomTkinter, PySide6) will be the first step.
|
||||||
|
|
||||||
|
### Deinterlacing
|
||||||
|
|
||||||
|
A `bwdif` deinterlacing filter is currently hardcoded in the video processing pipeline as a temporary solution.
|
||||||
|
|
||||||
|
**To-Do:**
|
||||||
|
- Implement detection for interlaced video streams (e.g., by checking the `field_order` property from `ffprobe` output).
|
||||||
|
- Apply the deinterlacing filter conditionally, only when interlaced content is detected, to avoid unnecessary processing on progressive sources.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+76
-17
@@ -5,12 +5,20 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from models.streams import VideoFile, VideoStream, AudioStream, SubtitleStream, OriginalMediaType
|
import jinja2
|
||||||
|
from models.streams import (
|
||||||
|
VideoFile,
|
||||||
|
VideoStream,
|
||||||
|
AudioStream,
|
||||||
|
SubtitleStream,
|
||||||
|
OriginalMediaType,
|
||||||
|
)
|
||||||
from lib.movie_details import get_movie_details, search_imdb
|
from lib.movie_details import get_movie_details, search_imdb
|
||||||
from lib.fs import write_shell_script
|
from lib.fs import write_shell_script, sanitize_filename
|
||||||
|
|
||||||
app = typer.Typer()
|
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"]:
|
||||||
if (
|
if (
|
||||||
@@ -30,7 +38,9 @@ def validate_imdb(value: str) -> str:
|
|||||||
if value is None:
|
if value is None:
|
||||||
return value
|
return value
|
||||||
if not re.match(r"^tt\d{7,8}$", value):
|
if not re.match(r"^tt\d{7,8}$", value):
|
||||||
raise click.BadParameter("IMDb ID must be 'tt' followed by 7-8 digits (e.g. tt1234567)")
|
raise click.BadParameter(
|
||||||
|
"IMDb ID must be 'tt' followed by 7-8 digits (e.g. tt1234567)"
|
||||||
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -38,15 +48,21 @@ def validate_imdb(value: str) -> str:
|
|||||||
def run_ffprobe(filename: Path) -> dict:
|
def run_ffprobe(filename: Path) -> dict:
|
||||||
"""Execute ffprobe and return parsed JSON data."""
|
"""Execute ffprobe and return parsed JSON data."""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe", "-v", "quiet",
|
"ffprobe",
|
||||||
"-print_format", "json",
|
"-v",
|
||||||
"-show_format", "-show_streams",
|
"quiet",
|
||||||
|
"-print_format",
|
||||||
|
"json",
|
||||||
|
"-show_format",
|
||||||
|
"-show_streams",
|
||||||
filename,
|
filename,
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, capture_output=True, check=True)
|
result = subprocess.run(cmd, capture_output=True, check=True)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
typer.secho("Error: ffprobe not found. Please install FFmpeg.", fg="red", err=True)
|
typer.secho(
|
||||||
|
"Error: ffprobe not found. Please install FFmpeg.", fg="red", err=True
|
||||||
|
)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
typer.secho("Error: ffprobe failed to probe the file.", fg="red", err=True)
|
typer.secho("Error: ffprobe failed to probe the file.", fg="red", err=True)
|
||||||
@@ -55,11 +71,19 @@ def run_ffprobe(filename: Path) -> dict:
|
|||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
return json.loads(result.stdout)
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
def prepare_video_file(filename: Path, imdb: str, nosubtitles: bool) -> VideoFile:
|
def prepare_video_file(filename: Path, imdb: str, nosubtitles: bool) -> VideoFile:
|
||||||
"""Initialize VideoFile with metadata."""
|
"""Initialize VideoFile with metadata."""
|
||||||
return VideoFile(imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles)
|
return VideoFile(imdb=imdb, video_path=filename, allow_subtitle=not nosubtitles)
|
||||||
|
|
||||||
def apply_stream_settings(video_details: VideoFile, preset: int, crf: int, original_media_type: OriginalMediaType, film_grain: int) -> VideoFile:
|
|
||||||
|
def apply_stream_settings(
|
||||||
|
video_details: VideoFile,
|
||||||
|
preset: int,
|
||||||
|
crf: int,
|
||||||
|
original_media_type: OriginalMediaType,
|
||||||
|
film_grain: int,
|
||||||
|
) -> VideoFile:
|
||||||
"""Apply encoding settings to video streams."""
|
"""Apply encoding settings to video streams."""
|
||||||
video_details.original_media_type = original_media_type
|
video_details.original_media_type = original_media_type
|
||||||
for stream in video_details.video_streams:
|
for stream in video_details.video_streams:
|
||||||
@@ -69,6 +93,7 @@ def apply_stream_settings(video_details: VideoFile, preset: int, crf: int, origi
|
|||||||
stream.film_grain = film_grain
|
stream.film_grain = film_grain
|
||||||
return video_details
|
return video_details
|
||||||
|
|
||||||
|
|
||||||
def confirm_and_write_script(export_data: str, export_path: Path):
|
def confirm_and_write_script(export_data: str, export_path: Path):
|
||||||
"""Confirm overwrite and write shell script."""
|
"""Confirm overwrite and write shell script."""
|
||||||
if export_path.exists():
|
if export_path.exists():
|
||||||
@@ -79,35 +104,69 @@ def confirm_and_write_script(export_data: str, export_path: Path):
|
|||||||
write_shell_script(export_data, export_path)
|
write_shell_script(export_data, export_path)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def main(
|
def main(
|
||||||
filename: Path = typer.Argument(..., exists=True, help="Input video file"),
|
filename: Path = typer.Argument(..., exists=True, help="Input video file"),
|
||||||
imdb: str = typer.Option(None, "-i", "--imdb", callback=validate_imdb, help="IMDB ID"),
|
imdb: str = typer.Option(
|
||||||
nosubtitles: bool = typer.Option(False, "-ns", "--nosubtitles", help="Remove subtitles"),
|
None, "-i", "--imdb", callback=validate_imdb, help="IMDB ID"
|
||||||
|
),
|
||||||
|
nosubtitles: bool = typer.Option(
|
||||||
|
False, "-ns", "--nosubtitles", help="Remove subtitles"
|
||||||
|
),
|
||||||
preset: int = typer.Option(2, "-p", "--preset", help="Encoding preset"),
|
preset: int = typer.Option(2, "-p", "--preset", help="Encoding preset"),
|
||||||
crf: int = typer.Option(30, "-c", "--crf", help="CRF value"),
|
crf: int = typer.Option(30, "-c", "--crf", help="CRF value"),
|
||||||
original_media_type: OriginalMediaType = typer.Option(OriginalMediaType.DVD, "-o", "--original-media-type", case_sensitive=False, help="Source of the original media."),
|
original_media_type: OriginalMediaType = typer.Option(
|
||||||
film_grain: int = typer.Option(20, "-fg", "--film-grain", help="Film grain amount (default: 20)"),
|
OriginalMediaType.DVD,
|
||||||
|
"-o",
|
||||||
|
"--original-media-type",
|
||||||
|
case_sensitive=False,
|
||||||
|
help="Source of the original media.",
|
||||||
|
),
|
||||||
|
film_grain: int = typer.Option(
|
||||||
|
20, "-fg", "--film-grain", help="Film grain amount (default: 20)"
|
||||||
|
),
|
||||||
):
|
):
|
||||||
video_details = prepare_video_file(filename, imdb, nosubtitles)
|
video_details = prepare_video_file(filename, imdb, nosubtitles)
|
||||||
video_details = get_movie_details(video_details)
|
video_details = get_movie_details(video_details)
|
||||||
|
|
||||||
stream_data = run_ffprobe(filename)
|
stream_data = run_ffprobe(filename)
|
||||||
video_details = parse_streams(stream_data, video_details)
|
video_details = parse_streams(stream_data, video_details)
|
||||||
video_details = apply_stream_settings(video_details, preset, crf, original_media_type, film_grain)
|
video_details = apply_stream_settings(
|
||||||
|
video_details, preset, crf, original_media_type, film_grain
|
||||||
|
)
|
||||||
|
|
||||||
export_data = " ".join(video_details.cmd())
|
# Create the output filename
|
||||||
|
year = (
|
||||||
|
video_details.date_released.year if video_details.date_released else "unknown"
|
||||||
|
)
|
||||||
|
output_filename = (
|
||||||
|
f"{sanitize_filename(video_details.title or 'untitled')}.{year}.mkv"
|
||||||
|
)
|
||||||
|
output_file_path = video_details.video_path.absolute().parent / output_filename
|
||||||
|
|
||||||
|
# Load Jinja2 template and render it
|
||||||
|
template_loader = jinja2.FileSystemLoader(
|
||||||
|
searchpath=Path(__file__).parent / "templates"
|
||||||
|
)
|
||||||
|
template_env = jinja2.Environment(loader=template_loader)
|
||||||
|
template = template_env.get_template("ffmpeg_command.sh.j2")
|
||||||
|
export_data = template.render(
|
||||||
|
video_file=video_details, output_file=str(output_file_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Write the script
|
||||||
export_path = filename.parent / "encode.sh"
|
export_path = filename.parent / "encode.sh"
|
||||||
confirm_and_write_script(export_data, export_path)
|
confirm_and_write_script(export_data, export_path)
|
||||||
|
|
||||||
typer.echo(str(filename.parent))
|
# typer.echo(str(filename.parent))
|
||||||
typer.echo(export_data)
|
# typer.echo(export_data)
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
@app.command()
|
||||||
def search_web(search_string: str):
|
def search_web(search_string: str):
|
||||||
imdb_id = search_imdb(search_string)
|
imdb_id = search_imdb(search_string)
|
||||||
typer.echo(imdb_id)
|
typer.echo(imdb_id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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
|
|
||||||
|
|
||||||
class OriginalMediaType(str, Enum):
|
class OriginalMediaType(str, Enum):
|
||||||
DVD = "DVD"
|
DVD = "DVD"
|
||||||
@@ -48,25 +47,6 @@ class VideoStream(BaseModel):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
def cmd_flags(self) -> list[str]:
|
|
||||||
"""Generate ffmpeg command flags for this video stream."""
|
|
||||||
return [
|
|
||||||
"-map",
|
|
||||||
f"0:{self.index}",
|
|
||||||
"-c:v",
|
|
||||||
"libsvtav1",
|
|
||||||
"-crf",
|
|
||||||
str(self.crf),
|
|
||||||
"-preset",
|
|
||||||
str(self.preset),
|
|
||||||
"-svtav1-params",
|
|
||||||
f"tune=0:film-grain={self.film_grain}:scd=1",
|
|
||||||
"-vf",
|
|
||||||
"'bwdif=mode=0,scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos'",
|
|
||||||
"-g",
|
|
||||||
str(round(self.fps) * 5),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class AudioStream(BaseModel):
|
class AudioStream(BaseModel):
|
||||||
index: int
|
index: int
|
||||||
@@ -76,28 +56,6 @@ class AudioStream(BaseModel):
|
|||||||
disposition: Disposition
|
disposition: Disposition
|
||||||
tags: Tags | None = Field(None)
|
tags: Tags | None = Field(None)
|
||||||
|
|
||||||
def cmd_flags(self, index: int) -> list[str]:
|
|
||||||
"""Generate ffmpeg command flags for this audio stream."""
|
|
||||||
side_loaded = []
|
|
||||||
if "side" in self.channel_layout:
|
|
||||||
side_loaded = [f"-filter:a:{index}", "'channelmap=channel_layout=5.1'"]
|
|
||||||
default_disposition = (
|
|
||||||
[f"-disposition:a:{index}", "default"] if self.disposition.default else []
|
|
||||||
)
|
|
||||||
language = getattr(self.tags, 'language', None) if self.tags else None
|
|
||||||
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 (
|
|
||||||
["-map", f"0:{self.index}", f"-c:a:{index}", "libopus"]
|
|
||||||
+ side_loaded
|
|
||||||
+ meta_flags
|
|
||||||
+ default_disposition
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SubtitleStream(BaseModel):
|
class SubtitleStream(BaseModel):
|
||||||
index: int
|
index: int
|
||||||
@@ -105,14 +63,6 @@ class SubtitleStream(BaseModel):
|
|||||||
disposition: Disposition
|
disposition: Disposition
|
||||||
tags: Tags | None = Field(None)
|
tags: Tags | None = Field(None)
|
||||||
|
|
||||||
def cmd_flags(self, index: int) -> list[str]:
|
|
||||||
"""Generate ffmpeg command flags for this subtitle stream."""
|
|
||||||
language = getattr(self.tags, 'language', None) if self.tags else None
|
|
||||||
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
|
||||||
@@ -124,38 +74,3 @@ class VideoFile(BaseModel):
|
|||||||
subtitle_streams: list[SubtitleStream] = Field(default_factory=list)
|
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)
|
original_media_type: OriginalMediaType = Field(OriginalMediaType.DVD)
|
||||||
|
|
||||||
def cmd(self) -> list[str]:
|
|
||||||
"""Generate the full ffmpeg command for this video file."""
|
|
||||||
cmd = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-hide_banner",
|
|
||||||
"-loglevel",
|
|
||||||
"error",
|
|
||||||
"-i",
|
|
||||||
f"'{str(self.video_path.absolute())}'",
|
|
||||||
"-map_metadata",
|
|
||||||
"-1",
|
|
||||||
]
|
|
||||||
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:
|
|
||||||
cmd.extend(stream.cmd_flags())
|
|
||||||
for i, stream in enumerate(self.audio_streams):
|
|
||||||
cmd.extend(stream.cmd_flags(i))
|
|
||||||
if self.allow_subtitle:
|
|
||||||
for i, stream in enumerate(self.subtitle_streams):
|
|
||||||
cmd.extend(stream.cmd_flags(i))
|
|
||||||
year = self.date_released.year if self.date_released else "unknown"
|
|
||||||
output_file = (
|
|
||||||
self.video_path.absolute().parent
|
|
||||||
/ f"{sanitize_filename(self.title or 'untitled')}.{year}.mkv"
|
|
||||||
)
|
|
||||||
cmd.append(f"'{str(output_file)}'")
|
|
||||||
return cmd
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# This script is a template. It needs to be rendered with:
|
||||||
|
# - video_file: A VideoFile object from models.streams.
|
||||||
|
# - output_file: The absolute path to the output file.
|
||||||
|
|
||||||
|
# By default, set log level to error.
|
||||||
|
LOG_FLAGS=("-loglevel" "error")
|
||||||
|
|
||||||
|
# If -v or --verbose is passed as the first argument, remove the log flags
|
||||||
|
# to use ffmpeg's default log level (usually 'info').
|
||||||
|
if [[ "$1" == "-v" || "$1" == "--verbose" ]]; then
|
||||||
|
LOG_FLAGS=()
|
||||||
|
fi
|
||||||
|
|
||||||
|
ffmpeg -hide_banner "${LOG_FLAGS[@]}" -i '{{ video_file.video_path.absolute() }}' \
|
||||||
|
-map_metadata -1 \
|
||||||
|
{%- if video_file.title %}
|
||||||
|
-metadata title='{{ video_file.title }}' \
|
||||||
|
{%- endif %}
|
||||||
|
{%- if video_file.date_released %}
|
||||||
|
-metadata date_released='{{ video_file.date_released.strftime("%Y-%m-%d") }}' \
|
||||||
|
{%- endif %}
|
||||||
|
{%- if video_file.imdb %}
|
||||||
|
-metadata imdb='{{ video_file.imdb }}' \
|
||||||
|
{%- endif %}
|
||||||
|
{%- if video_file.original_media_type %}
|
||||||
|
-metadata original_media_type='{{ video_file.original_media_type.value }}' \
|
||||||
|
{%- endif %}
|
||||||
|
-pix_fmt yuv420p10le \
|
||||||
|
{%- for stream in video_file.video_streams %}
|
||||||
|
-map 0:{{ stream.index }} \
|
||||||
|
-c:v libsvtav1 \
|
||||||
|
-crf {{ stream.crf }} \
|
||||||
|
-preset {{ stream.preset }} \
|
||||||
|
-svtav1-params tune=0:film-grain={{ stream.film_grain }}:scd=1 \
|
||||||
|
-vf 'bwdif=mode=0,scale=iw*sar:ih,setsar=1,scale=-2:ih:lanczos' \
|
||||||
|
-g {{ (stream.fps | round * 5) | int }} \
|
||||||
|
{%- endfor %}
|
||||||
|
{%- for stream in video_file.audio_streams %}
|
||||||
|
-map 0:{{ stream.index }} -c:a:{{ loop.index0 }} libopus \
|
||||||
|
{%- if 'side' in stream.channel_layout %}
|
||||||
|
-filter:a:{{ loop.index0 }} 'channelmap=channel_layout=5.1' \
|
||||||
|
{%- endif %}
|
||||||
|
{%- if stream.tags and stream.tags.language %}
|
||||||
|
-metadata:s:a:{{ loop.index0 }} language={{ stream.tags.language }} \
|
||||||
|
{%- endif %}
|
||||||
|
{%- if stream.tags and stream.tags.title %}
|
||||||
|
-metadata:s:a:{{ loop.index0 }} title='{{ stream.tags.title }}' \
|
||||||
|
{%- endif %}
|
||||||
|
{%- if stream.disposition.default %}
|
||||||
|
-disposition:a:{{ loop.index0 }} default \
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{%- if video_file.allow_subtitle %}
|
||||||
|
{%- for stream in video_file.subtitle_streams %}
|
||||||
|
-map 0:{{ stream.index }} -c:s:{{ loop.index0 }} copy \
|
||||||
|
{%- if stream.tags and stream.tags.language %}
|
||||||
|
-metadata:s:s:{{ loop.index0 }} language={{ stream.tags.language }} \
|
||||||
|
{%- endif %}
|
||||||
|
{%- endfor %}
|
||||||
|
{%- endif %}
|
||||||
|
'{{ output_file }}'
|
||||||
|
|
||||||
@@ -105,6 +105,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
@@ -117,6 +129,44 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdurl"
|
name = "mdurl"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -274,6 +324,7 @@ version = "1.0.1"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "jinja2" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "selectolax" },
|
{ name = "selectolax" },
|
||||||
@@ -283,6 +334,7 @@ dependencies = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "pydantic", specifier = ">=2.9.2" },
|
{ name = "pydantic", specifier = ">=2.9.2" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
{ name = "python-dotenv", specifier = ">=1.0.1" },
|
||||||
{ name = "selectolax", specifier = ">=0.3.31" },
|
{ name = "selectolax", specifier = ">=0.3.31" },
|
||||||
|
|||||||
Reference in New Issue
Block a user