Skip to content

Instantly share code, notes, and snippets.

@FindMuck
Last active November 13, 2025 02:11
Show Gist options
  • Select an option

  • Save FindMuck/88aa5d60e11d1f2cc1255e4fb4708669 to your computer and use it in GitHub Desktop.

Select an option

Save FindMuck/88aa5d60e11d1f2cc1255e4fb4708669 to your computer and use it in GitHub Desktop.
[PowerShell 5.1] Telegram Emoji Converter & Duration Spoofer

Telegram Emoji Converter & Duration Spoofer

This Gist contains a portable PowerShell 5.1 script to convert .avif files into video .webm emojis or static .png stickers, suitable for Telegram. A key feature of this script is its ability to spoof the duration of the output .webm files, bypassing Telegram's 3-second limit for video emoji.

Features

  • AVIF to WEBM/PNG Conversion: Converts .avif files into video .webm emojis or static .png stickers.
  • Duration Spoofing: Modifies the metadata of .webm files to bypass Telegram's 3-second duration limit for video emojis, a technique inspired by the tgradish Python script.
  • Parallel Processing: Utilizes a portable ThreadJob module for concurrent file conversions, significantly speeding up the process.
  • Adaptive Quality: Attempts multiple CRF (Constant Rate Factor) levels to ensure the output file size is within Telegram's limits.

Setup & Requirements

