mirror of
https://github.com/MichaByte/highlight-video-maker.git
synced 2026-01-29 05:32:12 -05:00
feat: refactor video processing to analyze amplitude directly from streams, extract segments on demand, and add dedicated horizontal/vertical rendering functions.
This commit is contained in:
parent
207f69fa4b
commit
f7a14942ac
3 changed files with 161 additions and 127 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -173,4 +173,9 @@ cython_debug/
|
|||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
.pypirc
|
||||
|
||||
vid-ins/
|
||||
video-maker-cache/
|
||||
*.mp4
|
||||
*.webm
|
||||
|
|
@ -31,11 +31,17 @@ def get_logger(level: int = logging.INFO):
|
|||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(level)
|
||||
|
||||
# create console handler with a higher log level
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(level)
|
||||
console_handler.setFormatter(CustomFormatter())
|
||||
if not logger.hasHandlers():
|
||||
# create console handler with a higher log level
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(level)
|
||||
console_handler.setFormatter(CustomFormatter())
|
||||
|
||||
logger.addHandler(console_handler)
|
||||
logger.addHandler(console_handler)
|
||||
else:
|
||||
# If handlers exist, just update the level if needed
|
||||
logger.setLevel(level)
|
||||
for handler in logger.handlers:
|
||||
handler.setLevel(level)
|
||||
|
||||
return logger
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import subprocess
|
|||
from collections import Counter
|
||||
from logging import Logger, getLevelNamesMapping
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Generator, List
|
||||
from typing import Any, Dict, Generator, List, Optional
|
||||
|
||||
import click
|
||||
|
||||
from .logger import get_logger
|
||||
|
||||
logger: Logger
|
||||
logger: Logger = get_logger()
|
||||
|
||||
XFADE_TRANSITIONS = [
|
||||
"fade",
|
||||
|
|
@ -52,7 +52,7 @@ def cli(log_level: str):
|
|||
|
||||
IN_DIR: Path
|
||||
OUT_DIR: Path
|
||||
CACHE_DIR = Path("/tmp/video-maker-cache")
|
||||
CACHE_DIR = Path("./video-maker-cache")
|
||||
THREADS = 12
|
||||
|
||||
MIN_SEGMENT_LENGTH = 5
|
||||
|
|
@ -68,7 +68,7 @@ def run_in_bash(
|
|||
shell=False,
|
||||
):
|
||||
return subprocess.run(
|
||||
f"/usr/bin/bash -c '{cmd}'",
|
||||
f"""bash -c '{cmd}'""",
|
||||
capture_output=capture_output,
|
||||
check=check,
|
||||
text=text,
|
||||
|
|
@ -140,34 +140,21 @@ def generate_segment_lengths(file_length: float) -> List[float]:
|
|||
return segment_lengths
|
||||
|
||||
|
||||
def split_video_segment(
|
||||
segment_lengths: List[float],
|
||||
file_name: Path,
|
||||
idx: int,
|
||||
out_dir: Path = Path(CACHE_DIR),
|
||||
):
|
||||
"""Splits a video into segments using ffmpeg."""
|
||||
logger.debug(f"Splitting {file_name} - segment {idx}")
|
||||
run_in_bash(
|
||||
f"ffmpeg -nostats -loglevel 0 -y -ss {seconds_to_timestamp(sum(segment_lengths[:idx]))} "
|
||||
f'-to {seconds_to_timestamp(sum(segment_lengths[:idx]) + segment_lengths[idx])} -i "{file_name}" '
|
||||
f'-c copy "{Path(out_dir, file_name.stem, str(idx) + file_name.suffix)}"',
|
||||
check=True,
|
||||
shell=True,
|
||||
)
|
||||
|
||||
|
||||
def get_amplitude_of_segment(clip: Path):
|
||||
"""Extracts the mean audio amplitude of a video segment."""
|
||||
logger.debug(f"Analyzing amplitude for clip: {clip}")
|
||||
def get_amplitude_from_stream(file: Path, start: float, duration: float):
|
||||
"""Extracts the mean audio amplitude of a video segment directly from stream."""
|
||||
logger.debug(f"Analyzing amplitude for {file} at {start} for {duration}s")
|
||||
res = run_in_bash(
|
||||
f'ffmpeg -i "{Path(CACHE_DIR, clip)}" -filter:a volumedetect -f null -',
|
||||
f'ffmpeg -ss {start} -t {duration} -i "{file}" -filter:a volumedetect -f null -',
|
||||
shell=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
).stderr
|
||||
logger.debug(res)
|
||||
return float(res.decode().split("mean_volume: ")[1].split(" dB")[0])
|
||||
try:
|
||||
return float(res.decode().split("mean_volume: ")[1].split(" dB")[0])
|
||||
except IndexError:
|
||||
logger.warning(f"Could not determine volume for {file} segment. Defaulting to -91dB.")
|
||||
return -91.0
|
||||
|
||||
|
||||
def build_input_flags(video_files: List[str]) -> str:
|
||||
|
|
@ -242,12 +229,39 @@ def run_ffmpeg_command(
|
|||
-c:v libx264 -preset slow \
|
||||
-c:a aac -b:a 128k "{output_file}"
|
||||
"""
|
||||
# the .split()[-1].split() lunacy gets the index of the final VXF
|
||||
# filter so that FFmpeg knows where to map the video output.
|
||||
# TODO: remove that mess and put the same logic in
|
||||
# build_transition_filters_dynamic
|
||||
run_in_bash(cmd, shell=True, check=True, capture_output=True)
|
||||
|
||||
def render_horizontal(decode_options: str, watermark_image: Path, output_file: Path):
|
||||
logger.info("Creating horizontal video...")
|
||||
run_in_bash(
|
||||
f'''ffmpeg -y {decode_options} -i "{CACHE_DIR / "out-unmarked.mp4"}" -i "{watermark_image}" \
|
||||
-filter_complex " \
|
||||
[1]format=rgba,colorchannelmixer=aa=0.5[logo]; \
|
||||
[0][logo]overlay=W-w-30:H-h-30:format=auto,format=yuv420p \
|
||||
" -c:a aac -b:a 128k "{output_file}"''',
|
||||
shell=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
logger.info(f"Horizontal video created at {output_file}")
|
||||
|
||||
def render_vertical(decode_options: str, watermark_image: Path, output_file: Path):
|
||||
logger.info("Creating vertical video...")
|
||||
run_in_bash(
|
||||
f'''ffmpeg -y {decode_options} -i "{CACHE_DIR / "out-unmarked.mp4"}" -i "{watermark_image}" \
|
||||
-filter_complex " \
|
||||
[0]crop=3/4*in_w:in_h[zoomed]; \
|
||||
[zoomed]split[original][copy]; \
|
||||
[copy]scale=-1:ih*(4/3)*(4/3),crop=w=ih*9/16,gblur=sigma=17:steps=5[blurred]; \
|
||||
[blurred][original]overlay=(main_w-overlay_w)/2:(main_h-overlay_h)/2[vert]; \
|
||||
[vert][1]overlay=(W-w)/2:H-h-30,format=yuv420p \
|
||||
" -c:a aac -b:a 128k "{output_file}"''',
|
||||
shell=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
logger.info(f"Vertical video created at {output_file}")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
|
|
@ -270,6 +284,7 @@ def run_ffmpeg_command(
|
|||
"It should not exist and must either be an absolute path "
|
||||
'or start with "./".',
|
||||
type=click.Path(exists=False, resolve_path=True, path_type=Path),
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--vert-output-file",
|
||||
|
|
@ -277,6 +292,7 @@ def run_ffmpeg_command(
|
|||
"It should not exist and must either be an absolute path "
|
||||
'or start with "./".',
|
||||
type=click.Path(exists=False, resolve_path=True, path_type=Path),
|
||||
required=False,
|
||||
)
|
||||
@click.option(
|
||||
"--decode-options",
|
||||
|
|
@ -297,15 +313,22 @@ def run_ffmpeg_command(
|
|||
def run(
|
||||
input_dir: Path,
|
||||
watermark_image: Path,
|
||||
horiz_output_file: Path,
|
||||
vert_output_file: Path,
|
||||
horiz_output_file: Optional[Path],
|
||||
vert_output_file: Optional[Path],
|
||||
decode_options: str,
|
||||
num_segs: int,
|
||||
):
|
||||
"""Main function that orchestrates the video processing pipeline."""
|
||||
logger.info("Starting video processing pipeline.")
|
||||
|
||||
if not horiz_output_file and not vert_output_file:
|
||||
logger.error("No output file specified! Provide --horiz-output-file or --vert-output-file (or both).")
|
||||
# We could also use click constraints but checking here is fine for a quick refactor.
|
||||
return
|
||||
|
||||
raw_videos = next(input_dir.walk())
|
||||
|
||||
# Get the representative video (shortest video)
|
||||
representative_video = min(
|
||||
(Path(raw_videos[0], p) for p in raw_videos[2]), key=get_video_duration
|
||||
)
|
||||
|
|
@ -316,80 +339,93 @@ def run(
|
|||
get_video_duration(representative_video)
|
||||
)
|
||||
|
||||
for vid in raw_videos[2]:
|
||||
Path(CACHE_DIR, Path(vid).stem).resolve().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Splitting videos into segments using multiprocessing
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=THREADS) as split_executor:
|
||||
try:
|
||||
Path(CACHE_DIR, representative_video.stem).mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
except FileExistsError:
|
||||
pass
|
||||
# Pre-create cache dir
|
||||
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Analyze amplitudes of the representative video segments directly (without splitting)
|
||||
logger.info("Analyzing audio levels...")
|
||||
segment_amplitudes: Dict[int, float] = {}
|
||||
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=THREADS) as executor:
|
||||
futures = {}
|
||||
for idx in range(len(representative_video_segments)):
|
||||
for vid in raw_videos[2]:
|
||||
split_executor.submit(
|
||||
split_video_segment,
|
||||
representative_video_segments,
|
||||
Path(raw_videos[0], vid).resolve(),
|
||||
idx,
|
||||
)
|
||||
|
||||
# Computing amplitude for each segment
|
||||
representative_video_audio_futures: Dict[str, concurrent.futures.Future[float]] = {}
|
||||
|
||||
with concurrent.futures.ProcessPoolExecutor(
|
||||
max_workers=THREADS
|
||||
) as amplitude_executor:
|
||||
for split_vid in next(Path(CACHE_DIR, Path(representative_video).stem).walk())[
|
||||
2
|
||||
]:
|
||||
representative_video_audio_futures[split_vid] = amplitude_executor.submit(
|
||||
get_amplitude_of_segment,
|
||||
Path(CACHE_DIR, Path(representative_video).stem, split_vid).resolve(),
|
||||
start_time = sum(representative_video_segments[:idx])
|
||||
duration = representative_video_segments[idx]
|
||||
futures[idx] = executor.submit(
|
||||
get_amplitude_from_stream,
|
||||
representative_video,
|
||||
start_time,
|
||||
duration
|
||||
)
|
||||
|
||||
for idx, future in futures.items():
|
||||
segment_amplitudes[idx] = future.result()
|
||||
|
||||
representative_video_audio_levels: Dict[str, float] = {}
|
||||
# Collecting results
|
||||
for seg in representative_video_audio_futures.keys():
|
||||
representative_video_audio_levels[seg] = representative_video_audio_futures[
|
||||
seg
|
||||
].result()
|
||||
highest = dict(Counter(representative_video_audio_levels).most_common(num_segs))
|
||||
loudest_seg_indexes: List[int] = [int(str(Path(k).stem)) for k in highest.keys()]
|
||||
for video in raw_videos[2]:
|
||||
out_folder = Path(CACHE_DIR, "loudest", Path(video).stem)
|
||||
out_folder.mkdir(parents=True, exist_ok=True)
|
||||
for seg in loudest_seg_indexes:
|
||||
split_video_segment(
|
||||
representative_video_segments,
|
||||
Path(raw_videos[0], video),
|
||||
seg,
|
||||
out_folder.parent,
|
||||
)
|
||||
# Identify loudest segments
|
||||
highest = dict(Counter(segment_amplitudes).most_common(num_segs))
|
||||
loudest_seg_indexes: List[int] = sorted(list(highest.keys()))
|
||||
|
||||
logger.info("Extracting selected segments...")
|
||||
video_files: List[str] = []
|
||||
with open(str(Path(CACHE_DIR, "list.txt")), "w") as f:
|
||||
for seg in loudest_seg_indexes:
|
||||
random_seg = Path(random.choice(raw_videos[2]))
|
||||
vid_path = Path(
|
||||
CACHE_DIR, "loudest", random_seg.stem, str(seg) + random_seg.suffix
|
||||
|
||||
# We need a directory for the chosen segments
|
||||
out_loudest_dir = Path(CACHE_DIR, "loudest")
|
||||
out_loudest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(str(Path(CACHE_DIR, "list.txt")), "w") as f, \
|
||||
concurrent.futures.ProcessPoolExecutor(max_workers=THREADS) as split_executor:
|
||||
|
||||
future_to_vid_path = {}
|
||||
|
||||
for seg_idx in loudest_seg_indexes:
|
||||
# Pick a random video for this segment index
|
||||
random_vid_name = random.choice(raw_videos[2])
|
||||
random_vid_path = Path(raw_videos[0], random_vid_name)
|
||||
|
||||
# Define output path for this specific clip
|
||||
final_clip_path = out_loudest_dir / f"seg_{seg_idx}_{random_vid_path.stem}{random_vid_path.suffix}"
|
||||
|
||||
start_time = sum(representative_video_segments[:seg_idx])
|
||||
duration = representative_video_segments[seg_idx]
|
||||
|
||||
future = split_executor.submit(
|
||||
run_in_bash,
|
||||
f"ffmpeg -nostats -loglevel 0 -y -ss {seconds_to_timestamp(start_time)} "
|
||||
f'-t {duration} -i "{random_vid_path}" '
|
||||
f'-c copy "{final_clip_path}"',
|
||||
check=True,
|
||||
shell=True
|
||||
)
|
||||
f.write(f"file '{vid_path}'\n")
|
||||
video_files.append(str(vid_path.resolve()))
|
||||
future_to_vid_path[future] = final_clip_path
|
||||
|
||||
f.write(f"file '{final_clip_path}'\n")
|
||||
video_files.append(str(final_clip_path.resolve()))
|
||||
|
||||
# Wait for all splits to finish
|
||||
for fut in concurrent.futures.as_completed(future_to_vid_path):
|
||||
try:
|
||||
fut.result()
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to extract segment: {e}")
|
||||
|
||||
filter_gen = nonrepeating_generator(XFADE_TRANSITIONS, num_segs)
|
||||
|
||||
input_flags: str = f"{decode_options} {build_input_flags(video_files)}"
|
||||
pre_filters, vlabels, alabels = build_preprocess_filters(video_files)
|
||||
try:
|
||||
pre_filters, vlabels, alabels = build_preprocess_filters(video_files)
|
||||
except IndexError:
|
||||
logger.error("No video files generated? Aborting.")
|
||||
return
|
||||
|
||||
durations = [get_video_duration(Path(vf)) for vf in video_files]
|
||||
|
||||
vfades, afades, final_v, final_a = build_transition_filters_dynamic(
|
||||
filter_gen, vlabels, alabels, durations, 0.5
|
||||
)
|
||||
|
||||
full_filter: str = assemble_filter_complex(pre_filters, vfades, afades)
|
||||
|
||||
logger.info("Creating unmarked video...")
|
||||
logger.info("Creating unmarked video (master)...")
|
||||
|
||||
run_ffmpeg_command(
|
||||
output_file=CACHE_DIR
|
||||
|
|
@ -399,37 +435,24 @@ def run(
|
|||
final_audio_label=final_a,
|
||||
)
|
||||
|
||||
logger.info("Creating horizontal video...")
|
||||
|
||||
# Horizontal Pipeline: Take unmarked file and add a semi‑transparent watermark.
|
||||
run_in_bash(
|
||||
f'''ffmpeg -y {decode_options} -i "{CACHE_DIR / "out-unmarked.mp4"}" -i "{watermark_image}" \
|
||||
-filter_complex " \
|
||||
[1]format=rgba,colorchannelmixer=aa=0.5[logo]; \
|
||||
[0][logo]overlay=W-w-30:H-h-30:format=auto,format=yuv420p \
|
||||
" -c:a aac -b:a 128k "{horiz_output_file}"''',
|
||||
shell=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
logger.info("Creating vertical video...")
|
||||
|
||||
# Vertical Pipeline: Crop (zoom), split & blur unmarked file for a vertical aspect ratio,
|
||||
# then overlay a centered, opaque watermark at the bottom.
|
||||
run_in_bash(
|
||||
f'''ffmpeg -y {decode_options} -i "{CACHE_DIR / "out-unmarked.mp4"}" -i "{watermark_image}" \
|
||||
-filter_complex " \
|
||||
[0]crop=3/4*in_w:in_h[zoomed]; \
|
||||
[zoomed]split[original][copy]; \
|
||||
[copy]scale=-1:ih*(4/3)*(4/3),crop=w=ih*9/16,gblur=sigma=17:steps=5[blurred]; \
|
||||
[blurred][original]overlay=(main_w-overlay_w)/2:(main_h-overlay_h)/2[vert]; \
|
||||
[vert][1]overlay=(W-w)/2:H-h-30,format=yuv420p \
|
||||
" -c:a aac -b:a 128k "{vert_output_file}"''',
|
||||
shell=True,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Parallelize Final Output
|
||||
render_futures = []
|
||||
with concurrent.futures.ProcessPoolExecutor(max_workers=2) as render_executor:
|
||||
if horiz_output_file:
|
||||
render_futures.append(
|
||||
render_executor.submit(render_horizontal, decode_options, watermark_image, horiz_output_file)
|
||||
)
|
||||
|
||||
if vert_output_file:
|
||||
render_futures.append(
|
||||
render_executor.submit(render_vertical, decode_options, watermark_image, vert_output_file)
|
||||
)
|
||||
|
||||
for f in concurrent.futures.as_completed(render_futures):
|
||||
try:
|
||||
f.result()
|
||||
except Exception as e:
|
||||
logger.exception(f"Rendering failed: {e}")
|
||||
|
||||
logger.info("Video processing pipeline completed.")
|
||||
logger.info("Cleaning up temporary files...")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue