3 Commits

Author SHA1 Message Date
cskonopka
bc25c3580a added commands to example for newer programs 2025-11-09 03:09:15 -05:00
cskonopka
d1ad41c984 cleaning up repo 2025-11-09 01:58:25 -05:00
cskonopka
5e7678bf7a added mince 2025-11-08 22:25:56 -05:00
15 changed files with 1287 additions and 327 deletions

View File

@@ -1,5 +1,5 @@
<p align="center">
<img width="45%" height="45%" src="https://github.com/vondas-network/videobeaux/blob/main/img/videobeaux-1.png?raw=true"/>
<img width="45%" height="45%" src="https://github.com/schwwaaa/videobeaux/blob/main/img/videobeaux-1.png?raw=true"/>
</p>
<p align="center"><em>The friendly multilateral video toolkit built for artists by artists. It's your best friend.</em></p>
@@ -9,7 +9,7 @@
In the shell prompt, go to the place where you want the project to live. Paste that in a macOS Terminal or Linux shell prompt & run it.
``` bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/vondas-network/videobeaux/refs/heads/main/install.sh)"
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/schwwaaa/videobeaux/refs/heads/main/install.sh)"
```
### Windows
@@ -41,7 +41,7 @@ __ _(_) __| | ___ ___ | |__ ___ __ _ _ ___ __
📺 The friendly multilateral video toolkit built for artists by artists.
🫂 It's your best friend!
🌐 https://vondas.network
🌐 https://schwwaaa.com
usage: videobeaux --program PROGRAM --input INPUT_FILE --output OUTPUT_FILE [program options]
@@ -87,7 +87,7 @@ __ _(_) __| | ___ ___ | |__ ___ __ _ _ ___ __
Your friendly multilateral video toolkit built for artists by artists.
https://vondas.software
https://schwwaaa.com
--------------------------------------------------
Selected program mode: bad_predator
✅ This program mode does not require additional arguments
@@ -95,7 +95,7 @@ usage: python3 -m videobeaux.cli --program PROGRAM [global options] [program opt
📺 Your friendly multilateral video toolkit built for artists by artists.
It's your best friend!
https://vondas.software
https://schwwaaa.com
options:
-P PROGRAM, --program PROGRAM
@@ -135,7 +135,7 @@ __ _(_) __| | ___ ___ | |__ ___ __ _ _ ___ __
Your friendly multilateral video toolkit built for artists by artists.
https://vondas.software
https://schwwaaa.com
--------------------------------------------------
Selected program mode: bad_predator
✅ This program mode does not require additional arguments
@@ -159,14 +159,14 @@ __ _(_) __| | ___ ___ | |__ ___ __ _ _ ___ __
Your friendly multilateral video toolkit built for artists by artists.
https://vondas.software
https://schwwaaa.com
--------------------------------------------------
Selected program mode: stutter_pro
usage: python3 -m videobeaux.cli --program PROGRAM [global options] [program options]
📺 Your friendly multilateral video toolkit built for artists by artists.
It's your best friend!
https://vondas.software
https://schwwaaa.com
options:
-P PROGRAM, --program PROGRAM
@@ -207,7 +207,7 @@ __ _(_) __| | ___ ___ | |__ ___ __ _ _ ___ __
Your friendly multilateral video toolkit built for artists by artists.
https://vondas.software
https://schwwaaa.com
--------------------------------------------------
Selected program mode: stutter_pro
Input duration: 10.01 seconds
@@ -230,14 +230,14 @@ __ _(_) __| | ___ ___ | |__ ___ __ _ _ ___ __
Your friendly multilateral video toolkit built for artists by artists.
https://vondas.software
https://schwwaaa.com
--------------------------------------------------
Selected program mode: chain_builder
usage: python3 -m videobeaux.cli --program PROGRAM [global options] [program options]
📺 Your friendly multilateral video toolkit built for artists by artists.
It's your best friend!
https://vondas.software
https://schwwaaa.com
options:
-P PROGRAM, --program PROGRAM
@@ -279,7 +279,7 @@ __ _(_) __| | ___ ___ | |__ ___ __ _ _ ___ __
Your friendly multilateral video toolkit built for artists by artists.
https://vondas.software
https://schwwaaa.com
--------------------------------------------------
Selected program mode: chain_builder
🔁 Running step 1/3: rb_blur
@@ -369,11 +369,11 @@ https://github.com/user-attachments/assets/1fa8de04-98ef-49f7-9415-616e07210f0e
bad_contrast
https://github.com/vondas-network/videobeaux/assets/7625379/9ba59b08-79a8-4a09-8b18-c0fe90a6c5e2
https://github.com/schwwaaa/videobeaux/assets/7625379/9ba59b08-79a8-4a09-8b18-c0fe90a6c5e2
bad_predator
https://github.com/vondas-network/videobeaux/assets/7625379/0968ad50-cc97-4336-938f-01b47d86a7bd
https://github.com/schwwaaa/videobeaux/assets/7625379/0968ad50-cc97-4336-938f-01b47d86a7bd
ball_point_pen
@@ -381,11 +381,11 @@ https://github.com/user-attachments/assets/10e703a5-5036-4c3e-83f6-be04476ad089
blur_pix
https://github.com/vondas-network/videobeaux/assets/7625379/65403294-3e34-4ff8-816a-5de7c80c811d
https://github.com/schwwaaa/videobeaux/assets/7625379/65403294-3e34-4ff8-816a-5de7c80c811d
broken_scroll
https://github.com/vondas-network/videobeaux/assets/7625379/4cdebccc-8519-45c6-aded-089db73d20d2
https://github.com/schwwaaa/videobeaux/assets/7625379/4cdebccc-8519-45c6-aded-089db73d20d2
digital_boss
@@ -393,35 +393,35 @@ https://github.com/user-attachments/assets/23958066-f384-4801-9d91-5b2df6081a31
double_cup
https://github.com/vondas-network/videobeaux/assets/7625379/83d30a18-40d1-42e4-aff3-dbd50d67a7d1
https://github.com/schwwaaa/videobeaux/assets/7625379/83d30a18-40d1-42e4-aff3-dbd50d67a7d1
fever
https://github.com/vondas-network/videobeaux/assets/7625379/b476426f-0ca6-4667-be40-97df932b9909
https://github.com/schwwaaa/videobeaux/assets/7625379/b476426f-0ca6-4667-be40-97df932b9909
frame_delay_pro1-1
https://github.com/vondas-network/videobeaux/assets/7625379/871ccdb9-ae2b-46e1-8b0f-0514eb92e1aa
https://github.com/schwwaaa/videobeaux/assets/7625379/871ccdb9-ae2b-46e1-8b0f-0514eb92e1aa
frame_delay_pro1-2
https://github.com/vondas-network/videobeaux/assets/7625379/0a727474-25cf-42ab-a717-583e12b4a04d
https://github.com/schwwaaa/videobeaux/assets/7625379/0a727474-25cf-42ab-a717-583e12b4a04d
frame_delay_pro1-3
https://github.com/vondas-network/videobeaux/assets/7625379/5ab60f24-b4e2-4e0e-abc0-cfab62e09cda
https://github.com/schwwaaa/videobeaux/assets/7625379/5ab60f24-b4e2-4e0e-abc0-cfab62e09cda
frame_delay_pro2-1
https://github.com/vondas-network/videobeaux/assets/7625379/a88284bc-ca7e-4355-8f95-377434c61d13
https://github.com/schwwaaa/videobeaux/assets/7625379/a88284bc-ca7e-4355-8f95-377434c61d13
frame_delay_pro2-2
https://github.com/vondas-network/videobeaux/assets/7625379/acf571e7-7162-413f-80f8-769815093267
https://github.com/schwwaaa/videobeaux/assets/7625379/acf571e7-7162-413f-80f8-769815093267
frame_delay_pro2-3
https://github.com/vondas-network/videobeaux/assets/7625379/f717d419-687b-4cc3-ac07-64f45c763531
https://github.com/schwwaaa/videobeaux/assets/7625379/f717d419-687b-4cc3-ac07-64f45c763531
ghostee
@@ -429,31 +429,31 @@ https://github.com/user-attachments/assets/87c8b569-5165-485d-ae09-7a8bbbe74051
lsd_feedback
https://github.com/vondas-network/videobeaux/assets/7625379/9653929c-30ad-4c72-81c8-e3777c590783
https://github.com/schwwaaa/videobeaux/assets/7625379/9653929c-30ad-4c72-81c8-e3777c590783
looper_pro
https://github.com/vondas-network/videobeaux/assets/7625379/01090d49-8626-4fc0-b55c-807d100a78fa
https://github.com/schwwaaa/videobeaux/assets/7625379/01090d49-8626-4fc0-b55c-807d100a78fa
mirror_delay
https://github.com/vondas-network/videobeaux/assets/7625379/a3dea5c6-03a6-4f65-951d-211f50457b63
https://github.com/schwwaaa/videobeaux/assets/7625379/a3dea5c6-03a6-4f65-951d-211f50457b63
nostalgic
https://github.com/vondas-network/videobeaux/assets/7625379/3cef37d9-093f-4bd9-850c-4b163e8a3e01
https://github.com/schwwaaa/videobeaux/assets/7625379/3cef37d9-093f-4bd9-850c-4b163e8a3e01
overexposed_stutter
https://github.com/vondas-network/videobeaux/assets/7625379/f7250a1e-3cf5-4826-977a-a5a18b231ddb
https://github.com/schwwaaa/videobeaux/assets/7625379/f7250a1e-3cf5-4826-977a-a5a18b231ddb
overlay_img_pro
https://github.com/vondas-network/videobeaux/assets/7625379/3932d910-b898-4ed7-ba3a-288a708c0d83
https://github.com/schwwaaa/videobeaux/assets/7625379/3932d910-b898-4ed7-ba3a-288a708c0d83
pickle_juice
https://github.com/vondas-network/videobeaux/assets/7625379/387bfff5-fbdd-423d-b482-8ab4d5ce744f
https://github.com/schwwaaa/videobeaux/assets/7625379/387bfff5-fbdd-423d-b482-8ab4d5ce744f
recalled_sensor
@@ -465,19 +465,19 @@ https://github.com/user-attachments/assets/1770144d-4448-4719-8ef3-e44b720ec857
reverse
https://github.com/vondas-network/videobeaux/assets/7625379/74367227-6fee-455f-af36-804a1e6d6cb6
https://github.com/schwwaaa/videobeaux/assets/7625379/74367227-6fee-455f-af36-804a1e6d6cb6
scrolling_pro-1
https://github.com/vondas-network/videobeaux/assets/7625379/e84cfb49-f72d-449e-833a-0271903704f4
https://github.com/schwwaaa/videobeaux/assets/7625379/e84cfb49-f72d-449e-833a-0271903704f4
scrolling_pro-2
https://github.com/vondas-network/videobeaux/assets/7625379/19c6eef1-2bc0-4d84-b531-55f9ca07a912
https://github.com/schwwaaa/videobeaux/assets/7625379/19c6eef1-2bc0-4d84-b531-55f9ca07a912
scrolling_pro-3
https://github.com/vondas-network/videobeaux/assets/7625379/4a4272de-e074-4e37-8c2d-a282f2d8be57
https://github.com/schwwaaa/videobeaux/assets/7625379/4a4272de-e074-4e37-8c2d-a282f2d8be57
septic
@@ -485,7 +485,7 @@ https://github.com/user-attachments/assets/25f65267-60fa-421a-aaf3-02918844a488
slight_smear
https://github.com/vondas-network/videobeaux/assets/7625379/a7bca4c5-46b5-4b51-a827-6b8137d0117d
https://github.com/schwwaaa/videobeaux/assets/7625379/a7bca4c5-46b5-4b51-a827-6b8137d0117d
smudge
@@ -497,7 +497,7 @@ https://github.com/user-attachments/assets/28070fe5-52cd-42c9-93b7-a417c83add2d
speed
https://github.com/vondas-network/videobeaux/assets/7625379/c27efdb1-ae81-4d8d-a153-de6294b7fedf
https://github.com/schwwaaa/videobeaux/assets/7625379/c27efdb1-ae81-4d8d-a153-de6294b7fedf
splitting
@@ -505,23 +505,23 @@ https://github.com/user-attachments/assets/b6c13707-aaa8-416e-9f80-5ca6a386cd0f
stack_2x
https://github.com/vondas-network/videobeaux/assets/7625379/6f244aba-e741-46c9-9863-7fc43527a8d6
https://github.com/schwwaaa/videobeaux/assets/7625379/6f244aba-e741-46c9-9863-7fc43527a8d6
steel_wash
https://github.com/vondas-network/videobeaux/assets/7625379/eea99448-9352-48f1-a1ec-b2cac6ad056d
https://github.com/schwwaaa/videobeaux/assets/7625379/eea99448-9352-48f1-a1ec-b2cac6ad056d
stutter_pro-1
https://github.com/vondas-network/videobeaux/assets/7625379/03e234fb-d0fe-4d72-a11c-dff1bc59fa83
https://github.com/schwwaaa/videobeaux/assets/7625379/03e234fb-d0fe-4d72-a11c-dff1bc59fa83
stutter_pro-2
https://github.com/vondas-network/videobeaux/assets/7625379/e6d8c14a-9f20-4365-bb1f-5f473289a855
https://github.com/schwwaaa/videobeaux/assets/7625379/e6d8c14a-9f20-4365-bb1f-5f473289a855
stutter_pro-3
https://github.com/vondas-network/videobeaux/assets/7625379/864835ba-dc9d-4392-aa77-2cc062e2b700
https://github.com/schwwaaa/videobeaux/assets/7625379/864835ba-dc9d-4392-aa77-2cc062e2b700
t1000

Binary file not shown.

View File

View File

@@ -0,0 +1,15 @@
MINCE-prompt.txt
Hello! I have a module idea for a program I already made called cli.py that acts as a cli head to execute various programs or .py programs
- I've also provided an example program how we execute these commands in relation to cli.py
- I want to create a new program called "mince"
- I want the program to ingest a directory of videos.
- You can now select between several modes.
- mode 1: forward (merge together the folder of videos in sequence by number ascending or by letter ascending)
- mode 2: backward (merge together the folder of videos in sequence by number descending or by letter descending)
- mode 3: lenfor (merge videos together based on length in ascending order)
- mode 4: lenback (merge videos together based on length in descending order)
- mode 5: random (merge videos together in a random order using gaussian noise number)
- mode 6: random (merge videos together in a random order using fibonacci numbers)
- The final output is a rendered video from the directory using one of the modes as a guide for video edit sequencing.
-

View File

@@ -5,7 +5,7 @@
# INSTALL FFMPEG
brew install ffmpeg
# CLONE PROJECT
git clone git@github.com:vondas-network/videobeaux.git
git clone git@github.com:schwwaaa/videobeaux.git
# GO TO DIRECTORY
cd videobeaux
# CREATE VIRTUAL ENVIRONMENT

View File

@@ -3,7 +3,7 @@ name = "videobeaux"
version = "0.1.0"
description = "Your friendly multilateral video toolkit built for artists by artists. \n It's your best friend!"
authors = [
{ name = "Vondas Software", email = "schwwaaa@gmail.com" }
{ name = "schwwaaa", email = "schwwaaa@gmail.com" }
]
requires-python = ">=3.8"

View File

@@ -15,7 +15,7 @@ def main():
print("📺 The friendly multilateral video toolkit built for artists by artists. ")
print("🫂 It's your best friend!")
print()
print("🌐 https://vondas.network")
print("🌐 schwwaaa")
#print('-' * 50)
print()

View File

@@ -1,113 +1,345 @@
# from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress
# from pathlib import Path
# import sys
# import math
# # Video dimension presets (unchanged)
# VIDEO_PRESETS = {
# "sd": (320, 240), "720hd": (640, 360), "1080hd": (960, 540), "widescreen": (320, 180),
# "portrait1080": (1080, 1620), "480p": (640, 480), "576p": (720, 576), "720p": (1280, 720),
# "1080p": (1920, 1080), "1440p": (2560, 1440), "4k": (3840, 2160), "8k": (7680, 4320),
# "vga": (640, 480), "qvga": (320, 240), "wvga": (800, 480), "svga": (800, 600),
# "xga": (1024, 768), "wxga": (1280, 800), "sxga": (1280, 1024), "uxga": (1600, 1200),
# "wuxga": (1920, 1200), "qwxga": (2048, 1152), "qhd": (2560, 1440), "wqxga": (2560, 1600),
# "5k": (5120, 2880), "portrait720": (720, 1280), "portrait4k": (2160, 3840),
# "square1080": (1080, 1080), "square720": (720, 720), "cinema4k": (4096, 2160),
# "ultrawide1080": (2560, 1080), "ultrawide1440": (3440, 1440),
# "instagram_feed": (1080, 1080), "instagram_reels": (1080, 1920),
# "instagram_stories": (1080, 1920), "tiktok_video": (1080, 1920),
# "youtube_standard": (1920, 1080), "youtube_shorts": (1080, 1920),
# "facebook_feed": (1080, 1080), "facebook_stories": (1080, 1920),
# "twitter_video": (1280, 720), "twitter_square": (1080, 1080),
# "linkedin_video": (1920, 1080), "linkedin_square": (1080, 1080),
# "snapchat_video": (1080, 1920), "pinterest_video": (1080, 1920),
# "pinterest_square": (1000, 1000)
# }
# def register_arguments(parser):
# parser.description = (
# "Converts an input video to a specified preset dimension. "
# "Choose how to handle aspect ratio: pad, fit, fill-crop, or stretch. "
# "Use --portrait-full to force a 9:16 full-frame vertical output."
# )
# parser.add_argument(
# "--output-format", required=True, type=str,
# help="Format to convert output into (e.g., mp4, mov). Output can be a filename without extension."
# )
# parser.add_argument(
# "--preset", required=True, type=str, choices=VIDEO_PRESETS.keys(),
# help="Preset dimension (e.g., 1080p, instagram_reels)."
# )
# # Flexible aspect behavior. If provided, overrides --translate.
# parser.add_argument(
# "--mode",
# choices=["pad", "fit", "fill", "fill_crop", "stretch"],
# help=(
# "Aspect behavior: "
# "'pad' (letter/pillarbox to exact size), "
# "'fit' (scale to fit within box, no pad), "
# "'fill'/'fill_crop' (cover box, center-crop to exact size), "
# "'stretch' (ignore AR, stretch to exact size). "
# "If omitted, falls back to --translate (no→pad, yes→stretch)."
# )
# )
# # Back-compat with existing calls
# parser.add_argument(
# "--translate", type=str, choices=["yes", "no"], default="no",
# help="(Deprecated in favor of --mode) 'yes' = stretch; 'no' = pad."
# )
# # NEW: Force a true full-height Portrait 9:16 canvas (no borders).
# parser.add_argument(
# "--portrait-full", action="store_true",
# help=(
# "Force output to 9:16 portrait and fill the frame (center-crop). "
# "Uses the larger side of the preset as the output HEIGHT, and sets WIDTH = round(HEIGHT*9/16). "
# "Example: 1080p -> 1080x1920, 4k -> 2160x3840."
# )
# )
# # NOTE: input/output/force provided by top-level CLI.
# def _resolve_output_path(output: Path, fmt: str) -> Path:
# suffix = output.suffix.lower().lstrip(".")
# if suffix == fmt.lower():
# return output
# return output.with_suffix(f".{fmt}")
# def _even(x: int) -> int:
# return x if x % 2 == 0 else x - 1
# def _portrait_full_dims(w: int, h: int) -> tuple[int, int]:
# """
# Use the larger side of the preset as HEIGHT, set WIDTH for exact 9:16.
# Ensures even dimensions (some encoders require mod2).
# """
# H = max(w, h)
# W = int(round(H * 9 / 16))
# return _even(W), _even(H)
# def _vf_for_mode(mode: str, w: int, h: int) -> str:
# """
# - pad: keep AR, add borders to reach exact WxH
# - fit: keep AR, scale to fit within WxH (no padding)
# - fill_crop: keep AR, scale to cover WxH then crop center to exact WxH
# - stretch: ignore AR, force exact WxH
# """
# if mode == "stretch":
# return f"scale={w}:{h}:force_original_aspect_ratio=disable,setsar=1,setdar={w}/{h}"
# if mode == "fit":
# return f"scale={w}:{h}:force_original_aspect_ratio=decrease,setsar=1"
# if mode in ("fill", "fill_crop"):
# return (
# f"scale={w}:{h}:force_original_aspect_ratio=increase,"
# f"crop={w}:{h}:(in_w-out_w)/2:(in_h-out_h)/2,setsar=1,setdar={w}/{h}"
# )
# # default = pad
# return (
# f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
# f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,setdar={w}/{h}"
# )
# def run(args):
# output_path = Path(args.output)
# clean_output = _resolve_output_path(output_path, args.output_format)
# # Ensure destination folder exists
# clean_output.parent.mkdir(parents=True, exist_ok=True)
# if clean_output.exists() and not getattr(args, "force", False):
# print(f"❌ {clean_output} already exists. Use --force to overwrite.")
# sys.exit(1)
# if args.preset not in VIDEO_PRESETS:
# print(f"❌ Invalid preset: {args.preset}. Available presets: {', '.join(VIDEO_PRESETS.keys())}")
# sys.exit(1)
# target_width, target_height = VIDEO_PRESETS[args.preset]
# # If full portrait requested, override canvas to strict 9:16 using preset's larger side.
# if getattr(args, "portrait_full", False):
# target_width, target_height = _portrait_full_dims(target_width, target_height)
# # Force a fill/crop behavior to guarantee full-frame portrait (no borders).
# chosen_mode = "fill_crop"
# else:
# # Choose behavior: --mode overrides legacy --translate
# chosen_mode = args.mode if args.mode else ("stretch" if args.translate == "yes" else "pad")
# vf = _vf_for_mode(chosen_mode, target_width, target_height)
# command = [
# "ffmpeg",
# "-i", str(args.input),
# # Map robustly: succeed even if the source has no audio
# "-map", "0:v:0",
# "-map", "0:a:0?", # optional first audio track
# "-vf", vf,
# "-c:v", "libx264",
# "-b:v", "5000k",
# "-pix_fmt", "yuv420p",
# "-preset", "medium",
# "-c:a", "aac",
# "-b:a", "160k",
# "-ar", "48000",
# "-movflags", "+faststart",
# str(clean_output),
# ]
# if getattr(args, "force", False):
# command = command[:1] + ["-y"] + command[1:]
# run_ffmpeg_with_progress(command, args.input, clean_output)
from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress
from pathlib import Path
import sys
# Video dimension presets
# Video dimension presets (unchanged)
VIDEO_PRESETS = {
"sd": (320, 240), # Standard Definition (4:3)
"720hd": (640, 360), # 720p HD reduced (16:9)
"1080hd": (960, 540), # 1080p HD reduced (16:9)
"widescreen": (320, 180), # Widescreen low-res (16:9)
"portrait1080": (1080, 1620), # Portrait 1080p (9:13.5)
"480p": (640, 480), # Standard Definition (4:3)
"576p": (720, 576), # PAL Standard Definition (4:3)
"720p": (1280, 720), # HD (16:9)
"1080p": (1920, 1080), # Full HD (16:9)
"1440p": (2560, 1440), # QHD/2K (16:9)
"4k": (3840, 2160), # 4K UHD (16:9)
"8k": (7680, 4320), # 8K UHD (16:9)
"vga": (640, 480), # VGA (4:3)
"qvga": (320, 240), # Quarter VGA (4:3)
"wvga": (800, 480), # Wide VGA (5:3)
"svga": (800, 600), # Super VGA (4:3)
"xga": (1024, 768), # Extended Graphics Array (4:3)
"wxga": (1280, 800), # Wide XGA (16:10)
"sxga": (1280, 1024), # Super XGA (5:4)
"uxga": (1600, 1200), # Ultra XGA (4:3)
"wuxga": (1920, 1200), # Widescreen Ultra XGA (16:10)
"qwxga": (2048, 1152), # Quad Wide XGA (16:9)
"qhd": (2560, 1440), # Quad HD (16:9)
"wqxga": (2560, 1600), # Wide Quad XGA (16:10)
"5k": (5120, 2880), # 5K (16:9)
"portrait720": (720, 1280), # Portrait 720p (9:16)
"portrait4k": (2160, 3840), # Portrait 4K (9:16)
"square1080": (1080, 1080), # Square 1080p (1:1)
"square720": (720, 720), # Square 720p (1:1)
"cinema4k": (4096, 2160), # 4K DCI (Digital Cinema, ~17:9)
"ultrawide1080": (2560, 1080), # Ultrawide 1080p (21:9)
"ultrawide1440": (3440, 1440), # Ultrawide 1440p (21:9)
"instagram_feed": (1080, 1080), # Instagram square video (1:1)
"instagram_reels": (1080, 1920), # Instagram Reels/TikTok (9:16)
"instagram_stories": (1080, 1920), # Instagram Stories (9:16)
"tiktok_video": (1080, 1920), # TikTok standard video (9:16)
"youtube_standard": (1920, 1080), # YouTube standard video (16:9)
"youtube_shorts": (1080, 1920), # YouTube Shorts (9:16)
"facebook_feed": (1080, 1080), # Facebook in-feed video (1:1 recommended)
"facebook_stories": (1080, 1920), # Facebook Stories (9:16)
"twitter_video": (1280, 720), # Twitter/X video (16:9, recommended)
"twitter_square": (1080, 1080), # Twitter/X square video (1:1)
"linkedin_video": (1920, 1080), # LinkedIn video (16:9, recommended)
"linkedin_square": (1080, 1080), # LinkedIn square video (1:1)
"snapchat_video": (1080, 1920), # Snapchat video (9:16)
"pinterest_video": (1080, 1920), # Pinterest video (9:16)
"pinterest_square": (1000, 1000) # Pinterest square video (1:1)
"sd": (320, 240), "720hd": (640, 360), "1080hd": (960, 540), "widescreen": (320, 180),
"portrait1080": (1080, 1620), "480p": (640, 480), "576p": (720, 576), "720p": (1280, 720),
"1080p": (1920, 1080), "1440p": (2560, 1440), "4k": (3840, 2160), "8k": (7680, 4320),
"vga": (640, 480), "qvga": (320, 240), "wvga": (800, 480), "svga": (800, 600),
"xga": (1024, 768), "wxga": (1280, 800), "sxga": (1280, 1024), "uxga": (1600, 1200),
"wuxga": (1920, 1200), "qwxga": (2048, 1152), "qhd": (2560, 1440), "wqxga": (2560, 1600),
"5k": (5120, 2880), "portrait720": (720, 1280), "portrait4k": (2160, 3840),
"square1080": (1080, 1080), "square720": (720, 720), "cinema4k": (4096, 2160),
"ultrawide1080": (2560, 1080), "ultrawide1440": (3440, 1440),
"instagram_feed": (1080, 1080), "instagram_reels": (1080, 1920),
"instagram_stories": (1080, 1920), "tiktok_video": (1080, 1920),
"youtube_standard": (1920, 1080), "youtube_shorts": (1080, 1920),
"facebook_feed": (1080, 1080), "facebook_stories": (1080, 1920),
"twitter_video": (1280, 720), "twitter_square": (1080, 1080),
"linkedin_video": (1920, 1080), "linkedin_square": (1080, 1080),
"snapchat_video": (1080, 1920), "pinterest_video": (1080, 1920),
"pinterest_square": (1000, 1000)
}
def register_arguments(parser):
parser.description = "Converts an input video to a specified preset dimension. Optionally stretch to fit dimensions or maintain aspect ratio with padding."
parser.add_argument(
"--output-format",
required=True,
type=str,
help="Format to convert output into (e.g., mp4, mov, etc). Output argument can just be a filename with no extension."
)
parser.add_argument(
"--preset",
required=True,
type=str,
choices=VIDEO_PRESETS.keys(),
help="Preset dimension to convert the video to (e.g., 1080p, instagram_reels, etc)."
)
parser.add_argument(
"--translate",
type=str,
choices=["yes", "no"],
default="no",
help="Whether to stretch video to fit preset dimensions ('yes') or maintain aspect ratio with padding ('no', default)."
parser.description = (
"Converts an input video to a specified preset dimension. "
"Choose how to handle aspect ratio: pad, fit, fill-crop, or stretch. "
"Use --portrait-full to force a 9:16 full-frame vertical output."
)
parser.add_argument("--output-format", required=True, type=str,
help="Format for output file extension (e.g., mp4, mov).")
parser.add_argument("--preset", required=True, type=str, choices=VIDEO_PRESETS.keys(),
help="Preset dimension (e.g., 1080p, instagram_reels).")
# Flexible aspect behavior (overrides --translate when provided)
parser.add_argument("--mode",
choices=["pad", "fit", "fill", "fill_crop", "stretch"],
help="pad: keep AR + borders; fit: keep AR, no pad; "
"fill/fill_crop: cover + center-crop; stretch: ignore AR.")
# Back-compat
parser.add_argument("--translate", type=str, choices=["yes", "no"], default="no",
help="(Deprecated) yes=stretch, no=pad if --mode not set.")
# NEW: crop/pad alignment & cosmetics
parser.add_argument("--anchor",
choices=["center", "top", "bottom", "left", "right",
"top_left", "top_right", "bottom_left", "bottom_right"],
default="center",
help="Where to bias the crop or padding.")
parser.add_argument("--pad-color", default="#000000",
help="Padding color for --mode pad. Hex like #000000, #FFFFFF.")
parser.add_argument("--scale-flags",
choices=["lanczos", "bicubic", "bilinear", "neighbor"],
default="lanczos",
help="Scaler kernel for the scale filter.")
# 9:16 portrait override (no borders)
parser.add_argument("--portrait-full", action="store_true",
help="Force 9:16 portrait output and fill (cover + crop).")
def _resolve_output_path(output: Path, fmt: str) -> Path:
sfx = output.suffix.lower().lstrip(".")
return output if sfx == fmt.lower() else output.with_suffix(f".{fmt}")
def _even(x: int) -> int:
return x if x % 2 == 0 else x - 1
def _portrait_full_dims(w: int, h: int) -> tuple[int, int]:
# Use the larger side of the preset as HEIGHT; WIDTH = round(HEIGHT*9/16)
H = max(w, h)
W = int(round(H * 9 / 16))
return _even(W), _even(H)
def _color_hex_to_ffmpeg(color: str) -> str:
c = color.strip()
if c.startswith("#"):
c = c[1:]
# Accept 3/6 hex; normalize to 6
if len(c) == 3:
c = "".join(ch*2 for ch in c)
return f"0x{c}"
def _anchor_offsets(anchor: str, axis_len: str) -> tuple[str, str]:
"""
Return (x_expr, y_expr) for pad or crop offsets based on anchor.
axis_len is 'pad' or 'crop' context indicating how to compute expressions.
"""
# Center expressions
cx = "(ow-iw)/2" if axis_len == "pad" else "(in_w-out_w)/2"
cy = "(oh-ih)/2" if axis_len == "pad" else "(in_h-out_h)/2"
if anchor == "center": return cx, cy
if anchor == "top": return cx, ( "0" if axis_len == "pad" else "0")
if anchor == "bottom": return cx, ( "(oh-ih)" if axis_len == "pad" else "(in_h-out_h)" )
if anchor == "left": return ( "0" if axis_len == "pad" else "0"), cy
if anchor == "right": return ( "(ow-iw)" if axis_len == "pad" else "(in_w-out_w)"), cy
if anchor == "top_left": return ( "0" if axis_len == "pad" else "0"), ( "0" if axis_len == "pad" else "0")
if anchor == "top_right": return ( "(ow-iw)" if axis_len == "pad" else "(in_w-out_w)"), ( "0" if axis_len == "pad" else "0")
if anchor == "bottom_left": return ( "0" if axis_len == "pad" else "0"), ( "(oh-ih)" if axis_len == "pad" else "(in_h-out_h)")
if anchor == "bottom_right": return ( "(ow-iw)" if axis_len == "pad" else "(in_w-out_w)"), ( "(oh-ih)" if axis_len == "pad" else "(in_h-out_h)")
return cx, cy
def _vf_for_mode(mode: str, w: int, h: int, anchor: str, pad_color_hex: str, scale_flags: str) -> str:
color = _color_hex_to_ffmpeg(pad_color_hex)
if mode == "stretch":
return f"scale={w}:{h}:flags={scale_flags}:force_original_aspect_ratio=disable,setsar=1,setdar={w}/{h}"
if mode == "fit":
# Keep AR, fit into WxH; no padding
return f"scale={w}:{h}:flags={scale_flags}:force_original_aspect_ratio=decrease,setsar=1"
if mode in ("fill", "fill_crop"):
# Cover, then crop based on anchor
x, y = _anchor_offsets(anchor, "crop")
return (f"scale={w}:{h}:flags={scale_flags}:force_original_aspect_ratio=increase,"
f"crop={w}:{h}:{x}:{y},setsar=1,setdar={w}/{h}")
# default = pad
x, y = _anchor_offsets(anchor, "pad")
return (f"scale={w}:{h}:flags={scale_flags}:force_original_aspect_ratio=decrease,"
f"pad={w}:{h}:{x}:{y}:{color},setsar=1,setdar={w}/{h}")
def run(args):
output_path = Path(args.output)
clean_output = output_path.with_suffix(f".{args.output_format}")
if clean_output.exists() and not args.force:
clean_output = _resolve_output_path(output_path, args.output_format)
clean_output.parent.mkdir(parents=True, exist_ok=True)
if clean_output.exists() and not getattr(args, "force", False):
print(f"{clean_output} already exists. Use --force to overwrite.")
sys.exit(1)
# Get the target dimensions from the preset
if args.preset not in VIDEO_PRESETS:
print(f"❌ Invalid preset: {args.preset}. Available presets: {', '.join(VIDEO_PRESETS.keys())}")
sys.exit(1)
target_width, target_height = VIDEO_PRESETS[args.preset]
# FFmpeg command based on translate flag
if args.translate == "yes":
# Stretch video to fit dimensions exactly, ignoring aspect ratio
video_filter = f"scale={target_width}:{target_height}:force_original_aspect_ratio=disable"
if getattr(args, "portrait_full", False):
target_width, target_height = _portrait_full_dims(target_width, target_height)
chosen_mode = "fill_crop" # guarantee full-frame portrait
else:
# Maintain aspect ratio with padding
video_filter = f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2"
chosen_mode = args.mode if args.mode else ("stretch" if args.translate == "yes" else "pad")
vf = _vf_for_mode(chosen_mode, target_width, target_height, args.anchor, args.pad_color, args.scale_flags)
command = [
"ffmpeg",
"-i", str(args.input),
"-vf", video_filter,
"-c:v", "libx264", # Use H.264 for compatibility
"-c:a", "aac", # Use AAC for audio compatibility
"-b:v", "5000k", # Set reasonable video bitrate
str(clean_output)
# Map robustly: succeed even if the source has no audio
"-map", "0:v:0",
"-map", "0:a:0?", # optional first audio track
"-vf", vf,
"-c:v", "libx264",
"-b:v", "5000k",
"-pix_fmt", "yuv420p",
"-preset", "medium",
"-c:a", "aac",
"-b:a", "160k",
"-ar", "48000",
"-movflags", "+faststart",
str(clean_output),
]
# Add -y flag if force overwrite is enabled
run_ffmpeg_with_progress((command[:1] + ["-y"] + command[1:]) if args.force else command, args.input, clean_output)
if getattr(args, "force", False):
command = command[:1] + ["-y"] + command[1:]
run_ffmpeg_with_progress(command, args.input, clean_output)

View File

@@ -0,0 +1,342 @@
# videobeaux/programs/convert_mux.py
# A videobeaux-ready “convert” module that mirrors your standalone converter.
# - Uses register_arguments(parser) + run(args) like other videobeaux programs.
# - Respects global -i/--input, -o/--output, -F/--force from cli.py
# - Shows progress via run_ffmpeg_with_progress, just like your other modules.
import argparse
import shlex
import sys
from pathlib import Path
from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress # progress + error plumbing
# ------------------------------
# Helpers
# ------------------------------
def _ext_lower(path: str) -> str:
return Path(path).suffix.lower().lstrip(".")
def _guess_container_from_path(output_path: str, fmt_override: str | None = None) -> str:
"""
Map extension or fmt override to an FFmpeg muxer name.
NOTE: cli.py currently forces .mp4 extension. We still keep this map for completeness.
"""
if fmt_override:
return fmt_override.lower()
ext = _ext_lower(output_path)
alias = {
"mp4":"mp4", "m4v":"mp4", "mov":"mov", "qt":"mov", "mkv":"matroska",
"webm":"webm", "avi":"avi", "wmv":"asf", "asf":"asf",
"flv":"flv", "ts":"mpegts", "m2ts":"mpegts", "mpeg":"mpeg", "mpg":"mpeg",
"mxf":"mxf", "wav":"wav", "mp3":"mp3", "m4a":"ipod", "aac":"adts", "ogg":"ogg",
"oga":"ogg", "opus":"ogg", "flac":"flac", "alac":"ipod",
"gif":"gif",
# image sequences
"jpg":"image2", "jpeg":"image2", "png":"image2", "tif":"image2", "tiff":"image2", "bmp":"image2", "exr":"image2"
}
return alias.get(ext, ext or "mp4")
def _default_codecs_for_container(mux: str) -> tuple[str | None, str | None]:
"""
Conservative defaults if no profile and no explicit codec flags were provided.
"""
if mux in ("mp4", "mov", "matroska"):
return ("libx264", "aac")
if mux == "webm":
return ("libvpx-vp9", "libopus")
if mux == "gif":
return (None, None) # handled via palette method, not simple v/a codecs
if mux in ("mp3","adts","ogg","flac","wav","ipod"):
return (None, None) # audio-only handled separately
if mux == "mxf":
return ("mpeg2video", "pcm_s16le")
if mux == "mpegts":
return ("libx264", "aac")
if mux == "avi":
return ("mpeg4", "mp3")
return ("libx264", "aac")
def _profile_container_hint(name: str) -> str | None:
"""
Rough container hint to sanity-check against .mp4 limitation in cli.py.
"""
if name.startswith("mp4_"):
return "mp4"
if name.startswith("webm_"):
return "webm"
if name in {"prores_422", "prores_4444"}:
return "mov"
if name.startswith("dnxhr"):
return "mov"
if name.startswith("mxf_"):
return "mxf"
if name in {"lossless_ffv1"}:
return "matroska"
if name in {"gif", "png_seq", "jpg_seq"}:
return name # special
if name in {"mp3_320", "aac_192", "flac"}:
return "audio"
if name.startswith("avi_"):
return "avi"
return None
# ------------------------------
# Profiles (same names/values you provided)
# ------------------------------
def _PROFILES():
return {
# Web/General delivery
"mp4_h264": lambda: [
"-c:v","libx264","-preset","veryfast","-crf","18",
"-pix_fmt","yuv420p",
"-movflags","+faststart",
"-c:a","aac","-b:a","192k","-ac","2"
],
"mp4_hevc": lambda: [
"-c:v","libx265","-preset","medium","-crf","22",
"-tag:v","hvc1",
"-pix_fmt","yuv420p",
"-movflags","+faststart",
"-c:a","aac","-b:a","192k","-ac","2"
],
"mp4_av1": lambda: [
"-c:v","libaom-av1","-crf","28","-b:v","0",
"-pix_fmt","yuv420p",
"-movflags","+faststart",
"-c:a","aac","-b:a","192k","-ac","2"
],
# WebM
"webm_vp9": lambda: [
"-c:v","libvpx-vp9","-b:v","0","-crf","30",
"-row-mt","1",
"-pix_fmt","yuv420p",
"-c:a","libopus","-b:a","160k","-ac","2"
],
"webm_av1": lambda: [
"-c:v","libaom-av1","-crf","32","-b:v","0",
"-pix_fmt","yuv420p",
"-c:a","libopus","-b:a","160k","-ac","2"
],
# Professional mezzanine
"prores_422": lambda: [
"-c:v","prores_ks","-profile:v","2",
"-pix_fmt","yuv422p10le",
"-c:a","pcm_s16le"
],
"prores_4444": lambda: [
"-c:v","prores_ks","-profile:v","4",
"-pix_fmt","yuva444p10le",
"-c:a","pcm_s24le"
],
"dnxhr_hq": lambda: [
"-c:v","dnxhd","-profile:v","dnxhr_hq",
"-pix_fmt","yuv422p",
"-c:a","pcm_s16le"
],
# Broadcast MXF (OP1a)
"mxf_xdcamhd50_1080i59": lambda: [
"-c:v","mpeg2video","-b:v","50M","-minrate","50M","-maxrate","50M","-bufsize","17825792",
"-r","30000/1001","-flags","+ildct+ilme","-top","1",
"-pix_fmt","yuv422p",
"-c:a","pcm_s24le","-ar","48000","-ac","2",
"-f","mxf"
],
# Archival lossless
"lossless_ffv1": lambda: [
"-c:v","ffv1","-level","3","-g","1","-slicecrc","1",
"-c:a","pcm_s24le"
],
# GIF (special palette path handled later)
"gif": lambda: ["-filter_complex","[0:v]fps=15,scale=iw:-2:flags=lanczos"],
# Image sequences
"png_seq": lambda: ["-c:v","png"],
"jpg_seq": lambda: ["-qscale:v","2"],
# Audio-only
"mp3_320": lambda: ["-vn","-c:a","libmp3lame","-b:a","320k"],
"aac_192": lambda: ["-vn","-c:a","aac","-b:a","192k"],
"flac": lambda: ["-vn","-c:a","flac"],
# AVI speed-focused presets
"avi_mjpeg_fast": lambda: ["-c:v","mjpeg","-q:v","3","-c:a","pcm_s16le"],
"avi_mpeg4_fast": lambda: ["-c:v","mpeg4","-qscale:v","3","-bf","0","-mbd","0","-c:a","mp3","-b:a","192k"]
}
# ------------------------------
# Command builder
# ------------------------------
def _build_ffmpeg_command(args: argparse.Namespace) -> list[str]:
in_path = args.input
out_path = args.output
# Even though cli.py appends .mp4 and enforces it, we preserve format logic for future flexibility.
mux = _guess_container_from_path(out_path, args.format)
# FAIL FAST if profile clearly mismatches .mp4 (helps avoid confusing FFmpeg errors)
if out_path.lower().endswith(".mp4") and args.profile:
hint = _profile_container_hint(args.profile)
if hint and hint not in ("mp4", "audio"): # audio-only is also incompatible with .mp4 filename
raise SystemExit(
f"❌ Profile '{args.profile}' expects container '{hint}', "
f"but your output is '.mp4' (cli.py is MP4-only). "
f"Pick one of: mp4_h264, mp4_hevc, mp4_av1 — or pass raw FFmpeg flags after ' -- '."
)
# Base invocation (respect global --force)
cmd = ["ffmpeg", "-hide_banner"]
cmd += ["-y"] if args.force else ["-n"]
cmd += ["-i", in_path]
# Stream copy?
if args.copy:
cmd += ["-c", "copy"]
if args.ffmpeg_args:
cmd += args.ffmpeg_args
cmd += [out_path]
return cmd
# Start collecting settings
vcodec = args.vcodec
acodec = args.acodec
# Apply profile, if any
profile_args = []
if args.profile:
prof = _PROFILES().get(args.profile)
if not prof:
raise SystemExit(f"❌ Unknown profile: {args.profile}")
profile_args = prof()
# If no profile -> default codecs for the container
if not args.profile:
dv, da = _default_codecs_for_container(mux)
if not vcodec and dv: vcodec = dv
if not acodec and da: acodec = da
# GIF special path (but remember: cli forces .mp4; we still guard for correctness)
if mux == "gif" or args.profile == "gif":
if out_path.lower().endswith(".mp4"):
raise SystemExit("❌ GIF output requested, but output file ends with .mp4 (cli is MP4-only).")
vf_chain = args.vf if args.vf else "fps=15,scale=iw:-2:flags=lanczos"
palette_chain = f"[0:v]{vf_chain},palettegen[p];[0:v]{vf_chain}[v];[v][p]paletteuse"
gif_cmd = ["ffmpeg", "-hide_banner"] + (["-y"] if args.force else ["-n"]) + [
"-i", in_path,
"-filter_complex", palette_chain,
"-gifflags", "+transdiff",
out_path
]
return gif_cmd
# Video opts
if vcodec: cmd += ["-c:v", vcodec]
if args.crf is not None: cmd += ["-crf", str(args.crf)]
if args.bitrate: cmd += ["-b:v", args.bitrate]
if args.maxrate: cmd += ["-maxrate", args.maxrate]
if args.bufsize: cmd += ["-bufsize", args.bufsize]
if args.preset: cmd += ["-preset", args.preset]
if args.profile_v: cmd += ["-profile:v", args.profile_v]
if args.level: cmd += ["-level", args.level]
if args.pix_fmt: cmd += ["-pix_fmt", args.pix_fmt]
if args.gop: cmd += ["-g", str(args.gop)]
if args.r: cmd += ["-r", args.r]
if args.vf: cmd += ["-vf", args.vf]
if args.tagv: cmd += ["-tag:v", args.tagv]
# Audio opts
if acodec: cmd += ["-c:a", acodec]
if args.abitrate: cmd += ["-b:a", args.abitrate]
if args.ac is not None: cmd += ["-ac", str(args.ac)]
if args.ar: cmd += ["-ar", args.ar]
# MP4 nicety
if mux == "mp4" and (not args.profile or "+faststart" not in " ".join(profile_args)):
cmd += ["-movflags", "+faststart"]
# Append profile-specific args last (so they can override)
if profile_args:
cmd += profile_args
# AVI niceties if user forces .avi in the future; harmless if .mp4
if mux == "avi" and not args.profile:
have_quality_flag = any(x is not None for x in (args.crf, args.bitrate, args.maxrate, args.bufsize))
if (vcodec or "").lower() == "mpeg4" and not have_quality_flag:
cmd += ["-qscale:v","3","-bf","0"]
if (vcodec or "").lower() == "mjpeg" and not have_quality_flag:
cmd += ["-q:v","3"]
if not acodec:
cmd += ["-c:a","mp3","-b:a","192k"]
# Raw passthrough after --
if args.ffmpeg_args:
cmd += args.ffmpeg_args
cmd += [out_path]
return cmd
# ------------------------------
# videobeaux hooks
# ------------------------------
def register_arguments(parser):
"""
Register per-program flags only. Global -i/-o/-F/--help come from cli.py.
"""
parser.description = "Black-box FFmpeg converter: any format in → any format out (mp4 outputs enforced by cli)."
# Container/format control
parser.add_argument("--format", help="Force container/muxer hint (mp4, mov, webm, matroska, mxf, gif, image2, avi)")
parser.add_argument("--profile", choices=sorted(_PROFILES().keys()), help="Apply a curated preset")
# Codecs & quality
parser.add_argument("--vcodec", help="Video codec (e.g., libx264, libx265, libaom-av1, prores_ks, dnxhd, mpeg2video, mpeg4, mjpeg)")
parser.add_argument("--acodec", help="Audio codec (e.g., aac, libopus, libmp3lame, mp3, pcm_s16le)")
parser.add_argument("--crf", type=int, help="Constant Rate Factor (quality target)")
parser.add_argument("--bitrate", help="Video bitrate, e.g. 5M")
parser.add_argument("--maxrate", help="Video maxrate")
parser.add_argument("--bufsize", help="VBV buffer size")
parser.add_argument("--preset", help="Codec speed/efficiency preset")
parser.add_argument("--profile-v", dest="profile_v", help="Video codec profile (e.g., high/main/baseline; or ProRes profile index)")
parser.add_argument("--level", help="Video level (e.g., 4.1)")
parser.add_argument("--pix-fmt", dest="pix_fmt", help="Pixel format (e.g., yuv420p, yuv422p10le, yuva444p10le)")
parser.add_argument("--gop", type=int, help="GOP/keyframe interval (frames)")
parser.add_argument("-r", help="Output frame rate (e.g., 30000/1001, 25, 24)")
parser.add_argument("--vf", help="Video filtergraph")
parser.add_argument("--tagv", help="Force video fourcc/tag (e.g., hvc1)")
# Audio
parser.add_argument("--abitrate", help="Audio bitrate (e.g., 192k)")
parser.add_argument("--ac", type=int, help="Audio channels")
parser.add_argument("--ar", help="Audio sample rate (e.g., 48000)")
# Stream copy
parser.add_argument("--copy", action="store_true", help="Stream copy all streams when compatible (no re-encode)")
# Passthrough: raw ffmpeg args after `--`
parser.add_argument("ffmpeg_args", nargs=argparse.REMAINDER, help="Raw args after -- go straight to ffmpeg")
def run(args):
"""
Build command and execute through videobeaux's progress runner.
"""
cmd = _build_ffmpeg_command(args)
# Friendly echo so users can see exactly what will run (quoted like your original)
print("↪︎", " ".join(shlex.quote(c) for c in cmd))
# Use the shared runner so progress + errors are consistent across programs
run_ffmpeg_with_progress(
(cmd[:1] + ["-y"] + cmd[1:]) if args.force else cmd,
args.input,
args.output
)

View File

@@ -10,13 +10,13 @@ Modes:
randn : random order via Gaussian (normal) noise keys
randfib : pseudo-random order via Fibonacci modulo walk
New:
• Robust timestamp handling on concat (prevents absurd durations like 239:13:39).
• Optional pre-merge NORMALIZATION: unify dimensions, pixel format, FPS, audio rate/channels.
- --size WxH (e.g., 1920x1080)
- --fit fit|fill|stretch (default: fit)
- --fps 30, --ar 48000, --ac 2, --pixfmt yuv420p, --norm-crf 20, --norm-preset medium
We normalize each source into temp mp4s with consistent params, then concat via stream-copy.
Engines:
demuxer (default) : concat demuxer. Fast (copy) when inputs match; safest if you pre-normalize.
filter : single-pass filter_complex concat. Use when you want everything in one encode.
Safeties:
--decode-tolerant : ignore tiny decode errors; drop corrupt packets instead of stalling.
--hard-trim : trim each clips A/V to its probed duration before concat (prevents frozen tails).
"""
import re
@@ -28,12 +28,6 @@ import subprocess
from pathlib import Path
from typing import List, Optional, Tuple
# Try to use your progress helper if available (nice when re-encoding).
try:
from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress # type: ignore
except Exception:
run_ffmpeg_with_progress = None # fallback to subprocess if not present
# ---------- Utilities ----------
VIDEO_EXTS = {
@@ -53,13 +47,38 @@ def _numeric_token(stem: str) -> Optional[int]:
def _probe_duration_seconds(path: Path) -> float:
try:
out = subprocess.check_output(
["ffprobe","-v","error","-show_entries","format=duration","-of","default=noprint_wrappers=1:nokey=1",str(path)],
["ffprobe","-v","error","-show_entries","format=duration",
"-of","default=noprint_wrappers=1:nokey=1",str(path)],
stderr=subprocess.STDOUT
).decode().strip()
return float(out)
except Exception:
return 0.0
def _probe_wh(path: Path) -> Tuple[int,int]:
try:
out = subprocess.check_output(
["ffprobe","-v","error","-select_streams","v:0",
"-show_entries","stream=width,height",
"-of","csv=p=0", str(path)],
stderr=subprocess.STDOUT
).decode().strip()
w,h = out.split(",")
return int(w), int(h)
except Exception:
return 1920,1080
def _has_audio(path: Path) -> bool:
try:
out = subprocess.check_output(
["ffprobe","-v","error","-select_streams","a:0",
"-show_entries","stream=index","-of","csv=p=0", str(path)],
stderr=subprocess.STDOUT
).decode().strip()
return bool(out)
except Exception:
return False
def _gather_inputs(indir: Path) -> List[Path]:
files = [p for p in sorted(indir.iterdir()) if _is_video(p)]
if not files:
@@ -110,173 +129,150 @@ def _order_randfib(files: List[Path], seed: Optional[int]) -> List[Path]:
order.extend(leftovers)
return order
# ---------- Concat list writing (ABSOLUTE paths, escaped) ----------
def _ff_concat_escape(p: Path) -> str:
s = str(p.resolve(strict=False))
return s.replace("'", r"'\''")
def _write_concat_list(paths: List[Path], list_path: Path):
with list_path.open("w", encoding="utf-8", newline="\n") as f:
for p in paths:
esc = _ff_concat_escape(p)
f.write(f"file '{esc}'\n")
# ---------- Shell runner ----------
def _run(cmd: List[str]):
print("↪︎"," ".join(str(c) for c in cmd))
proc = subprocess.run(cmd)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg exited with code {proc.returncode}")
# ---------- Normalization ----------
def _parse_size(size: Optional[str]) -> Optional[Tuple[int,int]]:
def _parse_size(size: Optional[str]) -> Optional[tuple]:
if not size: return None
if "x" not in size.lower():
raise ValueError("Size must be WxH, e.g., 1920x1080")
w,h = size.lower().split("x",1)
return int(w), int(h)
def _norm_filters(w: int, h: int, fit: str, pixfmt: str) -> str:
"""
Build a filterchain that yields WxH frames with square pixels and stable DAR.
fit:
- fit : letterbox (keep AR) -> pad
- fill : cover, then center crop
- stretch : direct stretch
"""
if fit == "fit": # scale down/up to fit inside WxH, then pad
return (
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black,"
f"setsar=1,setdar={w}/{h},format={pixfmt}"
)
elif fit == "fill": # cover then crop center
return (
f"scale={w}:{h}:force_original_aspect_ratio=increase,"
f"crop={w}:{h},setsar=1,setdar={w}/{h},format={pixfmt}"
)
# ---------- Filters for filter engine ----------
def _norm_vfilter(w: int, h: int, fit: str, pixfmt: str, fps: Optional[float]) -> str:
chain = "settb=AVTB,setpts=PTS-STARTPTS,"
if fit == "fit":
chain += (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black,"
f"setsar=1,setdar={w}/{h},format={pixfmt}")
elif fit == "fill":
chain += (f"scale={w}:{h}:force_original_aspect_ratio=increase,"
f"crop={w}:{h},setsar=1,setdar={w}/{h},format={pixfmt}")
elif fit == "stretch":
return f"scale={w}:{h},setsar=1,setdar={w}/{h},format={pixfmt}"
chain += f"scale={w}:{h},setsar=1,setdar={w}/{h},format={pixfmt}"
else:
raise ValueError("fit must be one of: fit, fill, stretch")
def _normalize_one(src: Path, dst: Path, w: int, h: int, fit: str, fps: Optional[float],
ar: int, ac: int, pixfmt: str, vcodec: str, crf: str, preset: str,
faststart: bool):
vf = _norm_filters(w,h,fit,pixfmt)
base = ["ffmpeg","-y",
"-fflags","+genpts", # regen PTS per source
"-i", str(src)]
if fps:
base += ["-r", str(fps)]
cmd = base + [
"-vf", vf,
"-c:v", vcodec, "-crf", crf, "-preset", preset,
"-c:a", "aac", # normalize audio codec for concat-copy later
"-ar", str(ar),
"-ac", str(ac),
"-vsync","2",
"-avoid_negative_ts","make_zero",
]
chain += f",fps={fps}:round=near"
return chain
def _norm_afilter_existing(ar: int, ac: int) -> str:
layout = "stereo" if ac == 2 else "mono"
return (f"asetpts=PTS-STARTPTS,"
f"aresample={ar}:async=1:first_pts=0,"
f"aformat=sample_rates={ar}:channel_layouts={layout}")
def _norm_afilter_silence(ar: int, ac: int, dur: float) -> str:
layout = "stereo" if ac == 2 else "mono"
return (f"anullsrc=r={ar}:cl={layout},"
f"atrim=0:{dur:.6f},"
f"asetpts=PTS-STARTPTS,"
f"aformat=sample_rates={ar}:channel_layouts={layout}")
# ---------- File list writer for demuxer ----------
def _ff_concat_escape(p: Path) -> str:
s = str(p.resolve(strict=False))
return s.replace("'", r"'\''")
def _write_concat_list(paths: List[Path], list_path: Path):
list_path.parent.mkdir(parents=True, exist_ok=True)
with list_path.open("w", encoding="utf-8", newline="\n") as f:
for p in paths:
esc = _ff_concat_escape(p)
f.write(f"file '{esc}'\n")
# ---------- Pre-normalize (demuxer engine, optional) ----------
def _build_norm_cmd(src: Path, dst: Path, w: int, h: int, fit: str,
fps: Optional[float], ar: int, ac: int, pixfmt: str,
vcodec: str, crf: str, preset: str, faststart: bool,
decode_tolerant: bool, hard_trim: bool, dur: float) -> List[str]:
# Video normalize chain
if fit == "fit":
vf = (f"settb=AVTB,setpts=PTS-STARTPTS,"
f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color=black,"
f"setsar=1,setdar={w}/{h},format={pixfmt}")
elif fit == "fill":
vf = (f"settb=AVTB,setpts=PTS-STARTPTS,"
f"scale={w}:{h}:force_original_aspect_ratio=increase,"
f"crop={w}:{h},setsar=1,setdar={w}/{h},format={pixfmt}")
else:
vf = f"settb=AVTB,setpts=PTS-STARTPTS,scale={w}:{h},setsar=1,setdar={w}/{h},format={pixfmt}"
if fps:
vf += f",fps={fps}:round=near"
if hard_trim and dur > 0:
vf += f",trim=start=0:duration={dur:.6f},setpts=PTS-STARTPTS"
# input flags (FIX: combine -fflags values into one arg)
inp = ["-fflags","+genpts"]
if decode_tolerant:
inp = ["-err_detect","ignore_err","-fflags","+discardcorrupt+genpts"]
cmd = ["ffmpeg","-y"] + inp + ["-i", str(src),
"-vf", vf,
"-c:v", vcodec, "-crf", crf, "-preset", preset,
"-c:a", "aac", "-ar", str(ar), "-ac", str(ac),
"-avoid_negative_ts","make_zero"]
if faststart and dst.suffix.lower() in {".mp4",".m4v",".mov"}:
cmd += ["-movflags","+faststart"]
if hard_trim and dur > 0:
cmd += ["-af", f"atrim=start=0:duration={dur:.6f},asetpts=PTS-STARTPTS"]
cmd += [str(dst)]
_run(cmd)
return cmd
def _maybe_normalize(inputs: List[Path], tmpdir: Path, args) -> List[Path]:
"""
When size/fps/pixfmt/audio params are provided, pre-normalize each input to a temp mp4
with unified parameters. Returns the list to actually concat.
"""
need_norm = bool(args.size or args.fps or args.normalize)
if not need_norm:
def _maybe_prenormalize(inputs: List[Path], tmpdir: Path, args) -> List[Path]:
need = bool(args.size or args.fps or args.normalize or args.hard_trim)
if not need:
return inputs
w,h = _parse_size(args.size) if args.size else (_probe_wh(inputs[0]))
out = []
for i, src in enumerate(inputs):
dst = tmpdir / f"norm_{i:04d}.mp4"
dur = _probe_duration_seconds(src)
cmd = _build_norm_cmd(src, dst, w, h, args.fit, args.fps,
args.ar, args.ac, args.pixfmt,
args.norm_vcodec, args.norm_crf, args.norm_preset, args.faststart,
getattr(args,"decode_tolerant",False),
getattr(args,"hard_trim",False), dur if dur>0 else 0.0)
print("↪︎", " ".join(cmd))
if subprocess.run(cmd).returncode != 0:
raise RuntimeError("ffmpeg normalization failed")
out.append(dst)
return out
size = _parse_size(args.size) if args.size else None
w,h = (size if size else (None,None))
if size is None:
# If user requested normalize without explicit size, keep each native size but enforce
# pixfmt/fps/audio params. To keep concat-safe dimensions, use the first clips size.
# (More deterministic: probe via ffprobe video width/height; here we take from first via scale=iw:ih)
# Simpler: require size; otherwise default to the first files coded size via "fit" passthrough.
# Well just infer from first decoded frame using -1:-1 behavior is not possible for concat safety.
# So pick first files coded size via a tiny probe:
w,h = 1920,1080 # sane default
fit = args.fit
fps = args.fps
ar = args.ar
ac = args.ac
pixfmt = args.pixfmt
vcodec = args.norm_vcodec
crf = args.norm_crf
preset = args.norm_preset
faststart = args.faststart
out_list = []
for idx,src in enumerate(inputs):
dst = tmpdir / f"norm_{idx:04d}.mp4"
_normalize_one(src,dst,w,h,fit,fps,ar,ac,pixfmt,vcodec,crf,preset,faststart)
out_list.append(dst)
return out_list
# ---------- Argument registration ----------
# ---------- Args ----------
def register_arguments(parser: argparse.ArgumentParser):
parser.description = (
"Mince a folder of videos into a single output by merging them in an ordered sequence.\n"
"Ordering modes:\n"
" forward : filename order (numeric-aware), ascending\n"
" backward : filename order (numeric-aware), descending\n"
" lenfor : by duration ascending\n"
" lenback : by duration descending\n"
" randn : random order via Gaussian noise keys\n"
" randfib : pseudo-random order via Fibonacci modulo walk\n\n"
"Normalization (optional):\n"
" --normalize or provide --size WxH to pre-normalize inputs (dimensions/pixfmt/FPS/audio) before concat.\n"
" Strategies: --fit fit|fill|stretch (default: fit)."
"Mince: merge a folder of videos into one output in a chosen order.\n"
"Default engine is 'demuxer' (concat demuxer). Use --engine filter for in-graph concat."
)
# Ordering / behavior
parser.add_argument("--mode",
choices=["forward","backward","lenfor","lenback","randn","randfib"],
required=True, help="Sequence strategy.")
parser.add_argument("--seed", type=int, default=None,
help="Random seed for randn / randfib (stable ordering if provided).")
# Concat behavior
parser.add_argument("--reencode", action="store_true",
help="Force re-encode after concat (rarely needed when pre-normalizing).")
parser.add_argument("--vcodec", type=str, default="libx264",
help="Video codec when final re-encode (default: libx264).")
parser.add_argument("--acodec", type=str, default="aac",
help="Audio codec when final re-encode (default: aac).")
parser.add_argument("--crf", type=str, default="20",
help="CRF when final re-encoding with libx264 (default: 20).")
parser.add_argument("--preset", type=str, default="medium",
help="x264 preset when final re-encoding (default: medium).")
parser.add_argument("--fallback-reencode", action="store_true",
help="If stream-copy concat fails, automatically retry with re-encode.")
parser.add_argument("--faststart", action="store_true",
help="Add +faststart to MP4 outputs (moves moov atom to front).")
parser.add_argument("--engine", choices=["demuxer","filter"], default="demuxer",
help="Concat engine: 'demuxer' (fast) or 'filter' (single-pass encode). Default: demuxer.")
# Pre-normalization controls (per-input)
# Demuxer engine options
parser.add_argument("--normalize", action="store_true",
help="Enable pre-normalization even if --size is not given (uses defaults).")
help="(demuxer) Pre-normalize sources so -c copy concat is safe.")
parser.add_argument("--size", type=str, default=None,
help="Target dimensions WxH (e.g., 1920x1080) for pre-normalization.")
help="Target WxH (e.g., 1920x1080). If omitted and --normalize, infer from first clip.")
parser.add_argument("--fit", type=str, choices=["fit","fill","stretch"], default="fit",
help="How to map source AR into target size (default: fit/letterbox).")
help="Mapping from source AR to target size (default: fit).")
parser.add_argument("--fps", type=float, default=None,
help="Normalize FPS (e.g., 30). Omit to preserve each sources fps.")
help="Normalize video FPS (e.g., 30). Omit to keep native cadence.")
parser.add_argument("--pixfmt", type=str, default="yuv420p",
help="Pixel format for normalized clips (default: yuv420p).")
parser.add_argument("--ar", type=int, default=48000,
help="Audio sample rate for normalized clips (default: 48000).")
help="Audio sample rate (default: 48000).")
parser.add_argument("--ac", type=int, default=2,
help="Audio channels for normalized clips (default: 2).")
help="Audio channels (default: 2).")
parser.add_argument("--norm-vcodec", type=str, default="libx264",
help="Video codec for normalized clips (default: libx264).")
parser.add_argument("--norm-crf", type=str, default="20",
@@ -284,10 +280,29 @@ def register_arguments(parser: argparse.ArgumentParser):
parser.add_argument("--norm-preset", type=str, default="medium",
help="x264 preset for normalized clips (default: medium).")
# Final encode (filter engine) or demuxer fallback re-encode
parser.add_argument("--vcodec", type=str, default="libx264",
help="Final video codec (default: libx264).")
parser.add_argument("--acodec", type=str, default="aac",
help="Final audio codec (default: aac).")
parser.add_argument("--crf", type=str, default="20",
help="CRF for libx264 (default: 20).")
parser.add_argument("--preset", type=str, default="medium",
help="x264 preset (default: medium).")
parser.add_argument("--faststart", action="store_true",
help="Add +faststart for MP4/MOV containers.")
parser.add_argument("--fallback-reencode", action="store_true",
help="(demuxer) If copy-concat fails, retry with a re-encode.")
# New safeties
parser.add_argument("--decode-tolerant", action="store_true",
help="Ignore minor decode errors and drop corrupt packets on input.")
parser.add_argument("--hard-trim", action="store_true",
help="Trim each clips A/V to its probed duration before concat.")
# ---------- Runner ----------
def run(args):
# Validate global pieces from cli.py
if not args.input:
print("❌ You must pass a directory via -i/--input."); sys.exit(1)
indir = Path(args.input)
@@ -304,76 +319,162 @@ def run(args):
if not files:
print(f"❌ No video files found in {indir}"); sys.exit(1)
# Establish order
mode = args.mode
if mode == "forward": ordered = _sort_forward(files)
elif mode == "backward":ordered = _sort_backward(files)
elif mode == "lenfor": ordered = _sort_by_length(files, reverse=False)
elif mode == "lenback": ordered = _sort_by_length(files, reverse=True)
elif mode == "randn": ordered = _order_randn(files, seed=args.seed)
elif mode == "randfib": ordered = _order_randfib(files, seed=args.seed)
if args.mode == "forward": ordered = _sort_forward(files)
elif args.mode == "backward": ordered = _sort_backward(files)
elif args.mode == "lenfor": ordered = _sort_by_length(files, reverse=False)
elif args.mode == "lenback": ordered = _sort_by_length(files, reverse=True)
elif args.mode == "randn": ordered = _order_randn(files, seed=args.seed)
elif args.mode == "randfib": ordered = _order_randfib(files, seed=args.seed)
else:
print(f"❌ Unknown mode: {mode}"); sys.exit(1)
print(f"❌ Unknown mode: {args.mode}"); sys.exit(1)
print("🧩 Merge order:")
for i,p in enumerate(ordered,1):
print(f" {i:>3}. {p.name}")
# Temp workspace
tmpdir = Path(tempfile.mkdtemp(prefix="mince_"))
# Pre-normalize if requested/needed
norm_inputs = _maybe_normalize(ordered, tmpdir, args)
if args.engine == "demuxer":
tmpdir = Path(tempfile.mkdtemp(prefix="mince_"))
inputs = _maybe_prenormalize(ordered, tmpdir, args)
# Build concat list
list_path = tmpdir / "list.txt"
_write_concat_list(norm_inputs, list_path)
list_path = tmpdir / "list.txt"
_write_concat_list(inputs, list_path)
base = ["ffmpeg"]
if getattr(args,"force",False):
base += ["-y"]
base = ["ffmpeg"]
if getattr(args,"force",False):
base += ["-y"]
# --- Fast path: stream-copy concat demuxer with timestamp hygiene ---
if not args.reencode:
# Fast copy-concat
cmd = base + [
"-fflags","+genpts", # regen pts across joined stream
"-fflags","+genpts",
"-safe","0",
"-f","concat",
"-i", str(list_path),
"-c","copy",
"-avoid_negative_ts","make_zero",
str(out_path)
]
print("↪︎", " ".join(cmd))
r = subprocess.run(cmd)
if r.returncode == 0:
print("✅ Done.")
return
if not args.fallback_reencode:
raise RuntimeError(f"ffmpeg exited with code {r.returncode}")
# Fallback re-encode
vf = "format=yuv420p"
cmd = base + [
"-safe","0",
"-f","concat",
"-i", str(list_path),
"-vf", vf,
"-c:v", args.vcodec,
"-crf", args.crf,
"-preset", args.preset,
"-c:a", args.acodec,
"-avoid_negative_ts","make_zero",
str(out_path)
]
if args.faststart and out_path.suffix.lower() in {".mp4",".m4v",".mov"}:
cmd += ["-movflags","+faststart"]
cmd += [str(out_path)]
try:
_run(cmd)
print("✅ Done."); return
except Exception:
if not args.fallback_reencode:
raise
print("⚠️ Stream-copy concat failed or produced bad timestamps; retrying with re-encode because --fallback-reencode is set...")
print("↪︎", " ".join(cmd))
if subprocess.run(cmd).returncode != 0:
raise RuntimeError("ffmpeg re-encode failed")
print("✅ Done.")
return
# ---------- filter engine ----------
size = _parse_size(args.size)
if size is None:
w0,h0 = _probe_wh(ordered[0])
size = (w0,h0)
print(f" No --size provided; inferring from first clip: {w0}x{h0}")
W,H = size
cmd = ["ffmpeg"]
if getattr(args,"force",False):
cmd += ["-y"]
vfilters: List[str] = []
afilters: List[str] = []
has_any_audio = False
has_audio_list = []
durations = []
for idx, p in enumerate(ordered):
# Input flags per clip (FIX: combine -fflags values)
inp_flags = ["-fflags","+genpts"]
if getattr(args,"decode_tolerant",False):
inp_flags = ["-err_detect","ignore_err","-fflags","+discardcorrupt+genpts"]
cmd += inp_flags + ["-i", str(p)]
dur = _probe_duration_seconds(p)
durations.append(dur if dur > 0 else 0.0)
has_a = _has_audio(p)
has_audio_list.append(has_a)
if has_a:
has_any_audio = True
vnorm = _norm_vfilter(W,H,args.fit,args.pixfmt,args.fps)
if getattr(args,"hard_trim",False) and dur > 0:
vnorm += f",trim=start=0:duration={dur:.6f},setpts=PTS-STARTPTS"
vfilters.append(f"[{idx}:v] {vnorm} [v{idx}]")
if has_a:
anorm = _norm_afilter_existing(args.ar,args.ac)
if getattr(args,"hard_trim",False) and dur > 0:
anorm += f",atrim=start=0:duration={dur:.6f},asetpts=PTS-STARTPTS"
afilters.append(f"[{idx}:a] {anorm} [a{idx}]")
else:
# if any audio exists elsewhere, we'll synth silence later for this one
pass
parts: List[str] = []
if vfilters:
parts.append(";".join(vfilters))
v_stack = "".join([f"[v{idx}]" for idx in range(len(ordered))])
if has_any_audio:
for idx, has_a in enumerate(has_audio_list):
if not has_a:
dur = durations[idx] if durations[idx] > 0 else 0.001
anorm = _norm_afilter_silence(args.ar,args.ac,dur)
afilters.append(f"{anorm} [a{idx}]")
if afilters:
parts.append(";".join(afilters))
a_stack = "".join([f"[a{idx}]" for idx in range(len(ordered))])
concat = f"{v_stack}{a_stack}concat=n={len(ordered)}:v=1:a=1[vout][aout]"
parts.append(concat)
filter_complex = ";".join(parts)
cmd += ["-filter_complex", filter_complex,
"-map","[vout]","-map","[aout]",
"-c:v", args.vcodec,
"-crf", args.crf,
"-preset", args.preset,
"-c:a", args.acodec,
"-pix_fmt", args.pixfmt]
else:
concat = f"{v_stack}concat=n={len(ordered)}:v=1:a=0[vout]"
parts.append(concat)
filter_complex = ";".join(parts)
cmd += ["-filter_complex", filter_complex,
"-map","[vout]",
"-c:v", args.vcodec,
"-crf", args.crf,
"-preset", args.preset,
"-pix_fmt", args.pixfmt]
# --- Robust path: final re-encode (rare when pre-normalized, but available) ---
vf = "format=yuv420p"
cmd = base + [
"-safe","0",
"-f","concat",
"-i", str(list_path),
"-vf", vf,
"-c:v", args.vcodec,
"-crf", args.crf,
"-preset", args.preset,
"-c:a", args.acodec,
"-vsync","2",
"-avoid_negative_ts","make_zero",
]
if args.faststart and out_path.suffix.lower() in {".mp4",".m4v",".mov"}:
cmd += ["-movflags","+faststart"]
cmd += [str(out_path)]
if run_ffmpeg_with_progress:
run_ffmpeg_with_progress(cmd, str(norm_inputs[0]), str(out_path))
else:
_run(cmd)
print("↪︎"," ".join(map(str,cmd)))
if subprocess.run(cmd).returncode != 0:
raise RuntimeError("ffmpeg filter-engine failed")
print("✅ Done.")

View File

@@ -0,0 +1,270 @@
# videobeaux/programs/qwikchop.py
# Qwikchop (export-only, seamless heads)
# - Create EXACTLY N edits from each input video.
# - Optional: trim leading black/fade at each edit head, and lightly pad edges.
# - Export edits into a folder. No concat/merge.
import re
import shutil
import subprocess
from pathlib import Path
from typing import List, Tuple
from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress
# ---------------- helpers ----------------
def _ffprobe_duration_seconds(path: Path) -> float:
cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
]
out = subprocess.check_output(cmd, stderr=subprocess.STDOUT).decode("utf-8", "ignore").strip()
try:
return float(out)
except Exception:
return 0.0
def _mkdir(p: Path):
p.mkdir(parents=True, exist_ok=True)
def _videos_in_dir(d: Path, recurse: bool) -> List[Path]:
exts = {".mp4", ".mov", ".m4v"}
it = d.rglob("*") if recurse else d.iterdir()
return sorted([p for p in it if p.is_file() and p.suffix.lower() in exts])
def _work_and_export_paths(inp: Path, output_base: str | None) -> Tuple[Path, Path]:
"""
Return (work_dir, export_dir).
- If -o/--output is provided (cli may coerce .mp4), use its stem as base.
- Otherwise use folders next to the input.
"""
if output_base:
base = Path(output_base).with_suffix("") # strip .mp4 if cli added it
work_dir = base.parent / f"{base.stem}_{inp.stem}_qwik_tmp"
export_dir = base.parent / f"{inp.stem}_qwikchop"
else:
work_dir = inp.parent / f"{inp.stem}_qwik_tmp"
export_dir = inp.parent / f"{inp.stem}_qwikchop"
return work_dir, export_dir
# ---- black leader detection ----
_BLACK_LINE = re.compile(r"black_start:(?P<bs>[-\d\.]+)\s+black_end:(?P<be>[-\d\.]+)\s+black_duration:(?P<bd>[-\d\.]+)")
def _measure_leading_black(
src: Path,
start_ts: float,
scan_window: float,
thresh: float,
pic_th: float
) -> float:
"""
Probe the first 'scan_window' seconds of the would-be edit for black content
using ffmpeg blackdetect. Returns how many seconds to skip at the head.
"""
# We analyze a short snippet starting at start_ts
cmd = [
"ffmpeg",
"-ss", f"{start_ts:.6f}",
"-t", f"{max(0.01, scan_window):.6f}",
"-i", str(src),
"-vf", f"blackdetect=d=0.02:thresh={thresh}:pic_th={pic_th}",
"-an",
"-f", "null",
"-"
]
try:
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stderr = proc.stderr or ""
# Look for first black region starting at 0
trim = 0.0
for line in stderr.splitlines():
m = _BLACK_LINE.search(line)
if not m:
continue
bs = float(m.group("bs"))
be = float(m.group("be"))
# blackdetect reports times relative to snippet start; if it starts near 0, trim to its end
if -0.005 <= bs <= 0.005 and be > 0:
trim = max(trim, be)
break
return max(0.0, trim)
except Exception:
return 0.0
def _segment_into_n_edits(
inp: Path,
work_dir: Path,
pieces: int,
edge_pad_pre: float,
edge_pad_post: float,
trim_black_front: bool,
black_scan: float,
black_thresh: float,
black_pict: float,
min_edit: float,
) -> List[Path]:
"""
Split video into exactly N edits with optional leader trim and edge pads.
Returns list of temp segment paths in natural order.
"""
dur = _ffprobe_duration_seconds(inp)
if dur <= 0:
raise RuntimeError(f"Cannot read duration for {inp}")
pieces = max(1, int(pieces))
base_chunk = dur / pieces
segs: List[Path] = []
_mkdir(work_dir)
pad_digits = max(4, len(str(pieces)))
for i in range(pieces):
# nominal bounds
start = base_chunk * i
end = dur if i == pieces - 1 else base_chunk * (i + 1)
seg_dur = max(0.0, end - start)
# shave tiny edges to dodge boundary fades
start += edge_pad_pre
seg_dur = max(0.0, seg_dur - (edge_pad_pre + edge_pad_post))
# optional: detect and trim leading black within a small scan window
if trim_black_front and seg_dur > 0.0:
to_scan = min(black_scan, seg_dur)
trim = _measure_leading_black(
src=inp,
start_ts=start,
scan_window=to_scan,
thresh=black_thresh,
pic_th=black_pict,
)
if trim > 0:
start += trim
seg_dur = max(0.0, seg_dur - trim)
# enforce minimum viable duration
if seg_dur < min_edit:
# collapse extremely short edits by borrowing a bit forward if possible
# (keep simple: if last edit is too short, merge into previous by skipping)
# For export-only and simplicity, skip zero/near-zero segments.
if seg_dur <= 0.0:
continue
out_path = work_dir / f"seg_{i+1:0{pad_digits}d}.mp4"
cmd = [
"ffmpeg",
"-ss", f"{start:.6f}",
"-t", f"{seg_dur:.6f}",
"-i", str(inp),
"-map", "0:v:0",
"-map", "0:a?",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-movflags", "+faststart",
str(out_path),
]
# Use the source file for progress probing (real media path)
run_ffmpeg_with_progress(cmd, str(inp), str(out_path))
segs.append(out_path)
return segs
def _export_segments(inp: Path, segs: List[Path], export_dir: Path, force: bool):
"""
Copy temp segments into export_dir with final names.
"""
if export_dir.exists() and force:
shutil.rmtree(export_dir, ignore_errors=True)
_mkdir(export_dir)
pad = max(4, len(str(len(segs))))
for i, src in enumerate(segs, start=1):
dst = export_dir / f"{inp.stem}_edit_{i:0{pad}d}.mp4"
shutil.copy2(src, dst)
print(f"📦 Exported {len(segs)} edits → {export_dir}")
def _qwikchop_one(args, inp: Path):
work_dir, export_dir = _work_and_export_paths(inp, args.output)
if work_dir.exists() and args.force:
shutil.rmtree(work_dir, ignore_errors=True)
segs = _segment_into_n_edits(
inp=inp,
work_dir=work_dir,
pieces=args.pieces,
edge_pad_pre=max(0.0, args.edge_pad_pre),
edge_pad_post=max(0.0, args.edge_pad_post),
trim_black_front=bool(args.trim_black_front),
black_scan=max(0.01, args.black_scan),
black_thresh=max(0.0, min(1.0, args.black_thresh)),
black_pict=max(0.0, min(1.0, args.black_pict)),
min_edit=max(0.01, args.min_edit),
)
if not segs:
print(f"⚠️ No edits produced for {inp}")
return
_export_segments(inp, segs, export_dir, force=args.force)
if not args.keep_temp:
shutil.rmtree(work_dir, ignore_errors=True)
# ---------------- program API ----------------
def register_arguments(parser):
parser.description = (
"Qwikchop (export-only): create EXACTLY N edits from each input video and "
"place them in a folder. Optional leader trim for seamless heads. No merging."
)
parser.add_argument("--pieces", type=int, required=True, help="Number of edits to create (exact count).")
parser.add_argument("--recurse", action="store_true", help="If input is a directory, include subdirectories.")
parser.add_argument("--keep-temp", action="store_true", help="Keep temporary chunk files (debugging).")
# Seamless options
parser.add_argument("--trim-black-front", action="store_true",
help="Detect and remove leading black at the start of each edit.")
parser.add_argument("--black-scan", type=float, default=0.25,
help="Seconds to scan at each edit head for black (default: 0.25).")
parser.add_argument("--black-thresh", type=float, default=0.10,
help="Black luma threshold for blackdetect (0..1, default: 0.10).")
parser.add_argument("--black-pict", type=float, default=0.10,
help="Min fraction of black pixels to count as black (0..1, default: 0.10).")
parser.add_argument("--edge-pad-pre", type=float, default=0.03,
help="Seconds to shave from the very start of each edit (default: 0.03).")
parser.add_argument("--edge-pad-post", type=float, default=0.02,
help="Seconds to shave from the end of each edit (default: 0.02).")
parser.add_argument("--min-edit", type=float, default=0.20,
help="Minimum allowed edit duration after trims (default: 0.20).")
def run(args):
inp = Path(args.input) if getattr(args, "input", None) else None
if not inp or not inp.exists():
print("❌ You must pass a valid --input file or directory.")
return
if inp.is_file():
_qwikchop_one(args, inp)
else:
vids = _videos_in_dir(inp, recurse=args.recurse)
if not vids:
print(f"⚠️ No videos found in {inp} (use --recurse to include subdirs).")
return
for v in vids:
_qwikchop_one(args, v)