diff --git a/.gitignore b/.gitignore index f973e31..e6385d8 100644 --- a/.gitignore +++ b/.gitignore @@ -173,9 +173,4 @@ cython_debug/ .ruff_cache/ # PyPI configuration file -.pypirc - -vid-ins/ -video-maker-cache/ -*.mp4 -*.webm \ No newline at end of file +.pypirc \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6cf0696..b6254cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "highlight_video_maker" -version = "0.2.3" +version = "0.1.0" authors = [{ name = "Micha Albert", email = "micha@2231.tech" }] description = "A utility to take several video inputs, take the loudest points, and create a compilation of them with smooth transitions" readme = "README.md" diff --git a/src/highlight_video_maker/logger.py b/src/highlight_video_maker/logger.py index cb67f8d..fd37775 100644 --- a/src/highlight_video_maker/logger.py +++ b/src/highlight_video_maker/logger.py @@ -31,17 +31,11 @@ def get_logger(level: int = logging.INFO): logger = logging.getLogger(__name__) logger.setLevel(level) - if not logger.hasHandlers(): - # create console handler with a higher log level - console_handler = logging.StreamHandler() - console_handler.setLevel(level) - console_handler.setFormatter(CustomFormatter()) + # create console handler with a higher log level + console_handler = logging.StreamHandler() + console_handler.setLevel(level) + console_handler.setFormatter(CustomFormatter()) - 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) + logger.addHandler(console_handler) return logger diff --git a/src/highlight_video_maker/main.py b/src/highlight_video_maker/main.py index dc223e3..4430448 100644 --- a/src/highlight_video_maker/main.py +++ b/src/highlight_video_maker/main.py @@ -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, Optional +from typing import Any, Dict, Generator, List import click from .logger import get_logger -logger: Logger = get_logger() +logger: Logger XFADE_TRANSITIONS = [ "fade", @@ -52,7 +52,7 @@ def cli(log_level: str): IN_DIR: Path OUT_DIR: Path -CACHE_DIR = Path("./video-maker-cache") +CACHE_DIR = Path("/tmp/video-maker-cache") THREADS = 12 MIN_SEGMENT_LENGTH = 5 @@ -68,7 +68,7 @@ def run_in_bash( shell=False, ): return subprocess.run( - f"""bash -c '{cmd}'""", + f"/usr/bin/bash -c '{cmd}'", capture_output=capture_output, check=check, text=text, @@ -140,21 +140,34 @@ def generate_segment_lengths(file_length: float) -> List[float]: return segment_lengths -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") +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}") res = run_in_bash( - f'ffmpeg -ss {start} -t {duration} -i "{file}" -filter:a volumedetect -f null -', + f'ffmpeg -i "{Path(CACHE_DIR, clip)}" -filter:a volumedetect -f null -', shell=True, check=True, capture_output=True, ).stderr logger.debug(res) - 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 + return float(res.decode().split("mean_volume: ")[1].split(" dB")[0]) def build_input_flags(video_files: List[str]) -> str: @@ -229,39 +242,12 @@ 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( @@ -284,7 +270,6 @@ def render_vertical(decode_options: str, watermark_image: Path, output_file: Pat "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", @@ -292,7 +277,6 @@ def render_vertical(decode_options: str, watermark_image: Path, output_file: Pat "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", @@ -313,22 +297,15 @@ def render_vertical(decode_options: str, watermark_image: Path, output_file: Pat def run( input_dir: Path, watermark_image: Path, - horiz_output_file: Optional[Path], - vert_output_file: Optional[Path], + horiz_output_file: Path, + vert_output_file: 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 ) @@ -339,93 +316,80 @@ def run( get_video_duration(representative_video) ) - # 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 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 for idx in range(len(representative_video_segments)): - 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() + for vid in raw_videos[2]: + split_executor.submit( + split_video_segment, + representative_video_segments, + Path(raw_videos[0], vid).resolve(), + idx, + ) - # 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...") + # 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(), + ) + + 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, + ) video_files: List[str] = [] - - # 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 + 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 ) - 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}") + f.write(f"file '{vid_path}'\n") + video_files.append(str(vid_path.resolve())) filter_gen = nonrepeating_generator(XFADE_TRANSITIONS, num_segs) input_flags: str = f"{decode_options} {build_input_flags(video_files)}" - try: - pre_filters, vlabels, alabels = build_preprocess_filters(video_files) - except IndexError: - logger.error("No video files generated? Aborting.") - return - + pre_filters, vlabels, alabels = build_preprocess_filters(video_files) 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 (master)...") + logger.info("Creating unmarked video...") run_ffmpeg_command( output_file=CACHE_DIR @@ -435,24 +399,37 @@ def run( final_audio_label=final_a, ) - # 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("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, + ) logger.info("Video processing pipeline completed.") logger.info("Cleaning up temporary files...")