mirror of
https://github.com/vondas-network/videobeaux.git
synced 2025-12-05 15:30:02 +01:00
Compare commits
3 Commits
613f252ae5
...
bc25c3580a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc25c3580a | ||
|
|
d1ad41c984 | ||
|
|
5e7678bf7a |
84
README.md
84
README.md
@@ -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.
15
experimental/MINCE-prompt.md
Normal file
15
experimental/MINCE-prompt.md
Normal 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.
|
||||
-
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
342
videobeaux/programs/convert_mux.py
Normal file
342
videobeaux/programs/convert_mux.py
Normal 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
|
||||
)
|
||||
@@ -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 clip’s 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 clip’s 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 file’s coded size via "fit" passthrough.
|
||||
# We’ll just infer from first decoded frame using -1:-1 behavior is not possible for concat safety.
|
||||
# So pick first file’s 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 source’s 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 clip’s 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.")
|
||||
|
||||
270
videobeaux/programs/qwikchop.py
Normal file
270
videobeaux/programs/qwikchop.py
Normal 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)
|
||||
Reference in New Issue
Block a user