diff --git a/.gitignore b/.gitignore index 08b3318..bf1da5f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,23 @@ -input/* -output/* +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# UV +uv.lock + +# Environment variables +.env + +# Ruff +.ruff_cache + +# Project +input +output diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..7f60911 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.19.0 + hooks: + - id: pyupgrade + args: [--py313-plus] + + - repo: https://github.com/crate-ci/typos + rev: v1.28.1 + hooks: + - id: typos + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.1 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: check-docstring-first + - id: check-added-large-files + - id: check-yaml + - id: debug-statements + - id: check-merge-conflict + - id: double-quote-string-fixer + - id: end-of-file-fixer + + - repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes diff --git a/main.py b/main.py new file mode 100644 index 0000000..acc71c0 --- /dev/null +++ b/main.py @@ -0,0 +1,108 @@ +from logging import getLogger +from pathlib import Path + +import ffmpeg +from ffmpeg.nodes import FilterableStream +from rich.prompt import Prompt + +from utils.log import configure_logging + +PATH = Path(__file__).parent +INPUT_DIR = PATH / 'input' +OUTPUT_DIR = PATH / 'output' + +OUTPUT_ARGS = {'vcodec': 'libvpx-vp9', 'pix_fmt': 'yuva420p', 'loglevel': 'error', 'y': None} + + +logger = getLogger(__name__) + + +def compress(input_file: Path, output_file: Path, sticker_width: int, bitrate: int | None = None): + file_input = ffmpeg.input(input_file) + file_output = OUTPUT_DIR / input_file.name.replace(input_file.suffix, '.webm') + + if not isinstance(file_input, FilterableStream): + logger.error(f'Failed to process file: {input_file}') + raise Exception('Failed to process file') + + stream = ffmpeg.filter(file_input, 'fps', fps=25, round='up') + stream = ffmpeg.filter( + stream, 'scale', sticker_width, -1, force_original_aspect_ratio='decrease' + ) + stream = ffmpeg.filter( + stream, 'pad', sticker_width, sticker_width, '(ow-iw)/2', '(oh-ih)/2', color='0x00000000' + ) + + if input_file.suffix == '.gif': + duration = float(ffmpeg.probe(input_file)['streams'][0]['duration']) + speed_factor = 1 + + if duration > 3: + speed_factor = duration / 3 + + stream = ffmpeg.filter(stream, 'setpts', f'PTS/{speed_factor}') + + args = OUTPUT_ARGS + + if bitrate: + args['video_bitrate'] = f'{bitrate}k' + + output = ffmpeg.output(stream, filename=file_output, **args) + ffmpeg.run(output) + + file_size = file_output.stat().st_size + + if file_size / 1024 > 256: + if not bitrate: + bitrate = int(int(ffmpeg.probe(output_file)['format']['bit_rate']) / 1000) + + bitrate_offset = 100 + + if file_size / 256 * 1024 > 2: + bitrate_offset = 100 * (file_size / (256 * 1024)) + + bitrate = int(bitrate - bitrate_offset) + + logger.info( + f'File size is too high [{int(file_size / 1024)} KB], reducing to {bitrate}k...' + ) + + return compress(input_file, output_file, sticker_width, bitrate) + + +def main(): + if not INPUT_DIR.exists(): + INPUT_DIR.mkdir(parents=True) + + if not OUTPUT_DIR.exists(): + OUTPUT_DIR.mkdir(parents=True) + + for file in OUTPUT_DIR.iterdir(): + file.unlink() + + sticker_type = Prompt.ask('Do you want to create stickers or emoji?', choices=['s', 'e']) + + match sticker_type: + case 's': + STICKER_WIDTH = 512 + case 'e': + STICKER_WIDTH = 100 + case _: + STICKER_WIDTH = 512 + + for file in INPUT_DIR.iterdir(): + logger.info(f'Processing {file.name}...') + + if file.suffix not in ('.png', '.jpg', '.webp', '.gif'): + logger.warning(f'Unsupported file type: {file.suffix}') + continue + + file_path = INPUT_DIR / file.name + file_output = OUTPUT_DIR / file.name.replace(file.suffix, '.webm') + + compress(file_path, file_output, STICKER_WIDTH) + + +if __name__ == '__main__': + configure_logging() + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3d3b65c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "telegramstickersgenerator-git" +version = "1.0.0" +description = "A simple tool to convert any images and/or gifs to webm extension so that they can be used to create animated sticker/emoji packs in Telegram." +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "ffmpeg-python==0.2.0", + "rich==13.9.4", +] + +[dependency-groups] +dev = [ + "poethepoet==0.31.1", + "pre-commit==4.0.1", +] + +[tool.poe.tasks] +_git = "git add ." +_lint = "pre-commit run --all-files" + +lint = ["_git", "_lint"] + +[tool.ruff] +target-version = "py313" +line-length = 100 + +[tool.ruff.lint] +extend-select = ["F", "UP", "B", "SIM", "I"] + +[tool.ruff.format] +quote-style = "single" +indent-style = "space" +docstring-code-format = true diff --git a/script.ps1 b/script.ps1 deleted file mode 100644 index de11582..0000000 --- a/script.ps1 +++ /dev/null @@ -1,172 +0,0 @@ -# Variables -$ffmpegPath = (Get-Command ffmpeg -ErrorAction SilentlyContinue).Path -$ffprobePath = (Get-Command ffprobe -ErrorAction SilentlyContinue).Path -$inputDir = "./input" -$outputDir = "./output" - -# Check if ffmpeg and ffprobe executables exist -if (-not $ffmpegPath -or -not $ffprobePath) { - Write-Host "ffmpeg or ffprobe is not installed on this system. Exiting..." - exit 1 -} - -# Ask user if he wants to do stickers or emoji -$choice = Read-Host "Do you want to create stickers or emoji? (s/e)" -if ($choice -eq "s") { - $stickerWidth = 512 -} elseif ($choice -eq "e") { - $stickerWidth = 100 -} else { - Write-Host "Invalid choice. Exiting..." - exit 1 -} - -# Check if the output directory exists, if not, create it -if (-not (Test-Path $outputDir)) { - New-Item -Path $outputDir -ItemType Directory -Force | Out-Null -} - -# Functions -# Function to get the bitrate of a video file using ffprobe -function Get-Bitrate { - param ( - [string]$filePath - ) - - # Get bitrate from ffprobe - $bitrateOutput = & $ffprobePath -v error -select_streams v:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 $filePath - - if ([string]::IsNullOrEmpty($bitrateOutput) -or $bitrateOutput -eq "N/A") { - # If direct bitrate is not available, calculate bitrate based on file size and duration - $fileSize = (Get-Item $filePath).Length - $durationOutput = & $ffprobePath -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 $filePath - - if ($fileSize -gt 0 -and $durationOutput -ne "N/A" -and $durationOutput -ne "") { - $duration = [float]$durationOutput - $bitrate = [math]::Round(($fileSize * 8) / $duration / 1000) # in kbps - return $bitrate - } else { - Write-Host "Unable to determine bitrate for $filePath." - return 1000 - } - } - - return [int]$bitrateOutput -} - -# Function to get the duration of a video file using ffprobe -function Get-Duration { - param ( - [string]$filePath - ) - - $durationOutput = & $ffprobePath -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 $filePath - - if ($durationOutput -ne "N/A" -and $durationOutput -ne "") { - return [float]$durationOutput - } else { - Write-Host "Unable to determine duration for $filePath." - return $null - } -} - -# Function to compress the video file until it is below the specified size -function Compress-File { - param ( - [string]$inputFilePath, - [string]$outputFilePath, - [int]$initialBitrate, - [int]$maxSizeKB - ) - - $bitrate = $initialBitrate - $originalDuration = Get-Duration -filePath $file.FullName - $speedFactor = $originalDuration / 3 - $outputFilePathTemp = "$outputFilePath.webm" - - if ($speedFactor -le 1) { - $speedFactor = 1 - } - - do { - Write-Host Trying bitrate: ${bitrate}k - - # Compress with the current bitrate - & $ffmpegPath -loglevel quiet -i $inputFilePath -t 3 -r 25 -filter:v "setpts=PTS/$speedFactor,scale=-1:$stickerWidth" -c:v libvpx-vp9 -b:v ${bitrate}k $outputFilePathTemp -y - - # Check the size of the compressed file - $fileSize = (Get-Item $outputFilePathTemp).Length - $fileSizeKB = [math]::Round($fileSize / 1KB) - - if ($fileSizeKB -le $maxSizeKB) { - # Rename the temporary file to the final output file name - if (Test-Path $outputFilePath) { Remove-Item -Path $outputFilePath -Force } - Rename-Item -Path $outputFilePathTemp -NewName (Split-Path $outputFilePath -Leaf) - return - } - - $bitrateOffset = 100 - if (($fileSizeKB / $maxSizeKB) -gt 2) { - $bitrateOffset = 100 * ($fileSizeKB / $maxSizeKB) - } - - # Reduce the bitrate - $bitrate = [math]::Max([int]($bitrate - $bitrateOffset), 100) # Ensuring bitrate doesn't go below 100k - - } while ($bitrate -gt 100) - - # If bitrate falls too low, retain the last generated file - if (Test-Path $outputFilePathTemp) { - if (Test-Path $outputFilePath) { Remove-Item -Path $outputFilePath -Force } - Rename-Item -Path $outputFilePathTemp -NewName (Split-Path $outputFilePath -Leaf) - } -} - -# Process each file in the input directory -foreach ($file in Get-ChildItem -Path $inputDir -File) { - $outputFile = "$outputDir/$($file.BaseName).webm" - - # Check if the file is a PNG or GIF - if ($file.Extension -notin ".png", ".gif") { - continue - } - - Write-Host "Processing $($file.FullName)..." - - # Convert the file to WebM format - if ($file.Extension -eq ".png") { - & $ffmpegPath -loglevel quiet -i $file.FullName -r 25 -vf "scale=-1:$stickerWidth" -c:v libvpx-vp9 -pix_fmt rgba $outputFile -y - } - - if ($file.Extension -eq ".gif") { - # Get the original duration of the GIF - $originalDuration = Get-Duration -filePath $file.FullName - - if ($originalDuration -gt 0) { - $speedFactor = $originalDuration / 3 - - if ($speedFactor -le 1) { - $speedFactor = 1 - } - - # Convert the file to WebM format - & $ffmpegPath -loglevel quiet -i $file.FullName -t 3 -r 25 -filter:v "setpts=PTS/$speedFactor,scale=-1:$stickerWidth" -c:v libvpx-vp9 $outputFile -y - } else { - Write-Host "Invalid duration for GIF $($file.FullName). Skipping..." - continue - } - - # Check if the file size exceeds 256 KB (262144 bytes) - $fileSize = (Get-Item $outputFile).Length - - if ($fileSize -gt 255 * 1024) { - Write-Host "File $($outputFile) exceeds 256 KB, compressing..." - - # Get the initial bitrate of the WebM file - $initialBitrate = Get-Bitrate -filePath $outputFile - - # Compress the file until it is below 255 KB - Compress-File -inputFilePath $file.FullName -outputFilePath $outputFile -initialBitrate $initialBitrate -maxSizeKB 255 - } - } -} diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/log.py b/utils/log.py new file mode 100644 index 0000000..906ead1 --- /dev/null +++ b/utils/log.py @@ -0,0 +1,15 @@ +import logging + +from rich.logging import RichHandler + + +def configure_logging(): + DATE_FORMAT = '[%d.%m %H:%M:%S]' + LOGGER_FORMAT = '%(asctime)s %(message)s' + + logging.basicConfig( + level=logging.INFO, + format=LOGGER_FORMAT, + datefmt=DATE_FORMAT, + handlers=[RichHandler(show_time=False, rich_tracebacks=True)], + )