This script is designed to be portable. Follow these steps to set it up:

  1. FFmpeg: Place ffmpeg.exe and ffprobe.exe in the parent directory (one level above the script's folder).
  2. ThreadJob Module: This script requires the Microsoft.PowerShell.ThreadJob module. To maintain portability:
    • Download the NuGet package manually from the PowerShell Gallery.
    • Extract the contents.
    • Create a folder named threads in the parent directory (alongside ffmpeg.exe).
    • Copy the extracted module folder (e.g., microsoft.powershell.threadjob) into the threads folder.

Your final folder structure should and can look like this:

📂 [root/]
├── ⚙️ ffmpeg.exe
├── ⚙️ ffprobe.exe
├── 📂 threads/
│   └── 📂 microsoft.powershell.threadjob/
│       └── 📄 (...module files here...)
├── 📂 YourConversionFolder/
│   ├── 📂 foo/
│   │   ├── 🖼️ 2.avif
│   │   └── 🖼️ 3.avif
│   ├── 📂 bar/foo/
│   │   └── 🖼️ 4.avif
│   ├── 🖼️ 1.avif
│   ├── 📜 uploader.ahk
│   └── 📜 tg_emoji_converter_spoofer.ps1
└── 📂 YourConversionFolder1/
    ├── 📂 foo/
    │   ├── 🖼️ 2.avif
    │   └── 🖼️ 3.avif
    ├── 📂 bar/foo/
    │   └── 🖼️ 4.avif
    ├── 🖼️ 1.avif
    ├── 📜 uploader.ahk
    └── 📜 tg_emoji_converter_spoofer.ps1

How It Works

The PowerShell script automates the process of converting .avif files using ffmpeg. For video emojis, it creates .webm files and then modifies their duration metadata. This "spoofing" is achieved by directly manipulating the byte data of the file to set a new, acceptable duration, similar to the logic in the tgradish Python script.

For a deeper understanding of the spoofing technique, you can refer to the Python implementation:

Telegram Specifications

For more information on Telegram's requirements for stickers and video emojis, please see the official documentation:

Automating Uploads with uploader.ahk

Alongside the PowerShell converter, this Gist provides an uploader.ahk script. It works by simulating keyboard and mouse inputs to semi-automate the process of uploading your finished .webm (or .png) files to the @Stickers bot within the official Telegram Desktop application.

Before running, you must:

  1. Have AutoHotkey installed.
  2. Manually edit the script's configuration variables. The script is dependent on your screen's coordinates and color values, and will not work without them.

The most critical variables to set are targetColor, targetX, and targetY. Use AutoHotkey's "Window Spy" tool on the @Stickers bot chat window to get these values. For all other details, please refer to the extensive comments inside the uploader.ahk file itself.

Files in this Gist

  • tg_emoji_converter_spoofer.ps1: The main PowerShell script for the conversion.
  • tg_emoji_converter_spoofer.md: This documentation.
  • uploader.ahk: A helper script to semi-automate uploading emojis using Telegram Desktop and the @Stickers bot.
# ================================================================================
# === Telegram Emoji Converter ===
# ================================================================================
# --- Configuration ---
$maxParallelJobs = 50
$EnableBenchmark = $true
$GpuPreference = "none" # "auto", "nvidia", "amd", "intel", "none"
# --- End of Configuration ---
# --- Get Location ---
$CurrentFolder = $PWD.Path
$ParentFolder = Split-Path -Parent $CurrentFolder
# --- Prerequisite Check ---
$moduleFolderPath = Join-Path $ParentFolder -ChildPath "threads\microsoft.powershell.threadjob"
if (Test-Path $moduleFolderPath) {
Get-ChildItem -Path $moduleFolderPath -Recurse | Unblock-File -ErrorAction SilentlyContinue
$manifestPath = Get-ChildItem -Path $moduleFolderPath -Filter "*.psd1" | Select-Object -First 1
if ($manifestPath) {
$module = Import-Module -Name $manifestPath.FullName -PassThru -ErrorAction SilentlyContinue
if (-not $module) { Write-Error "Module failed to import."; Read-Host; exit }
} else { Write-Error "No .psd1 file found."; Read-Host; exit }
} else { Write-Error "Module folder not found."; Read-Host; exit }
$ffmpegPath = Join-Path $ParentFolder "ffmpeg.exe"
$ffprobePath = Join-Path $ParentFolder "ffprobe.exe"
if (-not (Test-Path $ffmpegPath)) { Write-Error "[FATAL] ffmpeg.exe not found."; Read-Host; exit }
if (-not (Test-Path $ffprobePath)) { Write-Error "[FATAL] ffprobe.exe not found."; Read-Host; exit }
# --- GPU Auto-Detection Logic ---
# SEEMS NOT SUPPORTED AND UNNECESARY
$videoEncoder = "libvpx-vp9"; $hwaccel_params = @(); $gpuDetected = "CPU (High Quality)"
if ($GpuPreference -ne "none") {
$gpuInfo = (Get-WmiObject Win32_VideoController).Name; $vendor = $GpuPreference
if ($vendor -eq "auto") {
if ($gpuInfo -like "*NVIDIA*") { $vendor = "nvidia" }
elseif ($gpuInfo -like "*AMD*" -or $gpuInfo -like "*Radeon*") { $vendor = "amd" }
elseif ($gpuInfo -like "*Intel*") { $vendor = "intel" }
}
switch ($vendor) {
"nvidia" { $videoEncoder = "vp9_nvenc"; $hwaccel_params = @('-hwaccel', 'cuda'); $gpuDetected = "NVIDIA (NVENC)" }
"amd" { $videoEncoder = "vp9_amf"; $hwaccel_params = @('-hwaccel', 'd3d11va'); $gpuDetected = "AMD (AMF)" }
"intel" { $videoEncoder = "vp9_qsv"; $hwaccel_params = @('-hwaccel', 'qsv'); $gpuDetected = "Intel (QSV)" }
}
}
# --- Initial Setup ---
$videoOutputDir = Join-Path $CurrentFolder "output_video"
$staticOutputDir = Join-Path $CurrentFolder "output_static"
New-Item -ItemType Directory -Path $videoOutputDir -Force | Out-Null
New-Item -ItemType Directory -Path $staticOutputDir -Force | Out-Null
# --- Main Logic Block ---
Write-Host "Detected GPU for acceleration: $gpuDetected" -ForegroundColor Green
if ($EnableBenchmark) { Write-Host "Benchmark Timer: Enabled" -ForegroundColor Yellow }
Write-Host "====================================================================="
$conversionLogic = {
$allFiles = Get-ChildItem -Path $CurrentFolder -Filter "*.avif" -Recurse | Where-Object {
$_.FullName -notlike "*$videoOutputDir\*" -and $_.FullName -notlike "*$staticOutputDir\*"
}
if ($allFiles.Count -eq 0) { Write-Warning "No .avif files found."; return }
$initializationScript = {
function Set-WebmDuration {
param(
[string]$FilePath,
[double]$SpoofNumber = 555.55
)
try {
$bytes = $null
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
$bytes = [System.IO.File]::ReadAllBytes($FilePath)
if ($bytes -ne $null -and $bytes.Length -gt 100) { break }
} catch {}
Start-Sleep -Milliseconds 50
}
if ($null -eq $bytes) { return "Failed to read file." }
$infoId = [byte[]](0x15, 0x49, 0xA9, 0x66)
$durationId = [byte[]](0x44, 0x89)
$infoOffset = -1
for ($i = 0; $i -lt ($bytes.Length - $infoId.Length); $i++) {
$match = $true
for ($j = 0; $j -lt $infoId.Length; $j++) { if ($bytes[$i + $j] -ne $infoId[$j]) { $match = $false; break } }
if ($match) { $infoOffset = $i; break }
}
if ($infoOffset -eq -1) { return "Info tag not found" }
$durationOffset = -1
$searchLimit = [System.Math]::Min($infoOffset + 200, $bytes.Length)
for ($i = $infoOffset; $i -lt ($searchLimit - $durationId.Length); $i++) {
$match = $true
for ($j = 0; $j -lt $durationId.Length; $j++) { if ($bytes[$i + $j] -ne $durationId[$j]) { $match = $false; break } }
if ($match) { $durationOffset = $i; break }
}
if ($durationOffset -eq -1) { return "Duration tag not found after Info tag" }
$sizeByteOffset = $durationOffset + $durationId.Length
if ($sizeByteOffset -ge $bytes.Length) { return "File is truncated; cannot read size byte."}
$sizeByte = $bytes[$sizeByteOffset]
$targetLength = 0
if ($sizeByte -eq 0x84) { $targetLength = 4 }
elseif ($sizeByte -eq 0x88) { $targetLength = 8 }
else { return "Unsupported duration size marker: $($sizeByte)" }
$newDurationBytes = @()
if ($targetLength -eq 4) { $newDurationBytes = [System.BitConverter]::GetBytes([float]$SpoofNumber) }
else { $newDurationBytes = [System.BitConverter]::GetBytes([double]$SpoofNumber) }
if ([System.BitConverter]::IsLittleEndian) { [System.Array]::Reverse($newDurationBytes) }
$valueOffset = $sizeByteOffset + 1
[System.Array]::Copy($newDurationBytes, 0, $bytes, $valueOffset, $targetLength)
[System.IO.File]::WriteAllBytes($FilePath, $bytes)
return $true
} catch {
return "ERROR: $($_.Exception.Message)"
}
}
}
$jobs = $allFiles | ForEach-Object {
Start-ThreadJob -ThrottleLimit $maxParallelJobs -InitializationScript $initializationScript -ScriptBlock {
param(
$file, $ffmpegPath, $ffprobePath, $videoOutputDir, $staticOutputDir,
$hwaccel_args, $videoEncoder
)
$baseName = $file.BaseName; $parentFolderName = $file.Directory.Name
if ($parentFolderName -eq "Static") {
$streamCount = & $ffprobePath -v error -select_streams v -show_entries format=nb_streams -of default=noprint_wrappers=1:nokey=1 $file.FullName
$targetPath = Join-Path -Path $staticOutputDir -ChildPath "$baseName.png"
$counter = 1; while (Test-Path $targetPath) { $targetPath = Join-Path -Path $staticOutputDir -ChildPath "$baseName-$counter.png"; $counter++ }
if ($streamCount -ge 2) { $filterComplex = "[0:0][0:1]alphamerge,scale=w=100:h=100:force_original_aspect_ratio=decrease,pad=w=100:h=100:x=(100-iw)/2:y=(100-ih)/2:color=black@0.0" }
else { $filterComplex = "scale=w=100:h=100:force_original_aspect_ratio=decrease,format=rgba,pad=w=100:h=100:x=(100-iw)/2:y=(100-ih)/2:color=black@0.0" }
$ffmpegArgs = @('-i', $file.FullName, '-filter_complex', $filterComplex, '-y', $targetPath, '-loglevel', 'error')
$process = Start-Process -FilePath $ffmpegPath -ArgumentList $ffmpegArgs -NoNewWindow -Wait -PassThru
if ($process.ExitCode -ne 0) {
return "[FAILED] `"$($file.Name)`" (FFmpeg Code: $($process.ExitCode))"
} else {
return "[SUCCESS] `"$($file.Name)`""
}
} else {
$targetPath = Join-Path -Path $videoOutputDir -ChildPath "$baseName.webm"
$counter = 1; while (Test-Path $targetPath) { $targetPath = Join-Path -Path $videoOutputDir -ChildPath "$baseName-$counter.webm"; $counter++ }
$maxSize = 65536
$crfLevels = @(30, 40, 45, 50)
$process = $null
$success = $false
foreach ($crf in $crfLevels) {
$filterComplex = "[0:2][0:3]alphamerge,scale=w=100:h=100:force_original_aspect_ratio=decrease,pad=w=100:h=100:x=(100-iw)/2:y=(100-ih)/2:color=black@0.0,format=yuva420p,fps=30"
$ffmpegArgs = @($hwaccel_args + @('-i', $file.FullName, '-filter_complex', $filterComplex, '-c:v', $videoEncoder, "-crf", $crf, '-b:v', '0', '-an', '-map_metadata', '-1', '-y', $targetPath, '-loglevel', 'error'))
$process = Start-Process -FilePath $ffmpegPath -ArgumentList $ffmpegArgs -NoNewWindow -Wait -PassThru
if ($process.ExitCode -eq 0 -and (Test-Path $targetPath) -and (Get-Item $targetPath).Length -lt $maxSize) {
$success = $true; break
}
}
if (-not $success) {
$worstCrf = $crfLevels[-1]
for ($i = 1; $i -le 8; $i++) {
$setptsMultiplier = [math]::Round(1.0 - ($i * 0.1), 1)
$filterComplex = "[0:2][0:3]alphamerge,scale=w=100:h=100:force_original_aspect_ratio=decrease,pad=w=100:h=100:x=(100-iw)/2:y=(100-ih)/2:color=black@0.0,format=yuva420p,fps=30,setpts=$($setptsMultiplier)*PTS"
$ffmpegArgs = @($hwaccel_args + @('-i', $file.FullName, '-filter_complex', $filterComplex, '-c:v', $videoEncoder, "-crf", $worstCrf, '-b:v', '0', '-an', '-map_metadata', '-1', '-y', $targetPath, '-loglevel', 'error'))
$process = Start-Process -FilePath $ffmpegPath -ArgumentList $ffmpegArgs -NoNewWindow -Wait -PassThru
if ($process.ExitCode -eq 0 -and (Test-Path $targetPath) -and (Get-Item $targetPath).Length -lt $maxSize) {
$success = $true; break
}
}
}
if ($process.ExitCode -ne 0) {
return "[FAILED] `"$($file.Name)`" (FFmpeg Code: $($process.ExitCode))"
} elseif (-not (Test-Path $targetPath) -or (Get-Item $targetPath).Length -ge $maxSize) {
return "[FAILED-SIZE] `"$($file.Name)`" - File size is too large after all attempts."
} else {
$patchResult = Set-WebmDuration -FilePath $targetPath -SpoofNumber 555.55
if (($patchResult -is [bool] -and $patchResult) -or $patchResult -eq "Info tag not found" -or $patchResult -eq "Duration tag not found after Info tag") {
return "[SUCCESS] `"$($file.Name)`""
} else {
return "[FAILED-META] `"$($file.Name)`" - Reason: $patchResult"
}
}
}
} -ArgumentList @($_, $ffmpegPath, $ffprobePath, $videoOutputDir, $staticOutputDir, $hwaccel_params, $videoEncoder)
}
Write-Host "All $(@($jobs).Count) conversion tasks queued..."
$script:results = $jobs | Wait-Job | Receive-Job
$jobs | Remove-Job
}
if ($EnableBenchmark) {
$timingResult = Measure-Command { & $conversionLogic }
} else {
& $conversionLogic
}
$successCount = ($script:results | Where-Object { $_ -like "[SUCCESS]*" }).Count
$failureCount = ($script:results | Where-Object { $_ -like "[FAILED]*" -or $_ -like "[FAILED-META]*" -or $_ -like "[FAILED-SIZE]*" }).Count
Write-Host "`n--- FAILED FILES ---"
$script:results | Where-Object {$_ -like "[FAILED]*" -or $_ -like "[FAILED-META]*" -or $_ -like "[FAILED-SIZE]*"} | ForEach-Object { Write-Warning $_ }
Write-Host "====================================================================="
if ($EnableBenchmark) {
Write-Host "Total Execution Time: $($timingResult.TotalSeconds) seconds" -ForegroundColor Cyan
Write-Host "====================================================================="
}
Write-Host "Conversion finished!"
Write-Host "Successful: $successCount / Failed: $failureCount"
Write-Host "====================================================================="
Read-Host "Press Enter to exit"
#SingleInstance, Force
SetWorkingDir, %A_ScriptDir%
SetTitleMatchMode, 2
CoordMode, Pixel, Window
CoordMode, Mouse, Window
; --- 1. CONFIGURATION ---
; --- FOLDER & EMOJI ---
videoFolderPath := A_ScriptDir . "\output_video"
defaultEmoji := "☺"
; --- TELEGRAM WINDOW & VERIFICATION (Use AHK's Window Spy tool for these values) ---
telegramWindowTitle := "Stickers ahk_exe Telegram.exe"
fileDialogTitle := "Choose Files" ; Title of the "Choose Files" window. Varies by language.
targetColor := 0xBBGGRR ; The color of the message bubble (in 0xBBGGRR format).
targetX := x ; X coordinate inside the standalone window (ctrl+LMB).
targetY := y ; Y coordinate inside the standalone window (ctrl+LMB).
expectedText := "Thank you!" ; A unique word from the bot's reply to verify the successful upload.
; --- 2. MAIN AUTOMATION LOOP ---
if (!FileExist(videoFolderPath)) {
MsgBox, The folder "%videoFolderPath%" was not found.
ExitApp
}
WinActivate, %telegramWindowTitle%
WinWaitActive, %telegramWindowTitle%,, 2
if (ErrorLevel) {
MsgBox, Could not find the Telegram window. `n`nCriteria used: "%telegramWindowTitle%".
ExitApp
}
MsgBox, Please ensure the @Stickers bot is open and ready. Press OK to begin automation in 1 second.
Sleep, 1000
WinActivate, %telegramWindowTitle%
WinWaitActive, %telegramWindowTitle%,, 2
if (ErrorLevel) {
MsgBox, Could not find the Telegram window. `n`nCriteria used: "%telegramWindowTitle%".
ExitApp
}
Loop, Files, %videoFolderPath%\*.webm ; or .png
{
fullVideoPath := A_LoopFileFullPath
oldClipboard := ClipboardAll
Clipboard := fullVideoPath
Sleep, 5
SendInput, ^o
Sleep, 5
WinWaitActive, %fileDialogTitle%,, 2
if (ErrorLevel) {
MsgBox, Timed out waiting for the '%fileDialogTitle%' window. Script will stop.
ExitApp
}
SendInput, ^v
Sleep, 5
SendInput, {Enter}
Sleep, 600
WinActivate, %telegramWindowTitle%
WinWaitActive, %telegramWindowTitle%,, 2
if (ErrorLevel) {
MsgBox, Could not find the Telegram window. `n`nCriteria used: "%telegramWindowTitle%".
ExitApp
}
SendInput, ^{Enter}
Clipboard := oldClipboard
foundReply := false
Loop, 600
{
PixelGetColor, foundColor, %targetX%, %targetY%
if (foundColor == targetColor)
{
oldClipboard := ClipboardAll
Clipboard := ""
Click, Right, %targetX%, %targetY%
Sleep, 5
SendInput, {Up}
Sleep, 5
SendInput, {Enter}
Sleep, 200
SendInput, ^c
ClipWait, 2
SendInput, {Esc}
if (!ErrorLevel && InStr(Clipboard, expectedText)) {
foundReply := true
}
Clipboard := oldClipboard
break
}
Sleep, 100
}
if (!foundReply) {
MsgBox, Timed out waiting for the bots reply on file: %A_LoopFileName%`n`nCould not find the target color or verify the text. Script will stop.
ExitApp
}
Clipboard := defaultEmoji
Sleep, 5
SendInput, ^v
Sleep, 5
SendInput, ^{Enter}
}
MsgBox, Automation Complete! All .webm files have been processed.
ExitApp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment