| import os |
| import subprocess |
| import tempfile |
|
|
| def _parse_bool(val, default=False): |
| if val is None: |
| return default |
| if isinstance(val, bool): |
| return val |
| if isinstance(val, str): |
| return val.lower() in ("true", "1", "yes", "on") |
| return bool(val) |
|
|
| def crop_to_aspect_ratio(clip, target_w, target_h): |
| """ |
| Crops a video clip from its center to match the target aspect ratio, |
| then resizes it to the target dimensions. |
| (Kept for any remaining moviepy usage, but concatenate_clips no longer uses this.) |
| """ |
| from moviepy.editor import VideoFileClip |
| w, h = clip.size |
| target_aspect = target_w / target_h |
| current_aspect = w / h |
| |
| if current_aspect > target_aspect: |
| new_w = int(h * target_aspect) |
| x1 = (w - new_w) // 2 |
| x2 = x1 + new_w |
| clip_cropped = clip.crop(x1=x1, y1=0, x2=x2, y2=h) |
| else: |
| new_h = int(w / target_aspect) |
| y1 = (h - new_h) // 2 |
| y2 = y1 + new_h |
| clip_cropped = clip.crop(x1=0, y1=y1, x2=w, y2=y2) |
| |
| return clip_cropped.resize(newsize=(target_w, target_h)) |
|
|
| def concatenate_clips(video_paths, aspect_ratio="vertical", output_path=None): |
| """ |
| Concatenates multiple video files using pure FFmpeg (no moviepy). |
| Each clip is normalized to the target resolution before joining. |
| Returns the total duration in seconds. |
| """ |
| if not video_paths: |
| raise ValueError("No video paths provided to concatenate.") |
|
|
| existing = [p for p in video_paths if os.path.exists(p) and os.path.getsize(p) > 0] |
| if not existing: |
| raise ValueError("None of the provided video paths exist or could be loaded.") |
|
|
| target_w, target_h = (1080, 1920) if aspect_ratio == "vertical" else (1920, 1080) |
|
|
| |
| norm_dir = tempfile.mkdtemp(prefix="concat_norm_") |
| normalized = [] |
|
|
| for i, path in enumerate(existing): |
| norm_path = os.path.join(norm_dir, f"norm_{i}.mp4") |
| |
| vf = ( |
| f"scale={target_w}:{target_h}:force_original_aspect_ratio=decrease," |
| f"pad={target_w}:{target_h}:-1:-1:color=black," |
| f"fps=30" |
| ) |
| cmd = [ |
| "ffmpeg", "-y", "-i", path, |
| "-vf", vf, |
| "-c:v", "libx264", "-preset", "fast", |
| "-c:a", "aac", "-ar", "44100", "-ac", "2", |
| norm_path |
| ] |
| r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| if r.returncode == 0 and os.path.exists(norm_path) and os.path.getsize(norm_path) > 0: |
| normalized.append(norm_path) |
| else: |
| print(f"[concat] Warning: failed to normalize {path}, skipping.") |
|
|
| if not normalized: |
| raise RuntimeError("All clips failed to normalize β cannot concatenate.") |
|
|
| |
| concat_list = os.path.join(norm_dir, "concat.txt") |
| with open(concat_list, "w") as f: |
| for p in normalized: |
| f.write(f"file '{p}'\n") |
|
|
| |
| total_duration = 0.0 |
| for p in normalized: |
| probe = subprocess.run( |
| ["ffprobe", "-v", "error", "-show_entries", "format=duration", |
| "-of", "default=noprint_wrappers=1:nokey=1", p], |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE |
| ) |
| try: |
| total_duration += float(probe.stdout.strip()) |
| except Exception: |
| pass |
|
|
| |
| if output_path: |
| cmd = [ |
| "ffmpeg", "-y", |
| "-f", "concat", "-safe", "0", "-i", concat_list, |
| "-c", "copy", |
| output_path |
| ] |
| r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| if r.returncode != 0 or not os.path.exists(output_path) or os.path.getsize(output_path) == 0: |
| err = r.stderr.decode("utf-8", errors="ignore")[-500:] |
| raise RuntimeError(f"FFmpeg concat failed: {err}") |
|
|
| |
| for p in normalized: |
| try: |
| os.remove(p) |
| except Exception: |
| pass |
| try: |
| os.remove(concat_list) |
| os.rmdir(norm_dir) |
| except Exception: |
| pass |
|
|
| return total_duration |
|
|
|
|
| def pitch_shift_audio(input_audio_path, output_audio_path, semitones=0.8): |
| """ |
| Pitch shifts an audio file using FFmpeg's asetrate and atempo filters. |
| semitones: shift amount (positive = higher, negative = lower). |
| """ |
| multiplier = 2.0 ** (semitones / 12.0) |
| sample_rate = 44100 |
| new_rate = int(sample_rate * multiplier) |
| tempo = 1.0 / multiplier |
| |
| cmd = [ |
| "ffmpeg", "-y", "-i", input_audio_path, |
| "-filter_complex", f"asetrate={new_rate},atempo={tempo}", |
| output_audio_path |
| ] |
| subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) |
|
|
| def apply_copyright_filters(input_path, output_path, options): |
| """ |
| Applies visual and audio transformations to bypass copyright filters. |
| Uses pure FFmpeg subprocess for reliability β no moviepy silent failures. |
| """ |
| aspect = options.get("aspect_ratio", "original") |
| mirror_mode = options.get("mirror_mode", "horizontal") |
| color_filter = options.get("color_filter", "cinematic") |
| do_zoom = _parse_bool(options.get("zoom"), True) |
| vignette = _parse_bool(options.get("vignette"), False) |
| noise_grain = _parse_bool(options.get("noise_grain"), False) |
| text_overlay = options.get("text_overlay", "") |
| subtitle_path = options.get("subtitle_file_path") |
|
|
| speed_factor = float(options.get("speed", 1.04)) |
| pitch_semi = float(options.get("pitch_shift", 0.8)) |
|
|
| border_color = options.get("border_color", "none") |
| try: |
| border_width = int(options.get("border_width", 0)) |
| except ValueError: |
| border_width = 0 |
|
|
| |
| probe = subprocess.run( |
| ["ffprobe", "-v", "error", "-select_streams", "a:0", |
| "-show_entries", "stream=codec_name", |
| "-of", "default=noprint_wrappers=1:nokey=1", input_path], |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE |
| ) |
| has_audio = bool(probe.stdout.strip()) |
|
|
| |
| vf = [] |
|
|
| if aspect == "vertical": |
| |
| vf.append("scale=1080:1920:force_original_aspect_ratio=decrease," |
| "pad=1080:1920:-1:-1:color=black") |
| elif aspect == "horizontal": |
| vf.append("scale=1920:1080:force_original_aspect_ratio=decrease," |
| "pad=1920:1080:-1:-1:color=black") |
|
|
| if border_color != "none" and border_width > 0: |
| |
| vf.append(f"pad=iw+2*{border_width}:ih+2*{border_width}:{border_width}:{border_width}:color={border_color}") |
|
|
| |
| if mirror_mode == "horizontal": |
| vf.append("hflip") |
| elif mirror_mode == "vertical": |
| vf.append("vflip") |
| elif mirror_mode == "both": |
| vf.append("hflip,vflip") |
|
|
| if do_zoom: |
| |
| vf.append("scale=iw*1.05:ih*1.05,crop=iw/1.05:ih/1.05") |
|
|
| |
| if color_filter == "cinematic": |
| vf.append("eq=contrast=1.15:brightness=0.03:saturation=1.2") |
| elif color_filter == "vibrant": |
| vf.append("eq=saturation=1.35:contrast=1.05") |
| elif color_filter == "beautify": |
| vf.append("eq=brightness=0.05:contrast=1.05:saturation=1.15,unsharp=3:3:0.5:3:3:0.5") |
| elif color_filter == "warm": |
| vf.append("colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131:0") |
| elif color_filter == "cool": |
| vf.append("colorchannelmixer=.3:.3:.3:0:.3:.3:.3:0:.8:.8:.8:0") |
| elif color_filter == "vintage": |
| vf.append("colorchannelmixer=.393:.769:.189:0:.349:.686:.168:0:.272:.534:.131:0") |
| elif color_filter == "grayscale": |
| vf.append("colorchannelmixer=.3:.59:.11:0:.3:.59:.11:0:.3:.59:.11:0") |
| elif color_filter == "dramatic": |
| vf.append("eq=contrast=1.3:brightness=-0.02:saturation=0.6") |
| elif color_filter == "faded": |
| vf.append("eq=contrast=0.85:brightness=0.08:saturation=0.9") |
|
|
| |
| if vignette: |
| vf.append("vignette=PI/5") |
|
|
| |
| if noise_grain: |
| vf.append("noise=alls=3:allf=t") |
|
|
| |
| if subtitle_path and os.path.exists(subtitle_path): |
| escaped_sub_path = subtitle_path.replace("'", "'\\''").replace(":", "\\:") |
| vf.append(f"subtitles='{escaped_sub_path}':force_style='FontSize=20,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1'") |
|
|
| |
| if text_overlay: |
| |
| drawtext_available = False |
| try: |
| filters_probe = subprocess.run(["ffmpeg", "-filters"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) |
| if "drawtext" in filters_probe.stdout: |
| drawtext_available = True |
| except Exception: |
| pass |
|
|
| if drawtext_available: |
| import re |
| |
| clean_text = re.sub(r"[^a-zA-Z0-9\s.,!?-]", "", text_overlay) |
| clean_text = clean_text.replace("'", "'\\''").replace(":", "\\:") |
| |
| |
| import glob |
| fontfile_param = "" |
| |
| linux_fonts = glob.glob("/usr/share/fonts/**/*.ttf", recursive=True) + \ |
| glob.glob("/usr/share/fonts/**/*.ttc", recursive=True) |
| if linux_fonts: |
| escaped_p = linux_fonts[0].replace("'", "'\\''").replace(":", "\\:") |
| fontfile_param = f":fontfile='{escaped_p}'" |
| else: |
| font_paths = [ |
| "/System/Library/Fonts/Helvetica.ttc", |
| "/Library/Fonts/Arial.ttf", |
| "/System/Library/Fonts/Supplemental/Arial.ttf", |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", |
| "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf" |
| ] |
| for p in font_paths: |
| if os.path.exists(p): |
| escaped_p = p.replace("'", "'\\''").replace(":", "\\:") |
| fontfile_param = f":fontfile='{escaped_p}'" |
| break |
| vf.append(f"drawtext=text='{clean_text}'{fontfile_param}:x=(w-text_w)/2:y=100:fontsize=36:fontcolor=white:box=1:boxcolor=black@0.4:boxborderw=6") |
| else: |
| print("[video_effects] Warning: 'drawtext' filter is not supported by this FFmpeg build. Skipping text overlay.") |
|
|
| if speed_factor != 1.0: |
| vf.append(f"setpts=PTS/{speed_factor:.4f}") |
|
|
|
|
| |
| af = [] |
|
|
| if has_audio: |
| if speed_factor != 1.0: |
| af.append(f"atempo={speed_factor:.4f}") |
|
|
| if pitch_semi != 0.0: |
| |
| multiplier = 2.0 ** (pitch_semi / 12.0) |
| new_rate = int(44100 * multiplier) |
| tempo_corr = 1.0 / multiplier |
| af.append(f"asetrate={new_rate},atempo={tempo_corr:.6f}") |
|
|
| |
| cmd = ["ffmpeg", "-y", "-i", input_path] |
|
|
| if vf: |
| cmd += ["-vf", ",".join(vf)] |
|
|
| cmd += ["-c:v", "libx264", "-preset", "fast", "-movflags", "+faststart"] |
|
|
| if has_audio: |
| if af: |
| cmd += ["-af", ",".join(af)] |
| cmd += ["-c:a", "aac"] |
| else: |
| cmd += ["-an"] |
|
|
| cmd.append(output_path) |
|
|
| |
| result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
|
|
| if result.returncode != 0 or not os.path.exists(output_path) or os.path.getsize(output_path) == 0: |
| err = result.stderr.decode("utf-8", errors="ignore")[-600:] |
| raise RuntimeError(f"FFmpeg filter failed for {os.path.basename(input_path)}: {err}") |
|
|
|
|
| def slice_video(input_path, output_dir, mode="auto", intervals=8, custom_ranges=None): |
| """ |
| Slices a video into multiple segments. |
| mode: "auto" (split every N seconds) or "timestamps" (split by custom list of ranges). |
| intervals: number of seconds per slice in auto mode. |
| custom_ranges: list of tuples/lists e.g. [[10, 20], [35, 45]] (in seconds). |
| Returns a list of created file paths. |
| """ |
| duration = 0.0 |
| try: |
| cmd = [ |
| "ffprobe", "-v", "error", "-show_entries", "format=duration", |
| "-of", "default=noprint_wrappers=1:nokey=1", input_path |
| ] |
| res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) |
| if res.returncode == 0: |
| duration = float(res.stdout.strip()) |
| except Exception as e: |
| print(f"[slice_video] ffprobe failed to get duration: {e}") |
|
|
| if duration <= 0.0: |
| try: |
| from moviepy.editor import VideoFileClip |
| clip = VideoFileClip(input_path) |
| duration = clip.duration |
| clip.close() |
| except Exception as e: |
| print(f"[slice_video] moviepy fallback also failed: {e}") |
| raise RuntimeError("Could not determine video duration.") |
| |
| slices = [] |
| if mode == "auto": |
| start = 0 |
| idx = 1 |
| while start < duration: |
| end = min(start + intervals, duration) |
| |
| if duration - end < 1.0: |
| end = duration |
| slices.append((start, end, f"clip_{idx}.mp4")) |
| idx += 1 |
| if end == duration: |
| break |
| start = end |
| elif mode == "timestamps" and custom_ranges: |
| for idx, r in enumerate(custom_ranges, 1): |
| start = r[0] |
| end = min(r[1], duration) |
| if start < duration: |
| slices.append((start, end, f"clip_{idx}.mp4")) |
| |
| output_files = [] |
| for start, end, filename in slices: |
| out_path = os.path.join(output_dir, filename) |
| |
| cmd = [ |
| "ffmpeg", "-y", "-ss", str(start), "-to", str(end), |
| "-i", input_path, "-c", "copy", out_path |
| ] |
| subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| if os.path.exists(out_path) and os.path.getsize(out_path) > 0: |
| output_files.append(out_path) |
| |
| return output_files |
|
|