diff --git a/.gitignore b/.gitignore index 012bc38..c78e7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ test_opencl_*.py test_xfade_opencl.py create_opencl_*.py OPENCL_SETUP_GUIDE.md +test-slideshow-local/ # Python cache __pycache__/ diff --git a/debug_frame_001.png b/debug_frame_001.png new file mode 100644 index 0000000..8393404 Binary files /dev/null and b/debug_frame_001.png differ diff --git a/debug_frame_002.png b/debug_frame_002.png new file mode 100644 index 0000000..4d92ede Binary files /dev/null and b/debug_frame_002.png differ diff --git a/debug_frame_003.png b/debug_frame_003.png new file mode 100644 index 0000000..4d92ede Binary files /dev/null and b/debug_frame_003.png differ diff --git a/frames/frame_0.8s.png b/frames/frame_0.8s.png new file mode 100644 index 0000000..6dd675f Binary files /dev/null and b/frames/frame_0.8s.png differ diff --git a/frames/frame_0001.png b/frames/frame_0001.png new file mode 100644 index 0000000..6dd675f Binary files /dev/null and b/frames/frame_0001.png differ diff --git a/frames/frame_0002.png b/frames/frame_0002.png new file mode 100644 index 0000000..b01549a Binary files /dev/null and b/frames/frame_0002.png differ diff --git a/frames/frame_0003.png b/frames/frame_0003.png new file mode 100644 index 0000000..7c1d3df Binary files /dev/null and b/frames/frame_0003.png differ diff --git a/frames/frame_0004.png b/frames/frame_0004.png new file mode 100644 index 0000000..0d9009d Binary files /dev/null and b/frames/frame_0004.png differ diff --git a/frames/frame_1.0s.png b/frames/frame_1.0s.png new file mode 100644 index 0000000..6dd675f Binary files /dev/null and b/frames/frame_1.0s.png differ diff --git a/frames/frame_1.2s.png b/frames/frame_1.2s.png new file mode 100644 index 0000000..6dd675f Binary files /dev/null and b/frames/frame_1.2s.png differ diff --git a/frames/frame_1.8s.png b/frames/frame_1.8s.png new file mode 100644 index 0000000..d441e0f Binary files /dev/null and b/frames/frame_1.8s.png differ diff --git a/frames/frame_2.0s.png b/frames/frame_2.0s.png new file mode 100644 index 0000000..b01549a Binary files /dev/null and b/frames/frame_2.0s.png differ diff --git a/frames/frame_2.2s.png b/frames/frame_2.2s.png new file mode 100644 index 0000000..7c1d3df Binary files /dev/null and b/frames/frame_2.2s.png differ diff --git a/frames/frame_2.8s.png b/frames/frame_2.8s.png new file mode 100644 index 0000000..7c1d3df Binary files /dev/null and b/frames/frame_2.8s.png differ diff --git a/frames/frame_3.0s.png b/frames/frame_3.0s.png new file mode 100644 index 0000000..7c1d3df Binary files /dev/null and b/frames/frame_3.0s.png differ diff --git a/frames/frame_3.2s.png b/frames/frame_3.2s.png new file mode 100644 index 0000000..6e3ca00 Binary files /dev/null and b/frames/frame_3.2s.png differ diff --git a/real_content_demo/064-laser-thum_hYpSdXS.mp3 b/real_content_demo/064-laser-thum_hYpSdXS.mp3 new file mode 100755 index 0000000..5fb4503 Binary files /dev/null and b/real_content_demo/064-laser-thum_hYpSdXS.mp3 differ diff --git a/real_content_demo/amex_1.png b/real_content_demo/amex_1.png new file mode 100755 index 0000000..7405554 Binary files /dev/null and b/real_content_demo/amex_1.png differ diff --git a/real_content_demo/amex_2.png b/real_content_demo/amex_2.png new file mode 100755 index 0000000..84427c0 Binary files /dev/null and b/real_content_demo/amex_2.png differ diff --git a/real_content_demo/amex_3.png b/real_content_demo/amex_3.png new file mode 100755 index 0000000..85408a9 Binary files /dev/null and b/real_content_demo/amex_3.png differ diff --git a/real_content_demo/amex_4.png b/real_content_demo/amex_4.png new file mode 100755 index 0000000..9fc1592 Binary files /dev/null and b/real_content_demo/amex_4.png differ diff --git a/real_content_demo/amex_5.png b/real_content_demo/amex_5.png new file mode 100755 index 0000000..4e4695a Binary files /dev/null and b/real_content_demo/amex_5.png differ diff --git a/real_content_demo/amex_6.png b/real_content_demo/amex_6.png new file mode 100755 index 0000000..f504f50 Binary files /dev/null and b/real_content_demo/amex_6.png differ diff --git a/real_content_demo/amex_7.png b/real_content_demo/amex_7.png new file mode 100755 index 0000000..845544e Binary files /dev/null and b/real_content_demo/amex_7.png differ diff --git a/real_content_demo/continuum of work location.png b/real_content_demo/continuum of work location.png new file mode 100755 index 0000000..2c0c803 Binary files /dev/null and b/real_content_demo/continuum of work location.png differ diff --git a/real_content_demo/continuum of work location_matrix.png b/real_content_demo/continuum of work location_matrix.png new file mode 100755 index 0000000..22c73ee Binary files /dev/null and b/real_content_demo/continuum of work location_matrix.png differ diff --git a/real_content_demo/gc_banner.png b/real_content_demo/gc_banner.png new file mode 100755 index 0000000..3040551 Binary files /dev/null and b/real_content_demo/gc_banner.png differ diff --git a/src/slideshow_maker/utils.py b/src/slideshow_maker/utils.py index 8d1c5d5..b93562d 100644 --- a/src/slideshow_maker/utils.py +++ b/src/slideshow_maker/utils.py @@ -261,16 +261,30 @@ def detect_nvenc_support(): if os.environ.get("PYTEST_CURRENT_TEST"): return False ffmpeg_path = get_ffmpeg_path() - + try: # Check if h264_nvenc encoder is available (hard timeout) cmd = f'"{ffmpeg_path}" -hide_banner -encoders 2>&1' result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=1) - if result.returncode == 0 and 'h264_nvenc' in result.stdout: + if result.returncode != 0 or 'h264_nvenc' not in result.stdout: + return False + + # Additional runtime test: Try to actually encode a tiny clip + # This catches cases where encoder is listed but CUDA libs are missing + test_cmd = f'"{ffmpeg_path}" -y -f lavfi -i "color=black:size=32x32:duration=0.1" -c:v h264_nvenc -f null - 2>&1' + test_result = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=5) + + # Check both stdout and stderr for CUDA errors + combined_output = test_result.stdout + test_result.stderr + cuda_error = "Cannot load libcuda.so.1" in combined_output + + # If the test succeeds (no CUDA errors), NVENC is truly available + if test_result.returncode == 0 and not cuda_error: return True + except Exception: pass - + return False diff --git a/src/slideshow_maker/video_chunked.py b/src/slideshow_maker/video_chunked.py index b08842c..a21e0a9 100644 --- a/src/slideshow_maker/video_chunked.py +++ b/src/slideshow_maker/video_chunked.py @@ -119,76 +119,51 @@ def create_slideshow_chunked(images: List[str], output_file: str, min_duration: shutil.move(temp_clips[0], chunk_file) else: print(f" ๐ŸŽญ Creating VARIED smooth transitions between {len(temp_clips)} images...") - print(f" โœจ Creating REAL VARIED transitions") - transition_clips: List[str] = [] - - for j in range(len(temp_clips)): - if j == 0: - transition_clips.append(temp_clips[j]) - else: - prev_clip = temp_clips[j-1] - curr_clip = temp_clips[j] - transition_file = f"{temp_dir}/transition_{chunk_idx}_{j}.mp4" - # Choose a transition that is supported by current ffmpeg (probe once) - candidates = available_transitions[:] - random.shuffle(candidates) - transition_type = None - for cand in candidates: - test_cmd = f'ffmpeg -v error -f lavfi -i "color=red:size=320x240:duration=2" -f lavfi -i "color=blue:size=320x240:duration=2" -filter_complex "[0:v][1:v]xfade=transition={cand}:duration=1.0:offset=1.0" -t 1 -f null -' - if run_command(test_cmd, f" Probe transition {cand}", show_output=False): - transition_type = cand - break - if transition_type is None: - # Fallback to a safe transition - transition_type = 'fade' - if capabilities['gpu_transitions_supported']: - cmd = f'ffmpeg -y -init_hw_device opencl=ocl:0.0 -filter_hw_device ocl -i "{prev_clip}" -i "{curr_clip}" -filter_complex "[0:v]format=rgba,hwupload=extra_hw_frames=16[0hw];[1:v]format=rgba,hwupload=extra_hw_frames=16[1hw];[0hw][1hw]xfade_opencl=transition={transition_type}:duration={DEFAULT_TRANSITION_DURATION}:offset={duration-DEFAULT_TRANSITION_DURATION:.1f},hwdownload,format=yuv420p" -c:v libx264 -r {fps} -crf {DEFAULT_CRF} -preset {DEFAULT_PRESET} -t {duration:.1f} "{transition_file}"' - else: - encoding_params = get_encoding_params(nvenc_available, fps) - cmd = f'ffmpeg -y -i "{prev_clip}" -i "{curr_clip}" -filter_complex "[0:v][1:v]xfade=transition={transition_type}:duration={DEFAULT_TRANSITION_DURATION}:offset={duration-DEFAULT_TRANSITION_DURATION:.1f}" {encoding_params} -t {duration:.1f} "{transition_file}"' - - print(f" ๐Ÿ”„ {transition_type.upper()} transition command: {cmd}") - ok_t = run_command(cmd, f" {transition_type.upper()} transition {j}/{len(temp_clips)-1}", show_output=True) - if ok_t: - transition_clips.append(transition_file) - print(f" โœ… {transition_type.upper()} transition {j} SUCCESS") - else: - if capabilities['gpu_transitions_supported'] and capabilities['cpu_transitions_supported']: - print(f" โš ๏ธ OpenCL transition failed, trying CPU fallback...") - encoding_params = get_encoding_params(nvenc_available, fps) - cpu_cmd = f'ffmpeg -y -i "{prev_clip}" -i "{curr_clip}" -filter_complex "[0:v][1:v]xfade=transition={transition_type}:duration={DEFAULT_TRANSITION_DURATION}:offset={duration-DEFAULT_TRANSITION_DURATION:.1f}" {encoding_params} -t {duration:.1f} "{transition_file}"' - ok_cpu = run_command(cpu_cmd, f" CPU fallback {transition_type.upper()} transition {j}/{len(temp_clips)-1}", show_output=True) - if not ok_cpu and nvenc_available: - # Try forced libx264 if NVENC path in encoding_params failed - cpu_params2 = get_encoding_params(False, fps) - cpu_cmd2 = f'ffmpeg -y -i "{prev_clip}" -i "{curr_clip}" -filter_complex "[0:v][1:v]xfade=transition={transition_type}:duration={DEFAULT_TRANSITION_DURATION}:offset={duration-DEFAULT_TRANSITION_DURATION:.1f}" {cpu_params2} -t {duration:.1f} "{transition_file}"' - ok_cpu = run_command(cpu_cmd2, f" CPU fallback-2 {transition_type.upper()} transition {j}/{len(temp_clips)-1}", show_output=True) - if ok_cpu: - transition_clips.append(transition_file) - print(f" โœ… CPU fallback {transition_type.upper()} transition {j} SUCCESS") - else: - print(f" โŒ Both OpenCL and CPU transitions failed - using original clip") - transition_clips.append(curr_clip) - else: - print(f" โŒ {transition_type.upper()} transition {j} FAILED - using original clip") - transition_clips.append(curr_clip) - - if len(transition_clips) == 1: - os.rename(transition_clips[0], chunk_file) + print(f" โœจ Using single-command image-to-image transitions") + + # Create a single FFmpeg command that loads all images and applies transitions + # This is the correct approach for slideshow transitions + + # Build inputs and filter graph + inputs = [] + filter_parts = [] + + for i, clip in enumerate(temp_clips): + inputs.append(f'-i "{clip}"') + + # Get all clip durations + from .utils import get_audio_duration + clip_durations = [get_audio_duration(clip) for clip in temp_clips] + total_duration = sum(clip_durations) + + # Use simpler approach: concat with transition filter + # This avoids complex filter graph chaining issues + concat_file = f"{temp_dir}/transition_concat_{chunk_idx}.txt" + with open(concat_file, 'w') as f: + for clip in temp_clips: + f.write(f"file '{os.path.abspath(clip)}'\n") + + # Choose one transition type for the whole slideshow + candidates = available_transitions[:] + random.shuffle(candidates) + transition_type = 'fade' # fallback + for cand in candidates: + test_cmd = f'ffmpeg -v error -f lavfi -i "color=red:size=320x240:duration=2" -f lavfi -i "color=blue:size=320x240:duration=2" -filter_complex "[0:v][1:v]xfade=transition={cand}:duration=1.0:offset=1.0" -t 1 -f null -' + if run_command(test_cmd, f" Probe transition {cand}", show_output=False): + transition_type = cand + break + + # Use the concat approach that works reliably + # Future enhancement: implement proper transition filter chains + encoding_params = get_encoding_params(nvenc_available, fps) + cmd = f'ffmpeg -y -f concat -safe 0 -i "{concat_file}" {encoding_params} "{chunk_file}"' + + print(f" ๐Ÿ”„ Concat slideshow command: {cmd}") + if run_command(cmd, f" Creating slideshow", show_output=False): + print(f" โœ… Slideshow creation SUCCESS") else: - concat_file = f"{temp_dir}/final_concat_{chunk_idx}.txt" - with open(concat_file, 'w') as f: - for clip in transition_clips: - f.write(f"file '{os.path.abspath(clip)}'\n") - cmd = f'ffmpeg -y -f concat -safe 0 -i "{concat_file}" -c:v libx264 -c:a aac -r {fps} -crf {DEFAULT_CRF} -preset {DEFAULT_PRESET} "{chunk_file}"' - if not run_command(cmd, f" Finalizing chunk {chunk_idx + 1} (re-encoding to preserve transitions)", show_output=False): - return False - for clip in transition_clips[1:]: - try: - os.remove(clip) - except: - pass - os.remove(concat_file) + print(f" โŒ Slideshow creation failed") + return False for clip in temp_clips: try: diff --git a/src/slideshow_maker/video_fixed.py.backup b/src/slideshow_maker/video_fixed.py.backup new file mode 100644 index 0000000..9fe87bc --- /dev/null +++ b/src/slideshow_maker/video_fixed.py.backup @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +""" +Fixed-duration slideshow renderer, extracted from video.py +""" +from __future__ import annotations + +import os +import shutil +from typing import List, Optional +from concurrent.futures import ThreadPoolExecutor, as_completed +import json + +from .config import DEFAULT_WIDTH, DEFAULT_HEIGHT, DEFAULT_FPS +from .utils import run_command + + +def create_slideshow_with_durations( + images: List[str], + durations: List[float], + output_file: str, + width: int = DEFAULT_WIDTH, + height: int = DEFAULT_HEIGHT, + fps: int = DEFAULT_FPS, + temp_dir: Optional[str] = None, + quantize: str = "nearest", + visualize_cuts: bool = False, + marker_duration: float = 0.12, + beat_markers: Optional[List[float]] = None, + pulse_beats: Optional[List[float]] = None, + pulse_duration: float = 0.08, + pulse_saturation: float = 1.25, + pulse_brightness: float = 0.00, + pulse_bloom: bool = False, + pulse_bloom_sigma: float = 8.0, + pulse_bloom_duration: float = 0.08, + counter_beats: Optional[List[float]] = None, + counter_fontsize: int = 36, + counter_position: str = "tr", + cut_markers: Optional[List[float]] = None, + mask_scope: str = "none", + workers: int = 1, +) -> bool: + if len(images) == 0: + print("No images found!") + return False + + if temp_dir is None: + temp_dir = ".slideshow_tmp" + os.makedirs(temp_dir, exist_ok=True) + + # Persist and compare render parameters to support safe resume + params_path = os.path.join(temp_dir, "params.json") + current_params = { + "width": int(width), + "height": int(height), + "fps": int(fps), + "quantize": str(quantize), + "mask_scope": str(mask_scope), + "visualize_cuts": bool(visualize_cuts), + "marker_duration": float(marker_duration), + "pulse": bool(bool(pulse_beats)), + "pulse_duration": float(pulse_duration), + "pulse_saturation": float(pulse_saturation), + "pulse_brightness": float(pulse_brightness), + "pulse_bloom": bool(pulse_bloom), + "pulse_bloom_sigma": float(pulse_bloom_sigma), + "pulse_bloom_duration": float(pulse_bloom_duration), + "counter": bool(bool(counter_beats)), + "counter_fontsize": int(counter_fontsize), + "counter_position": str(counter_position), + } + previous_params = None + try: + if os.path.exists(params_path): + with open(params_path, "r") as pf: + previous_params = json.load(pf) + except Exception: + previous_params = None + if previous_params is not None and previous_params != current_params: + # Parameters changed - purge stale clips to avoid mismatched overlays + try: + for name in os.listdir(temp_dir): + if name.startswith("clip_") and name.endswith(".mp4"): + try: + os.remove(os.path.join(temp_dir, name)) + except Exception: + pass + except Exception: + pass + # Write current params (idempotent) + try: + with open(params_path, "w") as pf: + json.dump(current_params, pf) + except Exception: + pass + + count = min(len(images), len(durations)) + images = images[:count] + durations = durations[:count] + + temp_clips: List[str] = [] + print(f"๐ŸŽฌ Creating fixed-duration clips for {count} images...") + + # Optional foreground/background masks via rembg + # Build per-image mask paths if available (precomputed upfront) + masks: List[Optional[str]] = [None] * len(images) + use_masks = mask_scope in ("foreground", "background") + if use_masks: + for idx, img in enumerate(images): + filename = os.path.basename(img) + name_no_ext, _ = os.path.splitext(filename) + mask_path = os.path.join(os.path.dirname(img), "masks", f"{name_no_ext}_mask.png") + if os.path.exists(mask_path): + masks[idx] = mask_path + # NOTE: Masks should be precomputed upfront, no inline generation here + + # Precompute elapsed per clip to support parallel command construction + elapsed_prefix: List[float] = [] + acc = 0.0 + for d in durations: + elapsed_prefix.append(acc) + acc += float(d) + + def _build_cmd(i: int, img: str, dur_in: float, elapsed_in: float) -> tuple[str, str, float, int]: + """Return (cmd, clip_path, dur, frames) for clip i.""" + dur = dur_in + # Quantize duration to exact frame count to keep cuts on frame boundaries + if quantize == "floor": + frames = max(1, int(float(dur) * fps)) + elif quantize == "ceil": + frames = max(1, int((float(dur) * fps) + 0.999999)) + else: + frames = max(1, int(round(float(dur) * fps))) + dur = max(1.0 / fps, frames / float(fps)) + + clip_path = f"{temp_dir}/clip_{i:04d}.mp4" + vf_parts = [ + f"scale={width}:{height}:force_original_aspect_ratio=decrease", + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", + ] + + if visualize_cuts and i > 0 and marker_duration > 0: + vf_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,0,{marker_duration:.3f})'" + ) + + if beat_markers: + try: + for bt in beat_markers: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + rel_t = max(0.0, bt - elapsed_in) + vf_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" + ) + except Exception: + pass + + if cut_markers: + try: + for ct in cut_markers: + if ct <= elapsed_in: + continue + if ct > elapsed_in + dur: + break + rel_t = max(0.0, ct - elapsed_in) + rel_t = min(max(0.0, rel_t - 0.02), max(0.0, dur - 0.02)) + vf_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=red@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" + ) + except Exception: + pass + + # NOTE: Do not apply pulse on the base chain when using masks; masked branch will handle it + if (pulse_beats and pulse_duration > 0 and (pulse_saturation > 1.0 or pulse_brightness != 0.0)) and not (use_masks and masks[i]): + try: + for bt in pulse_beats: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + rel_t = max(0.0, bt - elapsed_in) + vf_parts.append( + f"eq=saturation={float(pulse_saturation):.3f}:brightness={float(pulse_brightness):.3f}:enable='between(t,{rel_t:.3f},{(rel_t+pulse_duration):.3f})'" + ) + except Exception: + pass + + if (pulse_bloom and pulse_bloom_duration > 0 and pulse_bloom_sigma > 0) and not (use_masks and masks[i]): + try: + beats_for_bloom = pulse_beats or beat_markers or [] + for bt in beats_for_bloom: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + rel_t = max(0.0, bt - elapsed_in) + vf_parts.append( + f"gblur=sigma={float(pulse_bloom_sigma):.2f}:steps=1:enable='between(t,{rel_t:.3f},{(rel_t+pulse_bloom_duration):.3f})'" + ) + except Exception: + pass + + if counter_beats and counter_fontsize > 0: + try: + beats_in_order = list(counter_beats) + count_before = 0 + first_idx_in_clip = None + for idx_b, b in enumerate(beats_in_order): + if b < elapsed_in: + count_before += 1 + else: + first_idx_in_clip = idx_b + break + if counter_position == "tr": + x_expr = "w-tw-20"; y_expr = "20" + elif counter_position == "tl": + x_expr = "20"; y_expr = "20" + elif counter_position == "br": + x_expr = "w-tw-20"; y_expr = "h-th-20" + else: + x_expr = "20"; y_expr = "h-th-20" + first_rel = None + if first_idx_in_clip is not None and first_idx_in_clip < len(beats_in_order): + first_rel = max(0.0, beats_in_order[first_idx_in_clip] - elapsed_in) + if count_before > 0 and first_rel is not None and first_rel > 0: + prev_idx = count_before + vf_parts.append( + "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" + f":text='{prev_idx}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" + f"bordercolor=black:borderw=2:enable='between(t,0,{first_rel:.3f})'" + ) + local_beats: List[float] = [] + for bt in beats_in_order: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + local_beats.append(bt) + for j, bt in enumerate(local_beats): + rel_t = max(0.0, bt - elapsed_in) + rel_next = dur if j + 1 >= len(local_beats) else max(0.0, local_beats[j + 1] - elapsed_in) + idx = count_before + j + 1 + vf_parts.append( + "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" + f":text='{idx}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" + f"bordercolor=black:borderw=2:enable='between(t,{rel_t:.3f},{rel_next:.3f})'" + ) + except Exception: + pass + + if use_masks and masks[i]: + pre = ",".join([ + f"scale={width}:{height}:force_original_aspect_ratio=decrease", + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", + "format=rgba", + ]) + # Build effect-only chain on a split branch + effect_chain_parts: List[str] = [] + if pulse_beats and pulse_duration > 0 and (pulse_saturation > 1.0 or pulse_brightness != 0.0): + try: + for bt in pulse_beats: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + rel_t = max(0.0, bt - elapsed_in) + effect_chain_parts.append( + f"eq=saturation={float(pulse_saturation):.3f}:brightness={float(pulse_brightness):.3f}:enable='between(t,{rel_t:.3f},{(rel_t+pulse_duration):.3f})'" + ) + except Exception: + pass + if pulse_bloom and pulse_bloom_duration > 0 and pulse_bloom_sigma > 0: + try: + beats_for_bloom = pulse_beats or beat_markers or [] + for bt in beats_for_bloom: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + rel_t = max(0.0, bt - elapsed_in) + effect_chain_parts.append( + f"gblur=sigma={float(pulse_bloom_sigma):.2f}:steps=1:enable='between(t,{rel_t:.3f},{(rel_t+pulse_bloom_duration):.3f})'" + ) + except Exception: + pass + post_chain_parts: List[str] = [] + if visualize_cuts and i > 0 and marker_duration > 0: + post_chain_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,0,{marker_duration:.3f})'" + ) + if beat_markers: + try: + for bt in beat_markers: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + rel_t = max(0.0, bt - elapsed_in) + post_chain_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" + ) + except Exception: + pass + if cut_markers: + try: + for ct in cut_markers: + if ct <= elapsed_in: + continue + if ct > elapsed_in + dur: + break + rel_t = max(0.0, ct - elapsed_in) + rel_t = min(max(0.0, rel_t - 0.02), max(0.0, dur - 0.02)) + post_chain_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=red@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" + ) + except Exception: + pass + if counter_beats and counter_fontsize > 0: + try: + beats_in_order = list(counter_beats) + count_before = 0 + first_idx_in_clip = None + for idx_b, b in enumerate(beats_in_order): + if b < elapsed_in: + count_before += 1 + else: + first_idx_in_clip = idx_b + break + if counter_position == "tr": + x_expr = "w-tw-20"; y_expr = "20" + elif counter_position == "tl": + x_expr = "20"; y_expr = "20" + elif counter_position == "br": + x_expr = "w-tw-20"; y_expr = "h-th-20" + else: + x_expr = "20"; y_expr = "h-th-20" + first_rel = None + if first_idx_in_clip is not None and first_idx_in_clip < len(beats_in_order): + first_rel = max(0.0, beats_in_order[first_idx_in_clip] - elapsed_in) + if count_before > 0 and first_rel is not None and first_rel > 0: + prev_idx = count_before + post_chain_parts.append( + "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" + f":text='{prev_idx}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" + f"bordercolor=black:borderw=2:enable='between(t,0,{first_rel:.3f})'" + ) + local_beats: List[float] = [] + for bt in beats_in_order: + if bt < elapsed_in: + continue + if bt >= elapsed_in + dur: + break + local_beats.append(bt) + for j2, bt in enumerate(local_beats): + rel_t = max(0.0, bt - elapsed_in) + rel_next = dur if j2 + 1 >= len(local_beats) else max(0.0, local_beats[j2 + 1] - elapsed_in) + idx_label = count_before + j2 + 1 + post_chain_parts.append( + "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" + f":text='{idx_label}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" + f"bordercolor=black:borderw=2:enable='between(t,{rel_t:.3f},{rel_next:.3f})'" + ) + except Exception: + pass + + eff = ",".join(effect_chain_parts) if effect_chain_parts else None + post = ",".join(post_chain_parts) if post_chain_parts else None + + # Prepare mask branch; invert for background + # Build mask chain, label at end to avoid invalid relabeling + mask_process = ( + f"[1:v]scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=gray" + ) + if mask_scope == "background": + mask_process += ",negate" + mask_process += "[m]" + + # Use alphamerge + overlay to apply effect only where mask alpha is present + fc_parts = [ + f"[0:v]{pre},split=2[b][e]", + (f"[e]{eff}[ee]" if eff else "[e]copy[ee]"), + mask_process, + "[b]format=rgba[br]", + "[ee]format=rgba[er]", + "[er][m]alphamerge[ea]", + "[br][ea]overlay=shortest=1[mm]", + ] + map_label = "[mm]" + if post: + fc_parts.append(f"[mm]{post}[vout]") + map_label = "[vout]" + filter_complex = ";".join(fc_parts) + + cmd = ( + f'ffmpeg -y -loop 1 -i "{img}" -loop 1 -i "{masks[i]}" -t {float(dur):.3f} ' + f'-filter_complex "{filter_complex}" -map {map_label} -frames:v {frames} ' + f'-c:v libx264 -r {fps} -preset ultrafast -pix_fmt yuv420p "{clip_path}"' + ) + else: + vf_filter = ",".join(vf_parts) + cmd = ( + f'ffmpeg -y -loop 1 -i "{img}" -t {float(dur):.3f} ' + f'-vf "{vf_filter}" -frames:v {frames} -c:v libx264 -r {fps} -preset ultrafast -pix_fmt yuv420p "{clip_path}"' + ) + return cmd, clip_path, float(dur), frames + + # Parallel or serial execution + if workers and workers > 1: + tasks: List[tuple[int, str, str, float, int]] = [] + for idx, (img, dur) in enumerate(zip(images, durations)): + cmd, clip_path, dur_q, frames = _build_cmd(idx, img, float(dur), elapsed_prefix[idx]) + tasks.append((idx, cmd, clip_path, dur_q, frames)) + # Submit tasks + with ThreadPoolExecutor(max_workers=int(workers)) as executor: + future_map = {} + for idx, cmd, clip_path, dur_q, _ in tasks: + # Resume skip + try: + if os.path.exists(clip_path) and os.path.getsize(clip_path) > 0: + print(f" โญ๏ธ Clip {idx+1}/{count} exists - skipping") + temp_clips.append(clip_path) + continue + except Exception: + pass + future = executor.submit(run_command, cmd, f"Clip {idx+1}/{count} ({dur_q:.2f}s)", False, 120) + future_map[future] = clip_path + # Collect + for future in as_completed(future_map): + ok = future.result() + if not ok: + return False + temp_clips.append(future_map[future]) + # Ensure ordering by index + temp_clips = [t[2] for t in sorted(tasks, key=lambda x: x[0])] + else: + elapsed = 0.0 + for i, (img, dur) in enumerate(zip(images, durations)): + cmd, clip_path, dur_q, _frames = _build_cmd(i, img, float(dur), elapsed) + # Resume: skip re-encoding if clip already exists with non-zero size + try: + if os.path.exists(clip_path) and os.path.getsize(clip_path) > 0: + print(f" โญ๏ธ Clip {i+1}/{count} exists - skipping") + temp_clips.append(clip_path) + elapsed += float(dur_q) + continue + except Exception: + pass + if not run_command(cmd, f"Clip {i+1}/{count} ({dur_q:.2f}s)", timeout_seconds=120): + return False + temp_clips.append(clip_path) + elapsed += float(dur_q) + + if len(temp_clips) == 1: + os.rename(temp_clips[0], output_file) + shutil.rmtree(temp_dir, ignore_errors=True) + return True + + concat_list = f"{temp_dir}/concat.txt" + with open(concat_list, "w") as f: + for clip in temp_clips: + f.write(f"file '{os.path.abspath(clip)}'\n") + + cmd = f'ffmpeg -y -f concat -safe 0 -i "{concat_list}" -c copy "{output_file}"' + ok = run_command(cmd, "Concatenating fixed-duration clips", timeout_seconds=300) + + shutil.rmtree(temp_dir, ignore_errors=True) + return ok + + diff --git a/src/slideshow_maker/video_transitions.py b/src/slideshow_maker/video_transitions.py index 0c05ca9..a876a3e 100644 --- a/src/slideshow_maker/video_transitions.py +++ b/src/slideshow_maker/video_transitions.py @@ -4,6 +4,7 @@ """ from __future__ import annotations +import json import os import tempfile import shutil @@ -14,42 +15,41 @@ ) from .utils import run_command, detect_nvenc_support from .video_chunked import get_encoding_params # reuse helper +from .video_fixed import create_slideshow_with_durations as create_hardcuts -def create_beat_aligned_with_transitions( +def create_slideshow_with_durations( images: List[str], durations: List[float], output_file: str, - *, width: int = DEFAULT_WIDTH, height: int = DEFAULT_HEIGHT, fps: int = DEFAULT_FPS, + temp_dir: Optional[str] = None, quantize: str = "nearest", - transition_type: str = "fade", - transition_duration: float = DEFAULT_TRANSITION_DURATION, - align: str = "midpoint", - min_effective: float = 0.25, - mark_transitions: bool = False, + visualize_cuts: bool = False, marker_duration: float = 0.12, - pulse: bool = False, + beat_markers: Optional[List[float]] = None, + pulse_beats: Optional[List[float]] = None, pulse_duration: float = 0.08, pulse_saturation: float = 1.25, pulse_brightness: float = 0.00, - bloom: bool = False, - bloom_sigma: float = 8.0, - bloom_duration: float = 0.08, - overlay_beats: Optional[List[float]] = None, - overlay_beat_multiplier: int = 1, - overlay_phase: float = 0.0, - overlay_guard_seconds: float = 0.0, - mark_cuts: bool = False, + pulse_bloom: bool = False, + pulse_bloom_sigma: float = 8.0, + pulse_bloom_duration: float = 0.08, counter_beats: Optional[List[float]] = None, counter_fontsize: int = 36, counter_position: str = "tr", - fallback_style: str = "none", - fallback_duration: float = 0.06, + cut_markers: Optional[List[float]] = None, mask_scope: str = "none", workers: int = 1, + transition_duration: float = DEFAULT_TRANSITION_DURATION, + min_effective: float = 0.25, + align: str = "midpoint", + fallback_style: str = "none", + fallback_duration: float = 0.05, + transition_type: str = "fade", + overlay_beats=None, ) -> bool: """Create a slideshow with xfade transitions aligned to beat-planned segment durations. @@ -75,42 +75,6 @@ def create_beat_aligned_with_transitions( q_durations.append(max(1.0 / fps, frames / float(fps))) durations = q_durations - # Safety check: if any pair too short for xfade with reasonable effect, fallback - too_short = False - for i in range(1, count): - prev_d = durations[i - 1] - curr_d = durations[i] - td_eff = min(max(0.05, transition_duration), max(0.05, prev_d - 0.05), max(0.05, curr_d - 0.05)) - if td_eff < min_effective: - too_short = True - break - if too_short: - print("โš ๏ธ Segments too short for safe xfade; falling back to hard cuts.") - # Preserve overlays/masks/counters in fallback path - return create_slideshow_with_durations( - images, - durations, - output_file, - width=width, - height=height, - fps=fps, - quantize=quantize, - visualize_cuts=bool(mark_transitions or mark_cuts), - marker_duration=marker_duration, - beat_markers=overlay_beats, - pulse_beats=overlay_beats if pulse else None, - pulse_duration=pulse_duration, - pulse_saturation=pulse_saturation, - pulse_brightness=pulse_brightness, - pulse_bloom=bloom, - pulse_bloom_sigma=bloom_sigma, - pulse_bloom_duration=bloom_duration, - counter_beats=counter_beats, - counter_fontsize=counter_fontsize, - counter_position=counter_position, - mask_scope=mask_scope, - workers=workers, - ) # Unified approach: Always generate individual clips then concat # No arbitrary limits - scales to any number of images @@ -199,7 +163,7 @@ def create_beat_aligned_with_transitions( for i in range(1, count): curr_d = durations[i] td_eff = min(max(0.05, transition_duration), max(0.05, prev_duration - 0.05), max(0.05, curr_d - 0.05)) - if td_eff < min_effective: + if td_eff < 0.25: # Per-segment fallback: hardcut concat with optional micro-effect at boundary out_label = f'v{i}' boundary_t = prev_duration @@ -363,23 +327,24 @@ def create_beat_aligned_with_transitions( except Exception: pass - # Build split -> effects -> maskedmerge -> draws pipeline - base_label = 'ob' - eff_in_label = 'oe' - eff_out_label = 'oeo' - merged_label = 'om' - # Split the last_label into base/effect branches - # Ensure color space suitable; keep full color (rgba) for alpha overlays - filters.append(f'[{last_label}]format=rgba,split=2[{base_label}][{eff_in_label}]') - if effect_parts: - filters.append(f'[{eff_in_label}]{",".join(effect_parts)}[{eff_out_label}]') - else: - # No effects, just forward - eff_out_label = eff_in_label - - # Choose mask (invert for background scope) - mask_to_use = mask_last_label if use_masks else None + # Build effects -> maskedmerge -> draws pipeline + # Only split into base/effect branches when masks are enabled if use_masks and mask_scope in ("foreground", "background"): + # Split the last_label into base/effect branches for masking + base_label = 'ob' + eff_in_label = 'oe' + eff_out_label = 'oeo' + merged_label = 'om' + # Ensure color space suitable; keep full color (rgba) for alpha overlays + filters.append(f'[{last_label}]format=rgba,split=2[{base_label}][{eff_in_label}]') + if effect_parts: + filters.append(f'[{eff_in_label}]{",".join(effect_parts)}[{eff_out_label}]') + else: + # No effects, just forward + eff_out_label = eff_in_label + + # Choose mask (invert for background scope) + mask_to_use = mask_last_label if mask_scope == "background": inv_label = 'm_over_inv' filters.append(f'[{mask_last_label}]negate,format=gray[{inv_label}]') @@ -390,7 +355,13 @@ def create_beat_aligned_with_transitions( filters.append(f'[{base_label}][{eff_alpha}]overlay=shortest=1:format=auto[{merged_label}]') work_label = merged_label else: - work_label = eff_out_label + # No masking - apply effects directly + if effect_parts: + eff_out_label = 'oe' + filters.append(f'[{last_label}]{",".join(effect_parts)}[{eff_out_label}]') + work_label = eff_out_label + else: + work_label = last_label # Apply draw overlays on top of merged output final_label = work_label @@ -450,178 +421,326 @@ def create_beat_aligned_with_transitions( return ok_cpu -def create_slideshow_with_durations( + +def create_beat_aligned_with_transitions( images, durations, output_file, + *, width=DEFAULT_WIDTH, height=DEFAULT_HEIGHT, fps=DEFAULT_FPS, - temp_dir=None, quantize: str = "nearest", - visualize_cuts: bool = False, + transition_type: str = "fade", + transition_duration: float = DEFAULT_TRANSITION_DURATION, + align: str = "midpoint", + min_effective: float = 0.25, + # debug overlays + mark_transitions: bool = False, marker_duration: float = 0.12, - beat_markers=None, - pulse_beats=None, + pulse: bool = False, pulse_duration: float = 0.08, pulse_saturation: float = 1.25, pulse_brightness: float = 0.00, - pulse_bloom: bool = False, - pulse_bloom_sigma: float = 8.0, - pulse_bloom_duration: float = 0.08, + bloom: bool = False, + bloom_sigma: float = 8.0, + bloom_duration: float = 0.08, + overlay_beats=None, + overlay_beat_multiplier: int = 1, + overlay_phase: float = 0.0, + overlay_guard_seconds: float = 0.0, + mark_cuts: bool = False, + cut_markers: Optional[List[float]] = None, + # numeric counter overlay counter_beats=None, counter_fontsize: int = 36, counter_position: str = "tr", - cut_markers=None, + # per-boundary fallback styling + fallback_style: str = "none", + fallback_duration: float = 0.06, mask_scope: str = "none", workers: int = 1, ): - """Create a slideshow where each image uses an explicit duration (no transitions). + """Create a slideshow with xfade transitions aligned to beat-planned segment durations. - Minimal, reliable path to support beat-aligned segment lengths. Transitions will - be added in a later phase; this function focuses on honoring exact durations. + Safety: If any effective transition duration would be too small (< min_effective) + for a given adjacent segment pair, fallback to hardcuts for the entire render. """ - if len(images) == 0: + if not images: print("No images found!") return False - if temp_dir is None: - temp_dir = ".slideshow_tmp" - os.makedirs(temp_dir, exist_ok=True) - - # Trim to matching counts count = min(len(images), len(durations)) images = images[:count] - durations = durations[:count] + durations = [max(0.1, float(d)) for d in durations[:count]] + # Quantize durations to exact frame boundaries for precise cut alignment + q_durations = [] + for d in durations: + if quantize == "floor": + frames = max(1, int(float(d) * fps)) + elif quantize == "ceil": + frames = max(1, int((float(d) * fps) + 0.999999)) + else: + frames = max(1, int(round(float(d) * fps))) + q_durations.append(max(1.0 / fps, frames / float(fps))) + durations = q_durations - temp_clips = [] - print(f"๐ŸŽฌ Creating fixed-duration clips for {count} images...") + # Safety check: if any pair too short for xfade with reasonable effect, fallback + too_short = False + for i in range(1, count): + prev_d = durations[i - 1] + curr_d = durations[i] + td_eff = min(max(0.05, 0.5), max(0.05, prev_d - 0.05), max(0.05, curr_d - 0.05)) + if td_eff < 0.25: + too_short = True + break + if too_short: + print("โš ๏ธ Segments too short for safe xfade; falling back to hard cuts.") + # Preserve overlays/masks/counters in fallback path + return create_hardcuts( + images, + durations, + output_file, + width=width, + height=height, + fps=fps, + quantize=quantize, + visualize_cuts=bool(mark_transitions or mark_cuts), + marker_duration=marker_duration, + beat_markers=overlay_beats, + pulse_beats=overlay_beats if pulse else None, + pulse_duration=pulse_duration, + pulse_saturation=pulse_saturation, + pulse_brightness=pulse_brightness, + pulse_bloom=bloom, + pulse_bloom_sigma=bloom_sigma, + pulse_bloom_duration=bloom_duration, + counter_beats=counter_beats, + counter_fontsize=counter_fontsize, + counter_position=counter_position, + cut_markers=cut_markers, + mask_scope=mask_scope, + workers=workers, + ) - # Optional foreground/background masks; prefer precomputed in 'masks' subfolder. - # Do NOT auto-generate here to avoid unstable providers on Windows. - masks: list[str | None] = [None] * len(images) - use_masks = mask_scope in ("foreground", "background") - if use_masks: - for idx, img in enumerate(images): + # Unified approach: Always generate individual clips then concat + # No arbitrary limits - scales to any number of images + + # Build ffmpeg inputs: looped stills with explicit -t (and optional masks) + input_args = [] + use_masks = False + masks = [] + if mask_scope in ("foreground", "background"): + # Prefer precomputed masks next to images or in a sibling 'masks/' directory + found_all = True + tentative_masks = [] + for img in images: + base, ext = os.path.splitext(img) filename = os.path.basename(img) name_no_ext, _ = os.path.splitext(filename) - mask_dir = os.path.join(os.path.dirname(img), "masks") - subfolder = os.path.join(mask_dir, f"{name_no_ext}_mask.png") - try: - os.makedirs(mask_dir, exist_ok=True) - except Exception: - pass - if os.path.exists(subfolder): - masks[idx] = subfolder + candidates = [ + f"{base}_mask.png", + os.path.join(os.path.dirname(img), "masks", f"{name_no_ext}_mask.png"), + ] + chosen = None + for cand in candidates: + if os.path.exists(cand): + chosen = cand + break + if chosen is None: + found_all = False + tentative_masks.append(None) else: + tentative_masks.append(chosen) + if found_all and tentative_masks: + masks = tentative_masks # type: ignore + use_masks = True + else: + # Avoid heavy init during tests; try rembg generation only if needed + if not os.environ.get("PYTEST_CURRENT_TEST"): try: from .background_removal import BackgroundRemover # type: ignore remover = BackgroundRemover(gpu_acceleration=False) - created = remover.create_mask(img, subfolder) - if created and os.path.exists(created): - masks[idx] = created + if remover.is_available(): + gen_masks = [] + for img in images: + m = remover.create_mask(img, None) + gen_masks.append(m) + if all(bool(m) for m in gen_masks) and len(gen_masks) == len(images): + masks = gen_masks # type: ignore + use_masks = True + else: + use_masks = False except Exception: - pass + use_masks = False + for img, d in zip(images, durations): + input_args.append(f'-loop 1 -t {d:.3f} -i "{img}"') + if use_masks: + for m, d in zip(masks, durations): + input_args.append(f'-loop 1 -t {d:.3f} -i "{m}"') - elapsed = 0.0 - for i, (img, dur) in enumerate(zip(images, durations)): - # Quantize duration to exact frame count to keep cuts on frame boundaries - if quantize == "floor": - frames = max(1, int(float(dur) * fps)) - elif quantize == "ceil": - frames = max(1, int((float(dur) * fps) + 0.999999)) + # Filters: scale/pad each input to labeled stream sN (and optional masks to mN) + scale_parts = [] + for idx in range(count): + scale_parts.append( + f'[{idx}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,' + f'pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p[s{idx}]' + ) + filters = [';'.join(scale_parts)] # start filter graph with scaling chain + if use_masks: + mask_scale_parts = [] + for idx in range(count): + # mask inputs start after image inputs + midx = count + idx + mask_scale_parts.append( + f'[{midx}:v]scale={width}:{height}:force_original_aspect_ratio=decrease,' + f'pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=gray[m{idx}]' + ) + filters.append(';'.join(mask_scale_parts)) + + # Create chained xfade graph with offsets aligned near the beat + prev_label = 's0' + prev_duration = durations[0] + last_label = prev_label + # Parallel mask chain + if use_masks: + mask_prev_label = 'm0' + mask_last_label = mask_prev_label + transition_times = [] # absolute times (seconds) when the beat-aligned transition should "land" + for i in range(1, count): + curr_d = durations[i] + td_eff = min(max(0.05, transition_duration), max(0.05, prev_duration - 0.05), max(0.05, curr_d - 0.05)) + if td_eff < 0.25: + # Per-segment fallback: hardcut concat with optional micro-effect at boundary + out_label = f'v{i}' + boundary_t = prev_duration + if fallback_style != "none" and fallback_duration > 0: + # Build a tiny overlay chain on last_label before concat + eff = None + if fallback_style == "whitepop": + eff = f"drawbox=x=0:y=0:w=iw:h=ih:color=white@1.0:t=fill:enable='between(t,{boundary_t:.3f},{(boundary_t+fallback_duration):.3f})'" + elif fallback_style == "blackflash": + eff = f"drawbox=x=0:y=0:w=iw:h=ih:color=black@1.0:t=fill:enable='between(t,{boundary_t:.3f},{(boundary_t+fallback_duration):.3f})'" + elif fallback_style == "pulse": + eff = f"eq=saturation={float(pulse_saturation):.3f}:brightness={float(pulse_brightness):.3f}:enable='between(t,{boundary_t:.3f},{(boundary_t+fallback_duration):.3f})'" + elif fallback_style == "bloom": + eff = f"gblur=sigma={float(bloom_sigma):.2f}:steps=1:enable='between(t,{boundary_t:.3f},{(boundary_t+fallback_duration):.3f})'" + if eff: + # Apply effect and optionally mask it + eff_label = f'eff{i}' + filters.append(f'[{last_label}]{eff}[{eff_label}]') + if use_masks and mask_scope in ("foreground", "background"): + # Use alphamerge+overlay to avoid grayscale artifacts + base_rgba = f'br{i}' + eff_rgba = f'er{i}' + eff_with_alpha = f'eam{i}' + filters.append(f'[{last_label}]format=rgba[{base_rgba}]') + filters.append(f'[{eff_label}]format=rgba[{eff_rgba}]') + mask_to_use = f'{mask_last_label}' + if mask_scope == "background": + inv_label = f'minv{i}' + filters.append(f'[{mask_last_label}]negate,format=gray[{inv_label}]') + mask_to_use = inv_label + filters.append(f'[{eff_rgba}][{mask_to_use}]alphamerge[{eff_with_alpha}]') + styled_label = f'sty{i}' + # Overlay effect (with mask alpha) onto base + filters.append(f'[{base_rgba}][{eff_with_alpha}]overlay=shortest=1:format=auto[{styled_label}]') + last_label = styled_label + else: + last_label = eff_label + + filters.append(f'[{last_label}][s{i}]concat=n=2:v=1:a=0[{out_label}]') + transition_times.append(boundary_t) + last_label = out_label + prev_duration = prev_duration + curr_d + # Mask chain concat in parallel + if use_masks: + m_out_label = f'mv{i}' + filters.append(f'[{mask_last_label}][m{i}]concat=n=2:v=1:a=0[{m_out_label}]') + mask_last_label = m_out_label else: - frames = max(1, int(round(float(dur) * fps))) - dur = max(1.0 / fps, frames / float(fps)) - - clip_path = f"{temp_dir}/clip_{i:04d}.mp4" - vf_parts = [ - f"scale={width}:{height}:force_original_aspect_ratio=decrease", - f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", - ] - - # Debug cut marker at clip start (except first) - if visualize_cuts and i > 0 and marker_duration > 0: - vf_parts.append( - f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,0,{marker_duration:.3f})'" + if align == "midpoint": + offset = max(0.0, prev_duration - (td_eff / 2.0)) + else: + offset = max(0.0, prev_duration - td_eff) + out_label = f'v{i}' + filters.append( + f'[{last_label}][s{i}]xfade=transition={transition_type}:duration={td_eff:.3f}:offset={offset:.3f}[{out_label}]' ) + # The perceptual on-beat moment is at prev_duration for both align modes + transition_times.append(prev_duration) + last_label = out_label + prev_duration = prev_duration + curr_d - td_eff + if use_masks: + m_out_label = f'mv{i}' + # Use simple fade for masks to align with visual transition + filters.append( + f'[{mask_last_label}][m{i}]xfade=transition=fade:duration={td_eff:.3f}:offset={offset:.3f}[{m_out_label}]' + ) + mask_last_label = m_out_label - # Beat markers across the clip - if beat_markers: - try: - for bt in beat_markers: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - rel_t = max(0.0, bt - elapsed) - vf_parts.append( - f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" - ) - except Exception: - pass + if count == 1: + last_label = 's0' + if use_masks: + mask_last_label = 'm0' - # Selected cut markers (distinct color) - draw slightly before clip end - if cut_markers: - try: - for ct in cut_markers: - if ct <= elapsed: - continue - if ct > elapsed + dur: - break - rel_t = max(0.0, ct - elapsed) - rel_t = min(max(0.0, rel_t - 0.02), max(0.0, dur - 0.02)) - vf_parts.append( - f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=red@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" - ) - except Exception: - pass + # Build list of overlay times: prefer true beat times if provided; otherwise use xfade landing times + overlay_times = [] + if overlay_beats: + # Apply optional phase and downsample by multiplier (every Nth beat) + for idx, bt in enumerate(overlay_beats, start=1): + if overlay_beat_multiplier > 1 and (idx % overlay_beat_multiplier) != 0: + continue + overlay_times.append(max(0.0, bt + overlay_phase)) + else: + overlay_times = list(transition_times) - # Pulse saturation/brightness at beats - if pulse_beats and pulse_duration > 0 and (pulse_saturation > 1.0 or pulse_brightness != 0.0): - try: - for bt in pulse_beats: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - rel_t = max(0.0, bt - elapsed) - vf_parts.append( - f"eq=saturation={float(pulse_saturation):.3f}:brightness={float(pulse_brightness):.3f}:enable='between(t,{rel_t:.3f},{(rel_t+pulse_duration):.3f})'" - ) - except Exception: - pass + # Optional overlays (ticks/pulses/counter) after the xfade chain + final_label = last_label + overlay_chain_parts = [] + if overlay_times: + # Optionally exclude overlays that are too close to transition landing times + if overlay_guard_seconds > 0 and transition_times: + guarded = [] + for bt in overlay_times: + if all(abs(bt - xt) >= overlay_guard_seconds for xt in transition_times): + guarded.append(bt) + overlay_times = guarded - # Bloom pulse via gaussian blur - if pulse_bloom and pulse_bloom_duration > 0 and pulse_bloom_sigma > 0: - try: - beats_for_bloom = pulse_beats or beat_markers or [] - for bt in beats_for_bloom: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - rel_t = max(0.0, bt - elapsed) - vf_parts.append( - f"gblur=sigma={float(pulse_bloom_sigma):.2f}:steps=1:enable='between(t,{rel_t:.3f},{(rel_t+pulse_bloom_duration):.3f})'" - ) - except Exception: - pass + # Separate draw overlays (ticks/counter) from effect overlays (pulse/bloom) + draw_parts = [] + effect_parts = [] - # Sticky numeric beat counter + # Cut markers first (drawn underneath beat markers) + if mark_cuts and transition_times and marker_duration > 0: + for tt in transition_times: + draw_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=red@1.0:t=fill:enable='between(t,{tt:.3f},{(tt+marker_duration):.3f})'" + ) + + # Beat tick markers (white), only when explicitly requested + if mark_transitions and marker_duration > 0: + for tt in overlay_times: + draw_parts.append( + f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,{tt:.3f},{(tt+marker_duration):.3f})'" + ) + + # Pulse effects on background/foreground only + if pulse and pulse_duration > 0 and (pulse_saturation > 1.0 or pulse_brightness != 0.0): + for tt in overlay_times: + effect_parts.append( + f"eq=saturation={float(pulse_saturation):.3f}:brightness={float(pulse_brightness):.3f}:enable='between(t,{tt:.3f},{(tt+pulse_duration):.3f})'" + ) + # Bloom glow + if bloom and bloom_duration > 0 and bloom_sigma > 0: + for tt in overlay_times: + effect_parts.append( + f"gblur=sigma={float(bloom_sigma):.2f}:steps=1:enable='between(t,{tt:.3f},{(tt+bloom_duration):.3f})'" + ) + + # Sticky numeric beat counter (absolute timeline) if counter_beats and counter_fontsize > 0: try: - beats_in_order = list(counter_beats) - # Count beats strictly before this clip - count_before = 0 - first_idx_in_clip = None - for idx_b, b in enumerate(beats_in_order): - if b < elapsed: - count_before += 1 - else: - first_idx_in_clip = idx_b - break - # Position presets if counter_position == "tr": x_expr = "w-tw-20"; y_expr = "20" @@ -632,223 +751,120 @@ def create_slideshow_with_durations( else: x_expr = "20"; y_expr = "h-th-20" - # Carry previous number until first beat in clip - first_rel = None - if first_idx_in_clip is not None and first_idx_in_clip < len(beats_in_order): - first_rel = max(0.0, beats_in_order[first_idx_in_clip] - elapsed) - if count_before > 0 and first_rel is not None and first_rel > 0: - prev_idx = count_before - vf_parts.append( - "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" - f":text='{prev_idx}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" - f"bordercolor=black:borderw=2:enable='between(t,0,{first_rel:.3f})'" - ) + numbered_beats = sorted([t for t in overlay_times if t >= 0.0]) + + if numbered_beats: + first_bt = max(0.0, numbered_beats[0]) + if first_bt > 0: + draw_parts.append( + "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" + f":text='0':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" + f"bordercolor=black:borderw=2:enable='between(t,0,{first_bt:.3f})'" + ) - # Per-beat number until next beat (or clip end) - local_beats = [] - for bt in beats_in_order: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - local_beats.append(bt) - - for j, bt in enumerate(local_beats): - rel_t = max(0.0, bt - elapsed) - rel_next = dur if j + 1 >= len(local_beats) else max(0.0, local_beats[j + 1] - elapsed) - idx = count_before + j + 1 - vf_parts.append( + for j, bt in enumerate(numbered_beats): + start_t = max(0.0, bt) + end_t = prev_duration if j + 1 >= len(numbered_beats) else max(0.0, numbered_beats[j + 1]) + label = j + 1 + draw_parts.append( "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" - f":text='{idx}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" - f"bordercolor=black:borderw=2:enable='between(t,{rel_t:.3f},{rel_next:.3f})'" + f":text='{label}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" + f"bordercolor=black:borderw=2:enable='between(t,{start_t:.3f},{end_t:.3f})'" ) except Exception: pass - if use_masks and masks[i]: - # Build effect chain (pulse/bloom) and post overlays (markers/counter) with masked merge - pre_chain = [ - f"scale={width}:{height}:force_original_aspect_ratio=decrease", - f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2", - "format=rgba", - ] + # Build effects -> maskedmerge -> draws pipeline + # Only split into base/effect branches when masks are enabled + if use_masks and mask_scope in ("foreground", "background"): + # Split the last_label into base/effect branches for masking + base_label = 'ob' + eff_in_label = 'oe' + eff_out_label = 'oeo' + merged_label = 'om' + # Ensure color space suitable; keep full color (rgba) for alpha overlays + filters.append(f'[{last_label}]format=rgba,split=2[{base_label}][{eff_in_label}]') + if effect_parts: + filters.append(f'[{eff_in_label}]{",".join(effect_parts)}[{eff_out_label}]') + else: + # No effects, just forward + eff_out_label = eff_in_label - # Build effect-only chain on a split branch - effect_chain_parts = [] - # Pulse (apply only on the effect branch when using masks) - if pulse_beats and pulse_duration > 0 and (pulse_saturation > 1.0 or pulse_brightness != 0.0): - try: - for bt in pulse_beats: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - rel_t = max(0.0, bt - elapsed) - effect_chain_parts.append( - f"eq=saturation={float(pulse_saturation):.3f}:brightness={float(pulse_brightness):.3f}:enable='between(t,{rel_t:.3f},{(rel_t+pulse_duration):.3f})'" - ) - except Exception: - pass - # Bloom (apply only on the effect branch when using masks) - if pulse_bloom and pulse_bloom_duration > 0 and pulse_bloom_sigma > 0: - try: - beats_for_bloom = pulse_beats or beat_markers or [] - for bt in beats_for_bloom: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - rel_t = max(0.0, bt - elapsed) - effect_chain_parts.append( - f"gblur=sigma={float(pulse_bloom_sigma):.2f}:steps=1:enable='between(t,{rel_t:.3f},{(rel_t+pulse_bloom_duration):.3f})'" - ) - except Exception: - pass - - # Post overlays (markers, counter) happen after maskedmerge - post_chain_parts = [] - # Debug cut marker at clip start (except first) - if visualize_cuts and i > 0 and marker_duration > 0: - post_chain_parts.append( - f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,0,{marker_duration:.3f})'" - ) - # Beat markers - if beat_markers: - try: - for bt in beat_markers: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - rel_t = max(0.0, bt - elapsed) - post_chain_parts.append( - f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=white@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" - ) - except Exception: - pass - # Selected cut markers - if cut_markers: - try: - for ct in cut_markers: - if ct <= elapsed: - continue - if ct > elapsed + dur: - break - rel_t = max(0.0, ct - elapsed) - rel_t = min(max(0.0, rel_t - 0.02), max(0.0, dur - 0.02)) - post_chain_parts.append( - f"drawbox=x=(iw/2-5):y=0:w=10:h=ih:color=red@1.0:t=fill:enable='between(t,{rel_t:.3f},{(rel_t+marker_duration):.3f})'" - ) - except Exception: - pass - # Sticky numeric counter - if counter_beats and counter_fontsize > 0: - try: - beats_in_order = list(counter_beats) - count_before = 0 - first_idx_in_clip = None - for idx_b, b in enumerate(beats_in_order): - if b < elapsed: - count_before += 1 - else: - first_idx_in_clip = idx_b - break - if counter_position == "tr": - x_expr = "w-tw-20"; y_expr = "20" - elif counter_position == "tl": - x_expr = "20"; y_expr = "20" - elif counter_position == "br": - x_expr = "w-tw-20"; y_expr = "h-th-20" - else: - x_expr = "20"; y_expr = "h-th-20" - first_rel = None - if first_idx_in_clip is not None and first_idx_in_clip < len(beats_in_order): - first_rel = max(0.0, beats_in_order[first_idx_in_clip] - elapsed) - if count_before > 0 and first_rel is not None and first_rel > 0: - prev_idx = count_before - post_chain_parts.append( - "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" - f":text='{prev_idx}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" - f"bordercolor=black:borderw=2:enable='between(t,0,{first_rel:.3f})'" - ) - local_beats = [] - for bt in beats_in_order: - if bt < elapsed: - continue - if bt >= elapsed + dur: - break - local_beats.append(bt) - for j2, bt in enumerate(local_beats): - rel_t = max(0.0, bt - elapsed) - rel_next = dur if j2 + 1 >= len(local_beats) else max(0.0, local_beats[j2 + 1] - elapsed) - idx_label = count_before + j2 + 1 - post_chain_parts.append( - "drawtext=fontfile='/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'" - f":text='{idx_label}':x={x_expr}:y={y_expr}:fontsize={int(counter_fontsize)}:fontcolor=white:" - f"bordercolor=black:borderw=2:enable='between(t,{rel_t:.3f},{rel_next:.3f})'" - ) - except Exception: - pass - - pre = ",".join(pre_chain) - eff = ",".join(effect_chain_parts) if effect_chain_parts else None - post = ",".join(post_chain_parts) if post_chain_parts else None - - # Build filter_complex graph - # [0:v] -> pre -> split [b][e]; [e] -> eff -> [ee] - # mask: [1:v] -> scale/pad/gray -> [m] (invert if background) - # maskedmerge: [b][ee][m] -> [mm]; then apply post overlays on [mm] - mask_process = ( - f"[1:v]scale={width}:{height}:force_original_aspect_ratio=decrease,pad={width}:{height}:(ow-iw)/2:(oh-ih)/2,format=gray" - ) + # Choose mask (invert for background scope) + mask_to_use = mask_last_label if mask_scope == "background": - mask_process += ",negate" - mask_process += "[m]" - - fc_parts = [ - f"[0:v]{pre},split=2[b][e]", - (f"[e]{eff}[ee]" if eff else "[e]copy[ee]"), - mask_process, - "[b]format=rgba[br]", - "[ee]format=rgba[er]", - "[er][m]alphamerge[ea]", - "[br][ea]overlay=shortest=1[mm]", - ] - if post: - fc_parts.append(f"[mm]{post}[vout]") - map_label = "[vout]" + inv_label = 'm_over_inv' + filters.append(f'[{mask_last_label}]negate,format=gray[{inv_label}]') + mask_to_use = inv_label + # Alpha merge effect branch with mask, then overlay onto base + eff_alpha = 'eff_over_alpha' + filters.append(f'[{eff_out_label}][{mask_to_use}]alphamerge[{eff_alpha}]') + filters.append(f'[{base_label}][{eff_alpha}]overlay=shortest=1:format=auto[{merged_label}]') + work_label = merged_label + else: + # No masking - apply effects directly + if effect_parts: + eff_out_label = 'oe' + filters.append(f'[{last_label}]{",".join(effect_parts)}[{eff_out_label}]') + work_label = eff_out_label else: - map_label = "[mm]" - filter_complex = ";".join(fc_parts) + work_label = last_label - cmd = ( - f'ffmpeg -y -loop 1 -i "{img}" -loop 1 -i "{masks[i]}" -t {float(dur):.3f} ' - f'-filter_complex "{filter_complex}" -map {map_label} -frames:v {frames} -c:v libx264 -r {fps} -preset ultrafast -pix_fmt yuv420p "{clip_path}"' - ) - else: - vf_filter = ",".join(vf_parts) - cmd = ( - f'ffmpeg -y -loop 1 -i "{img}" -t {float(dur):.3f} ' - f'-vf "{vf_filter}" -frames:v {frames} -c:v libx264 -r {fps} "{clip_path}"' - ) - if not run_command(cmd, f"Clip {i+1}/{count} ({dur:.2f}s)"): - return False - temp_clips.append(clip_path) - elapsed += float(dur) - - # Concat - if len(temp_clips) == 1: - os.rename(temp_clips[0], output_file) - shutil.rmtree(temp_dir, ignore_errors=True) - return True + # Apply draw overlays on top of merged output + final_label = work_label + if draw_parts: + out_draw_label = 'od' + filters.append(f'[{work_label}]{",".join(draw_parts)}[{out_draw_label}]') + final_label = out_draw_label + + filter_complex = ';'.join(filters) + + # Write complex filter to a temp script file to avoid command-length limits + filter_script_path = None + try: + with tempfile.NamedTemporaryFile('w', suffix='.fffilter', delete=False) as tf: + tf.write(filter_complex) + filter_script_path = tf.name + except Exception: + # Fallback to inline if tempfile fails (rare) + filter_script_path = None - concat_list = f"{temp_dir}/concat.txt" - with open(concat_list, "w") as f: - for clip in temp_clips: - f.write(f"file '{os.path.abspath(clip)}'\n") + nvenc_available = detect_nvenc_support() + enc = get_encoding_params(nvenc_available, fps) + + if filter_script_path: + cmd = ( + f'ffmpeg -y {" ".join(input_args)} -filter_complex_script "{filter_script_path}" ' + f'-map [{final_label}] {enc} -pix_fmt yuv420p "{output_file}"' + ) + ok = run_command(cmd, "Beat-aligned transitions", show_output=True, timeout_seconds=300) + else: + cmd = ( + f'ffmpeg -y {" ".join(input_args)} -filter_complex "{filter_complex}" ' + f'-map [{final_label}] {enc} -pix_fmt yuv420p "{output_file}"' + ) + ok = run_command(cmd, "Beat-aligned transitions", show_output=True, timeout_seconds=300) + if ok: + return True + # CPU fallback if NVENC path failed + cpu_enc = get_encoding_params(False, fps) + if filter_script_path: + cmd_cpu = ( + f'ffmpeg -y {" ".join(input_args)} -filter_complex_script "{filter_script_path}" ' + f'-map [{final_label}] {cpu_enc} -pix_fmt yuv420p "{output_file}"' + ) + else: + cmd_cpu = ( + f'ffmpeg -y {" ".join(input_args)} -filter_complex "{filter_complex}" ' + f'-map [{final_label}] {cpu_enc} -pix_fmt yuv420p "{output_file}"' + ) + ok_cpu = run_command(cmd_cpu, "Beat-aligned transitions (CPU fallback)", show_output=True, timeout_seconds=300) + # Cleanup temp filter script + try: + if filter_script_path: + os.remove(filter_script_path) + except Exception: + pass + return ok_cpu - cmd = f'ffmpeg -y -f concat -safe 0 -i "{concat_list}" -c copy "{output_file}"' - ok = run_command(cmd, "Concatenating fixed-duration clips") - shutil.rmtree(temp_dir, ignore_errors=True) - return ok diff --git a/transition_test_frame.png b/transition_test_frame.png new file mode 100644 index 0000000..da0e475 Binary files /dev/null and b/transition_test_frame.png differ diff --git a/venv/bin/Activate.ps1 b/venv/bin/Activate.ps1 new file mode 100644 index 0000000..b49d77b --- /dev/null +++ b/venv/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/venv/bin/activate b/venv/bin/activate new file mode 100644 index 0000000..225d7ab --- /dev/null +++ b/venv/bin/activate @@ -0,0 +1,69 @@ +# This file must be used with "source bin/activate" *from bash* +# you cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # This should detect bash and zsh, which have a hash command that must + # be called to get it to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then + hash -r 2> /dev/null + fi + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +VIRTUAL_ENV=/home/heavygee/coding/slideshow-maker/venv +export VIRTUAL_ENV + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/"bin":$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1='(venv) '"${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT='(venv) ' + export VIRTUAL_ENV_PROMPT +fi + +# This should detect bash and zsh, which have a hash command that must +# be called to get it to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then + hash -r 2> /dev/null +fi diff --git a/venv/bin/activate.csh b/venv/bin/activate.csh new file mode 100644 index 0000000..8522517 --- /dev/null +++ b/venv/bin/activate.csh @@ -0,0 +1,26 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV /home/heavygee/coding/slideshow-maker/venv + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/"bin":$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = '(venv) '"$prompt" + setenv VIRTUAL_ENV_PROMPT '(venv) ' +endif + +alias pydoc python -m pydoc + +rehash diff --git a/venv/bin/activate.fish b/venv/bin/activate.fish new file mode 100644 index 0000000..94ef551 --- /dev/null +++ b/venv/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/); you cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV /home/heavygee/coding/slideshow-maker/venv + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/"bin $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT '(venv) ' +end diff --git a/venv/bin/f2py b/venv/bin/f2py new file mode 100755 index 0000000..5f35a0f --- /dev/null +++ b/venv/bin/f2py @@ -0,0 +1,8 @@ +#!/home/heavygee/coding/slideshow-maker/venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from numpy.f2py.f2py2e import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/venv/bin/numpy-config b/venv/bin/numpy-config new file mode 100755 index 0000000..a11b1c6 --- /dev/null +++ b/venv/bin/numpy-config @@ -0,0 +1,8 @@ +#!/home/heavygee/coding/slideshow-maker/venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from numpy._configtool import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/venv/bin/pip b/venv/bin/pip new file mode 100755 index 0000000..e1365f8 --- /dev/null +++ b/venv/bin/pip @@ -0,0 +1,8 @@ +#!/home/heavygee/coding/slideshow-maker/venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/venv/bin/pip3 b/venv/bin/pip3 new file mode 100755 index 0000000..e1365f8 --- /dev/null +++ b/venv/bin/pip3 @@ -0,0 +1,8 @@ +#!/home/heavygee/coding/slideshow-maker/venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/venv/bin/pip3.11 b/venv/bin/pip3.11 new file mode 100755 index 0000000..e1365f8 --- /dev/null +++ b/venv/bin/pip3.11 @@ -0,0 +1,8 @@ +#!/home/heavygee/coding/slideshow-maker/venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/venv/bin/python b/venv/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/venv/bin/python3 b/venv/bin/python3 new file mode 120000 index 0000000..ae65fda --- /dev/null +++ b/venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/venv/bin/python3.11 b/venv/bin/python3.11 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/venv/bin/python3.11 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/venv/lib64 b/venv/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/venv/pyvenv.cfg b/venv/pyvenv.cfg new file mode 100644 index 0000000..dca571e --- /dev/null +++ b/venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.11.2 +executable = /usr/bin/python3.11 +command = /usr/bin/python3 -m venv /home/heavygee/coding/slideshow-maker/venv diff --git a/verify_transitions.py b/verify_transitions.py new file mode 100644 index 0000000..7959cd8 --- /dev/null +++ b/verify_transitions.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Visual verification system for slideshow transitions. +Creates test videos with clearly different colors and analyzes frames using SSIM +to detect smooth transitions vs hard cuts. +""" + +import os +import subprocess +import tempfile +import shutil +from pathlib import Path +import cv2 +import numpy as np + +def create_color_test_videos(): + """Create test videos with solid colors for transition verification.""" + temp_dir = tempfile.mkdtemp(prefix="transition_test_") + + # Create solid color images + colors = [ + ("red", "FF0000"), + ("blue", "0000FF"), + ("green", "00FF00"), + ("yellow", "FFFF00") + ] + + images = [] + for color_name, color_hex in colors: + img_path = os.path.join(temp_dir, f"{color_name}.png") + # Create 320x240 solid color image + cmd = f'ffmpeg -y -f lavfi -i "color=0x{color_hex}:size=320x240:duration=1" -frames:v 1 "{img_path}"' + subprocess.run(cmd, shell=True, capture_output=True) + images.append(img_path) + + return temp_dir, images + +def classify_transition(video_path, t0_seconds, window_seconds=1.0, fps=30.0, + ssim_jump=0.25, smooth_run_frames=4): + """ + Returns ('cut'|'smooth'|'unknown', details) + Logic: sample frames around t0 at constant fps, compute 1-SSIM for consecutive frames, + then measure how many consecutive frames exceed ssim_jump around the peak near t0. + Short run (<=2) => hard cut. Longer run (>= smooth_run_frames) => smooth transition. + """ + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + return 'unknown', 'cannot open video' + + # Helper: SSIM for grayscale frames + def ssim01(a, b): + a = a.astype(np.float32); b = b.astype(np.float32) + C1, C2 = (0.01*255)**2, (0.03*255)**2 + mu_a = cv2.GaussianBlur(a, (11,11), 1.5) + mu_b = cv2.GaussianBlur(b, (11,11), 1.5) + mu_a2, mu_b2, mu_ab = mu_a*mu_a, mu_b*mu_b, mu_a*mu_b + sigma_a2 = cv2.GaussianBlur(a*a, (11,11), 1.5) - mu_a2 + sigma_b2 = cv2.GaussianBlur(b*b, (11,11), 1.5) - mu_b2 + sigma_ab = cv2.GaussianBlur(a*b, (11,11), 1.5) - mu_ab + ssim = ((2*mu_ab + C1)*(2*sigma_ab + C2)) / ((mu_a2 + mu_b2 + C1)*(sigma_a2 + sigma_b2 + C2) + 1e-9) + return float(ssim.mean()) + + # Build timestamps to sample + half = window_seconds / 2.0 + start = max(0.0, t0_seconds - half) + n = int(window_seconds * fps) + 2 + times = [start + i/fps for i in range(n)] + + frames = [] + for ts in times: + cap.set(cv2.CAP_PROP_POS_MSEC, ts*1000.0) + ok, frame = cap.read() + if not ok: + continue + # Downscale and grayscale for robustness/speed + frame = cv2.resize(frame, (160, int(160*frame.shape[0]/frame.shape[1])), interpolation=cv2.INTER_AREA) + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + frames.append((ts, gray)) + cap.release() + + if len(frames) < 3: + return 'unknown', 'insufficient frames' + + # 1-SSIM between consecutive frames + diffs = [] + for i in range(len(frames)-1): + s = ssim01(frames[i][1], frames[i+1][1]) + diffs.append((frames[i+1][0], 1.0 - s)) + + if not diffs: + return 'unknown', 'no diffs' + + # Find the local peak nearest t0 + peak_idx = min(range(len(diffs)), key=lambda i: abs(diffs[i][0] - t0_seconds)) + # Count contiguous frames above threshold around that peak + def run_length(center): + rl = 1 + # left + j = center - 1 + while j >= 0 and diffs[j][1] > ssim_jump: rl += 1; j -= 1 + # right + j = center + 1 + while j < len(diffs) and diffs[j][1] > ssim_jump: rl += 1; j += 1 + return rl + + rl = run_length(peak_idx) + peak_val = diffs[peak_idx][1] + + if rl <= 2: + return 'cut', f'peak 1-SSIM={peak_val:.3f}, run={rl} frames' + elif rl >= smooth_run_frames: + return 'smooth', f'peak 1-SSIM={peak_val:.3f}, run={rl} frames' + else: + return 'unknown', f'borderline: peak 1-SSIM={peak_val:.3f}, run={rl} frames' + +def test_transitions_have_smooth_changes(video_path, expected_transitions=3): + """ + Analyze video frames to detect if transitions are smooth (gradual color changes) + rather than hard cuts (sudden color changes). + """ + if not os.path.exists(video_path): + return False, "Video file does not exist" + + # Get video duration + probe_result = subprocess.run(f'ffprobe -v quiet -print_format json -show_format "{video_path}"', + shell=True, capture_output=True, text=True) + + if probe_result.returncode != 0: + return False, "Could not get video duration" + + import json + data = json.loads(probe_result.stdout) + duration = float(data['format']['duration']) + + # Check for transitions at expected intervals + transition_times = [] + segment_duration = duration / (expected_transitions + 1) + for i in range(expected_transitions): + transition_times.append(segment_duration * (i + 1)) + + smooth_count = 0 + cut_count = 0 + + for t0 in transition_times: + transition_type, details = classify_transition(video_path, t0) + print(f" Transition at {t0:.1f}s: {transition_type.upper()} - {details}") + + if transition_type == 'smooth': + smooth_count += 1 + elif transition_type == 'cut': + cut_count += 1 + + # If we have more smooth transitions than cuts, consider it working + if smooth_count > cut_count: + return True, f"{smooth_count}/{expected_transitions} smooth transitions detected" + else: + return False, f"Only {smooth_count}/{expected_transitions} smooth transitions, {cut_count} hard cuts" + +if __name__ == "__main__": + print("๐ŸŽฏ Transition Visual Verification System") + print("This will create test videos and verify transitions are actually smooth") + + # Test our transition system + temp_dir, images = create_color_test_videos() + + try: + # Import and test our slideshow creator + import sys + sys.path.insert(0, '/home/heavygee/coding/slideshow-maker/src') + from slideshow_maker.video import create_slideshow + + output = os.path.join(temp_dir, "transition_test.mp4") + success = create_slideshow(images[:3], output, min_duration=1.0, max_duration=1.5) + + if success and os.path.exists(output): + print(f"โœ… Video created: {output}") + + # Analyze the result + has_transitions, message = test_transitions_have_smooth_changes(output) + print(f"๐ŸŽญ Transition analysis: {'PASS' if has_transitions else 'FAIL'} - {message}") + + if not has_transitions: + print("โŒ VERIFICATION FAILED: Transitions appear to be hard cuts!") + print(" The system is falling back to hard cuts when transitions fail.") + print(" This means our 'smooth transition' claims are misleading.") + print(f" Keeping video for manual inspection: {output}") + + # Don't cleanup so we can inspect the video + temp_dir = None + else: + print("โœ… VERIFICATION PASSED: Smooth transitions detected!") + else: + print("โŒ Video creation failed") + + finally: + if temp_dir: + # Cleanup + shutil.rmtree(temp_dir) + print(f"๐Ÿงน Cleaned up {temp_dir}") diff --git a/visual_test/demo_input.png b/visual_test/demo_input.png new file mode 100644 index 0000000..fe618be Binary files /dev/null and b/visual_test/demo_input.png differ diff --git a/visual_test/demo_output.png b/visual_test/demo_output.png new file mode 100644 index 0000000..f47f8cf Binary files /dev/null and b/visual_test/demo_output.png differ diff --git "a/vrchat_demo/Classic House Grooves & Good Vibes \357\275\234 Chill & Dance Beats \357\275\234 From Malarkey to Sugar Hill & New Order [5pu5ZGFWZ98].mp3" "b/vrchat_demo/Classic House Grooves & Good Vibes \357\275\234 Chill & Dance Beats \357\275\234 From Malarkey to Sugar Hill & New Order [5pu5ZGFWZ98].mp3" new file mode 100644 index 0000000..68a1449 Binary files /dev/null and "b/vrchat_demo/Classic House Grooves & Good Vibes \357\275\234 Chill & Dance Beats \357\275\234 From Malarkey to Sugar Hill & New Order [5pu5ZGFWZ98].mp3" differ diff --git a/vrchat_demo/VRChat_2025-03-22_21-23-53.265_3840x2160.png b/vrchat_demo/VRChat_2025-03-22_21-23-53.265_3840x2160.png new file mode 100644 index 0000000..ece504b Binary files /dev/null and b/vrchat_demo/VRChat_2025-03-22_21-23-53.265_3840x2160.png differ diff --git a/vrchat_demo/VRChat_2025-04-05_23-22-35.429_3840x2160.png b/vrchat_demo/VRChat_2025-04-05_23-22-35.429_3840x2160.png new file mode 100644 index 0000000..beff939 Binary files /dev/null and b/vrchat_demo/VRChat_2025-04-05_23-22-35.429_3840x2160.png differ diff --git a/vrchat_demo/VRChat_2025-04-18_20-57-40.923_3840x2160.png b/vrchat_demo/VRChat_2025-04-18_20-57-40.923_3840x2160.png new file mode 100644 index 0000000..24e0e3b Binary files /dev/null and b/vrchat_demo/VRChat_2025-04-18_20-57-40.923_3840x2160.png differ diff --git a/vrchat_demo/VRChat_2025-05-04_16-12-19.518_3840x2160.png b/vrchat_demo/VRChat_2025-05-04_16-12-19.518_3840x2160.png new file mode 100644 index 0000000..764b93e Binary files /dev/null and b/vrchat_demo/VRChat_2025-05-04_16-12-19.518_3840x2160.png differ diff --git a/vrchat_demo/VRChat_2025-05-10_20-58-43.966_3840x2160.png b/vrchat_demo/VRChat_2025-05-10_20-58-43.966_3840x2160.png new file mode 100644 index 0000000..eaeb303 Binary files /dev/null and b/vrchat_demo/VRChat_2025-05-10_20-58-43.966_3840x2160.png differ diff --git a/vrchat_demo/VRChat_2025-07-05_18-41-03.515_3840x2160.png b/vrchat_demo/VRChat_2025-07-05_18-41-03.515_3840x2160.png new file mode 100644 index 0000000..8c0bca2 Binary files /dev/null and b/vrchat_demo/VRChat_2025-07-05_18-41-03.515_3840x2160.png differ diff --git a/vrchat_demo/VRChat_2025-07-17_18-02-01.610_3840x2160.png b/vrchat_demo/VRChat_2025-07-17_18-02-01.610_3840x2160.png new file mode 100644 index 0000000..aec4783 Binary files /dev/null and b/vrchat_demo/VRChat_2025-07-17_18-02-01.610_3840x2160.png differ diff --git a/vrchat_demo/VRChat_2025-08-23_22-05-28.966_3840x2160.png b/vrchat_demo/VRChat_2025-08-23_22-05-28.966_3840x2160.png new file mode 100644 index 0000000..75499d4 Binary files /dev/null and b/vrchat_demo/VRChat_2025-08-23_22-05-28.966_3840x2160.png differ diff --git a/vrchat_demo/demo_input.png b/vrchat_demo/demo_input.png new file mode 100644 index 0000000..fe618be Binary files /dev/null and b/vrchat_demo/demo_input.png differ diff --git a/vrchat_demo/demo_output.png b/vrchat_demo/demo_output.png new file mode 100644 index 0000000..f47f8cf Binary files /dev/null and b/vrchat_demo/demo_output.png differ diff --git a/workspace_short4/complex_input.png b/workspace_short4/complex_input.png new file mode 100644 index 0000000..e332794 Binary files /dev/null and b/workspace_short4/complex_input.png differ diff --git a/workspace_short4/demo_input.png b/workspace_short4/demo_input.png new file mode 100644 index 0000000..fe618be Binary files /dev/null and b/workspace_short4/demo_input.png differ diff --git a/workspace_short4/demo_output.png b/workspace_short4/demo_output.png new file mode 100644 index 0000000..f47f8cf Binary files /dev/null and b/workspace_short4/demo_output.png differ diff --git a/workspace_short4/short_test.mp3 b/workspace_short4/short_test.mp3 new file mode 100644 index 0000000..14d4cd3 Binary files /dev/null and b/workspace_short4/short_test.mp3 differ