From 09684ac5b5173f312d59b780e03c3ad5f1bf25e9 Mon Sep 17 00:00:00 2001 From: Christopher Konopka Date: Sat, 22 Nov 2025 14:42:28 -0500 Subject: [PATCH] added lagkage and updating --- docs/docs-lagkage-examples.txt | 402 ++++++++++++++++++ lagkage_all_layouts_zoomcrop_examples.zip | Bin 0 -> 4123 bytes lagkage_examples_zoomcrop.txt | 88 ++++ lagkage_layouts/layout_big_6.json | 93 ++++ lagkage_layouts/layout_crop_zoom_gif.json | 34 ++ .../layout_cropped_gif_corner.json | 31 ++ .../layout_ex01_logo_corner_zoom.json | 16 + .../layout_ex02_gif_zoomcrop_sticker.json | 21 + .../layout_ex03_dual_pip_zoom_right.json | 28 ++ .../layout_ex04_lower_third_crop_band.json | 20 + .../layout_ex05_mask_crop_zoom_center.json | 20 + .../layout_ex06_freeform_spray_zoom.json | 39 ++ .../layout_ex07_logo_grid_zoom_center.json | 56 +++ .../layout_ex08_story_mode_zoom.json | 37 ++ .../layout_ex09_split_screen_zoom_left.json | 32 ++ .../layout_ex10_full_showcase_zoomcrop.json | 76 ++++ lagkage_layouts/layout_freeform_showcase.json | 113 +++++ .../layout_full_package_showcase.json | 106 +++++ lagkage_layouts/layout_grid_3up.json | 52 +++ lagkage_layouts/layout_grid_4up.json | 53 +++ .../layout_logo_scatter_zoom_center.json | 85 ++++ lagkage_layouts/layout_logo_wall.json | 73 ++++ lagkage_layouts/layout_minimal.json | 28 ++ lagkage_layouts/layout_minimal_zoomcrop.json | 33 ++ lagkage_layouts/layout_pip_dual.json | 52 +++ lagkage_layouts/layout_random_spray.json | 64 +++ .../layout_splitscreen_zoomcrop.json | 34 ++ lagkage_layouts/layout_stickers.json | 53 +++ lagkage_layouts/layout_story_mode.json | 51 +++ lagkage_layouts/layout_vhs_heavy.json | 41 ++ lagkage_layouts/layout_zoomed_pip.json | 28 ++ videobeaux/programs/lagkage.py | 393 ++++++++++------- 32 files changed, 2103 insertions(+), 149 deletions(-) create mode 100644 docs/docs-lagkage-examples.txt create mode 100644 lagkage_all_layouts_zoomcrop_examples.zip create mode 100644 lagkage_examples_zoomcrop.txt create mode 100644 lagkage_layouts/layout_big_6.json create mode 100644 lagkage_layouts/layout_crop_zoom_gif.json create mode 100644 lagkage_layouts/layout_cropped_gif_corner.json create mode 100644 lagkage_layouts/layout_ex01_logo_corner_zoom.json create mode 100644 lagkage_layouts/layout_ex02_gif_zoomcrop_sticker.json create mode 100644 lagkage_layouts/layout_ex03_dual_pip_zoom_right.json create mode 100644 lagkage_layouts/layout_ex04_lower_third_crop_band.json create mode 100644 lagkage_layouts/layout_ex05_mask_crop_zoom_center.json create mode 100644 lagkage_layouts/layout_ex06_freeform_spray_zoom.json create mode 100644 lagkage_layouts/layout_ex07_logo_grid_zoom_center.json create mode 100644 lagkage_layouts/layout_ex08_story_mode_zoom.json create mode 100644 lagkage_layouts/layout_ex09_split_screen_zoom_left.json create mode 100644 lagkage_layouts/layout_ex10_full_showcase_zoomcrop.json create mode 100644 lagkage_layouts/layout_freeform_showcase.json create mode 100644 lagkage_layouts/layout_full_package_showcase.json create mode 100644 lagkage_layouts/layout_grid_3up.json create mode 100644 lagkage_layouts/layout_grid_4up.json create mode 100644 lagkage_layouts/layout_logo_scatter_zoom_center.json create mode 100644 lagkage_layouts/layout_logo_wall.json create mode 100644 lagkage_layouts/layout_minimal.json create mode 100644 lagkage_layouts/layout_minimal_zoomcrop.json create mode 100644 lagkage_layouts/layout_pip_dual.json create mode 100644 lagkage_layouts/layout_random_spray.json create mode 100644 lagkage_layouts/layout_splitscreen_zoomcrop.json create mode 100644 lagkage_layouts/layout_stickers.json create mode 100644 lagkage_layouts/layout_story_mode.json create mode 100644 lagkage_layouts/layout_vhs_heavy.json create mode 100644 lagkage_layouts/layout_zoomed_pip.json diff --git a/docs/docs-lagkage-examples.txt b/docs/docs-lagkage-examples.txt new file mode 100644 index 0000000..2b68aa3 --- /dev/null +++ b/docs/docs-lagkage-examples.txt @@ -0,0 +1,402 @@ +LAGKAGE – CORE FUNCTIONALITY EXAMPLES (v2) +======================================== + +Program: + lagkage (Videobeaux) + +Base usage pattern: + videobeaux -P lagkage -i media/base.mp4 -o out/NAME.mp4 --layout-json lagkage_layouts/LAYOUT.json --force + +This file contains 20+ examples demonstrating: +- place vs free positioning +- images / videos / GIFs +- sequence_direction variations +- per-layer zoom +- per-layer crop +- combinations of the above + + +------------------------------------------------------------ +EXAMPLE 01 – Minimal logo + sticker (place + free) +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_minimal.json + +Description: + - Top-right logo bug using place mode + - Free-position looping GIF sticker at lower-left area + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex01_minimal_logo_sticker.mp4 \ + --layout-json lagkage_layouts/layout_minimal.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 02 – Same layout, different base +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_minimal.json + +Description: + - Reuses the same layout with a different base video (e.g. a show cut) + +Command: + videobeaux -P lagkage -i media/base_show.mp4 \ + -o out/ex02_minimal_logo_sticker_show.mp4 \ + --layout-json lagkage_layouts/layout_minimal.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 03 – Dual PIP + logo + lower third +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_pip_dual.json + +Description: + - Logo in top-left + - Left and right PIP cameras using free mode + - Image lower-third band + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex03_pip_dual_show.mp4 \ + --layout-json lagkage_layouts/layout_pip_dual.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 04 – Dual PIP, backward stacking +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_pip_dual.json + (Edit JSON: "sequence_direction": "backward") + +Description: + - Same positions as Example 03 + - Backward stacking puts higher layer_number on top + - Good for making sure logo always ends above other overlays + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex04_pip_dual_backward.mp4 \ + --layout-json lagkage_layouts/layout_pip_dual.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 05 – Stickers + VHS noise overlay +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_stickers.json + +Description: + - Full-frame looping VHS noise (low opacity) + - Circus and bunny GIF stickers + - Floating logo + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex05_stickers_vhs_noise.mp4 \ + --layout-json lagkage_layouts/layout_stickers.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 06 – Freeform showcase (cams, gifs, masks, noise) +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_freeform_showcase.json + +Description: + - Multiple layers: + - top-right logo, top-left screen + - left/right PIP cams, mask on edge, lower band + - circus & bunny GIFs, center VHS noise + - All positioned using free mode coordinates + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex06_freeform_showcase.mp4 \ + --layout-json lagkage_layouts/layout_freeform_showcase.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 07 – 3-up horizontal video grid +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_grid_3up.json + +Description: + - Three video tiles left/center/right + - Small logo in top-left + - Good for side-by-side comparison or triptych views + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex07_grid_3up.mp4 \ + --layout-json lagkage_layouts/layout_grid_3up.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 08 – 4-up grid (quad cam) +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_grid_4up.json + +Description: + - Four camera feeds in a 2x2 grid + - Classic multi-cam surveillance / gallery layout + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex08_grid_4up.mp4 \ + --layout-json lagkage_layouts/layout_grid_4up.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 09 – Heavy VHS wash + mask + logo +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_vhs_heavy.json + +Description: + - VHS noise strongly overlayed (higher opacity) + - Center mask image + - Top-center logo + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex09_vhs_heavy_mask_logo.mp4 \ + --layout-json lagkage_layouts/layout_vhs_heavy.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 10 – Logo wall + mid screens +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_logo_wall.json + +Description: + - Logos in all four corners using place mode + - Two mid-frame screen images using free mode + - Good for brand-heavy overlays + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex10_logo_wall.mp4 \ + --layout-json lagkage_layouts/layout_logo_wall.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 11 – Random spray of gifs + center logo +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_random_spray.json + (sequence_direction set to "random") + +Description: + - Circus and bunny GIFs in four corners/edges + - Logo in center + - Randomized stacking order each run (depending on code behavior) + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex11_random_spray.mp4 \ + --layout-json lagkage_layouts/layout_random_spray.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 12 – Story mode: lower third + mask + tiny PIP +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_story_mode.json + +Description: + - Wide lower-third band defined via free mode + - Side mask element + - Small corner PIP video + logo + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex12_story_mode_package.mp4 \ + --layout-json lagkage_layouts/layout_story_mode.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 13 – Freeform scatter demo +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_freeform_showcase.json + +Description: + - Same freeform layout as Example 06 + - Emphasis on scattered positioning to test boundaries + - Demonstrates partially off-screen elements + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex13_freeform_scatter.mp4 \ + --layout-json lagkage_layouts/layout_freeform_showcase.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 14 – Minimal zoom-only sticker +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_minimal_zoomcrop.json + (Create JSON from README example) + +Description: + - Corner logo + - One GIF sticker with zoom > 1.0 but no crop, so content is pushed in + - Demonstrates per-layer zoom inside fixed box + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex14_minimal_zoom_sticker.mp4 \ + --layout-json lagkage_layouts/layout_minimal_zoomcrop.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 15 – Center-cropped GIF in the corner +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_cropped_gif_corner.json + (GIF layer has crop_x/crop_y/crop_w/crop_h set to central 50%) + +Description: + - Uses only the core of a noisy or busy GIF + - Placed bottom-right as a cropped badge + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex15_cropped_gif_corner.mp4 \ + --layout-json lagkage_layouts/layout_cropped_gif_corner.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 16 – Zoomed-in PIP camera box +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_zoomed_pip.json + (PIP layer has zoom > 1.0) + +Description: + - Small PIP box, but content is zoomed in + - Good for focusing on a region of the camera feed without changing frame size + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex16_zoomed_pip_box.mp4 \ + --layout-json lagkage_layouts/layout_zoomed_pip.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 17 – Combined crop + zoom (GIF) +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_crop_zoom_gif.json + +Description: + - Layer crops to middle 60% of GIF + - Then applies zoom=1.8 inside overlay box + - Great for punching into the “interesting” part of the animation + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex17_crop_zoom_gif.mp4 \ + --layout-json lagkage_layouts/layout_crop_zoom_gif.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 18 – Split-screen: left static, right zoom+crop +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_splitscreen_zoomcrop.json + +Description: + - Left side: standard PIP video + - Right side: same video source but with crop+zoom on a different region + - Demonstrates comparative views of the same source file + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex18_splitscreen_zoomcrop.mp4 \ + --layout-json lagkage_layouts/layout_splitscreen_zoomcrop.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 19 – Multi-logo scatter + zoomed center logo +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_logo_scatter_zoom_center.json + +Description: + - Logos in corners and midpoints using place + free + - Center logo uses zoom to feel larger within a fixed box + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex19_logo_scatter_zoom_center.mp4 \ + --layout-json lagkage_layouts/layout_logo_scatter_zoom_center.json \ + --force + + +------------------------------------------------------------ +EXAMPLE 20 – Full package: PIPs, stickers, crop+zoom, noise +------------------------------------------------------------ + +Layout: + lagkage_layouts/layout_full_package_showcase.json + +Description: + - Combines: + - Base logo + - Dual PIPs + - Multiple GIF stickers (some cropped, some zoomed) + - VHS noise layer at low opacity + - Designed as an “everything on” stress test for lagkage + +Command: + videobeaux -P lagkage -i media/base.mp4 \ + -o out/ex20_full_package_showcase.mp4 \ + --layout-json lagkage_layouts/layout_full_package_showcase.json \ + --force + + +END diff --git a/lagkage_all_layouts_zoomcrop_examples.zip b/lagkage_all_layouts_zoomcrop_examples.zip new file mode 100644 index 0000000000000000000000000000000000000000..bbbee4bc112bc35dc14c5768dda591b0371b3036 GIT binary patch literal 4123 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 int: + """ + Force an integer to be even (needed for some codecs / filters). + """ + return x if x % 2 == 0 else x + 1 def _preprocess_gif(src: str, base_duration: float, tmp_dir: Path, idx: int) -> str: """ - Transcode a GIF to a looping video with alpha that roughly matches - the base duration. + Convert GIF to a looping RGBA video with even dimensions and alpha. + Returns path to the temp video file. - IMPORTANT: - - We force even dimensions so filters/encoders don't choke. - - We use an alpha-capable codec (qtrle in a MOV container), - so transparency is preserved instead of being flattened. + - Loops the GIF for approximately base_duration. + - Forces even width/height. + - Keeps alpha via format=rgba + qtrle. """ tmp_dir.mkdir(parents=True, exist_ok=True) - # Use .mov since qtrle is typically stored in a QuickTime container - temp_path = tmp_dir / f"lagkage_gif_{idx}.mov" + out_path = tmp_dir / f"gif_pre_{idx:03d}.mov" + + vf = "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=rgba" cmd = [ "ffmpeg", "-hide_banner", - "-loglevel", "error", - "-ignore_loop", "0", - "-stream_loop", "-1", - "-i", src, - # Make width/height even and ensure we have RGBA (with alpha) - "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=rgba", - ] - - # If we know the base duration, trim to that; otherwise just loop and - # let the main overlay graph's base video duration cap play-out. - if base_duration > 0: - cmd += ["-t", f"{base_duration:.3f}"] - - cmd += [ - # Alpha-capable codec - "-c:v", "qtrle", - "-an", + "-loglevel", + "error", "-y", - str(temp_path), + "-ignore_loop", + "0", + "-stream_loop", + "-1", + "-i", + src, + "-t", + f"{base_duration:.3f}", + "-vf", + vf, + "-c:v", + "qtrle", + "-an", + str(out_path), ] - proc = subprocess.run(cmd) if proc.returncode != 0: raise RuntimeError(f"GIF preprocess failed for {src} (code {proc.returncode})") - return str(temp_path) + return str(out_path) + + +def _compute_place_coordinates(place: str, base_w: int, base_h: int, + box_w: int, box_h: int): + """ + Compute overlay x,y for place mode, given base and box dimensions. + """ + place = (place or "center").lower() + if place == "top_left": + return 0, 0 + elif place == "top_right": + return base_w - box_w, 0 + elif place == "bottom_left": + return 0, base_h - box_h + elif place == "bottom_right": + return base_w - box_w, base_h - box_h + elif place == "center": + return (base_w - box_w) // 2, (base_h - box_h) // 2 + else: + # Fallback to center for unknown place values + return (base_w - box_w) // 2, (base_h - box_h) // 2 + + +def _compute_overlay_box(base_w: int, src_w: int, src_h: int, size_pct: float): + """ + Compute the final overlay box width/height (even integers) based on: + - base width + - layer source aspect ratio + - size percentage of base width. + """ + if size_pct <= 0: + size_pct = 1.0 + + target_w = int(base_w * (size_pct / 100.0)) + if target_w < 2: + target_w = 2 + target_w = _even(target_w) + + if src_h > 0: + ar = src_w / src_h + target_h = int(target_w / ar) if ar > 0 else target_w + else: + target_h = target_w + + if target_h < 2: + target_h = 2 + target_h = _even(target_h) + + return target_w, target_h + + +# ----------------- main entry ----------------- def run(args): @@ -173,78 +204,147 @@ def run(args): if not layout_path.exists(): raise FileNotFoundError(f"Layout JSON not found: {layout_path}") - layout = _load_layout(layout_path) - seq, ordered_layers = _resolve_sequence(layout, args.sequence_direction) + # Load layout JSON + layout = json.loads(layout_path.read_text()) + layers = layout.get("layers", []) + if not isinstance(layers, list) or not layers: + raise ValueError("Layout JSON has no 'layers' array or it's empty.") + + # Determine sequence direction + seq_dir = args.sequence_direction or layout.get("sequence_direction", "forward") + if seq_dir not in ("forward", "backward", "random"): + seq_dir = "forward" + + # Order layers by layer_number, then adjust sequence if needed + ordered_layers = sorted(layers, key=lambda L: L.get("layer_number", 0)) + if seq_dir == "backward": + ordered_layers = list(reversed(ordered_layers)) + elif seq_dir == "random": + ordered_layers = random.sample(ordered_layers, len(ordered_layers)) base_input = args.input if not base_input: - raise ValueError("Global --input (base video) is required for json_layers.") + raise ValueError("Global --input (base video) is required for lagkage.") - # 1) Probe base video info once + # Probe base base_w, base_h, base_duration = _probe_base_info(base_input) - # 2) Prepare inputs for main ffmpeg call - # index 0: base video - # index 1..N: layer sources (with GIFs preprocessed to MP4) - inputs_for_ffmpeg = [("base", base_input)] - layer_inputs = [] # (layer_dict, input_index, target_width_px) + # Temp dir for GIF preprocess + tmp_dir = Path(tempfile.mkdtemp(prefix="lagkage_gifs_")) - # Folder to hold temp GIF->MP4 files (next to the output file) - tmp_dir = Path(args.output).with_suffix("") + # 1) Build input list for ffmpeg + # input 0 = base, 1..N = overlays + input_files = [base_input] + overlay_specs = [] # (input_index, layer_dict, src_w, src_h) for idx, layer in enumerate(ordered_layers, start=1): filename = layer.get("filename") if not filename: raise ValueError(f"Layer missing 'filename': {layer}") - # Resolve relative to layout JSON file + # Relative paths are relative to the layout JSON directory. if not os.path.isabs(filename): src = str(layout_path.parent / filename) else: src = filename - size_pct = float(layer.get("size", 100)) / 100.0 - target_w = max(1, int(base_w * size_pct)) - layer_type = (layer.get("type") or "").lower() - # If GIF, pre-process to finite-length MP4 that loops to base duration + # If GIF, pre-process to finite-length video that loops to base duration if layer_type == "gif": overlay_src = _preprocess_gif(src, base_duration, tmp_dir, idx) else: overlay_src = src - inputs_for_ffmpeg.append(("video", overlay_src)) - layer_inputs.append((layer, len(inputs_for_ffmpeg) - 1, target_w)) + src_w, src_h = _probe_video_size(overlay_src) - # 3) Build filter_complex + input_files.append(overlay_src) + in_index = len(input_files) - 1 + overlay_specs.append((in_index, layer, src_w, src_h)) + + # 2) Build filter_complex filter_parts = [] - # Base video label is [0:v] directly (like overlay_img_pro) + # Start with base video current_label = "[0:v]" - for idx, (layer, input_index, target_w) in enumerate(layer_inputs, start=1): - lay_in = f"[{input_index}:v]" - lay_alpha = f"[lay{idx}]" + for idx, (in_index, layer, src_w, src_h) in enumerate(overlay_specs, start=1): + size_pct = float(layer.get("size", 100.0)) + opacity = float(layer.get("opacity", 1.0)) + if opacity < 0.0: + opacity = 0.0 + if opacity > 1.0: + opacity = 1.0 + + zoom = float(layer.get("zoom", 1.0)) + if zoom < 1.0: + zoom = 1.0 + + # Compute final overlay box size on the base + box_w, box_h = _compute_overlay_box(base_w, src_w, src_h, size_pct) + + mode = (layer.get("mode") or "place").lower() + + if mode == "free": + x_expr = str(int(layer.get("pos_x", 0))) + y_expr = str(int(layer.get("pos_y", 0))) + else: + place = layer.get("place", "center") + px, py = _compute_place_coordinates(place, base_w, base_h, box_w, box_h) + x_expr, y_expr = str(px), str(py) + + in_label = f"[{in_index}:v]" + layer_label = f"[lay{idx}]" next_label = f"[tmp{idx}]" - opacity = float(layer.get("opacity", 1.0)) - # blend_mode is kept for future use but ignored here - _blend_mode = (layer.get("blend_mode") or "normal").lower() + layer_filters = [] - x_expr, y_expr = _place_expr(layer) + # Optional crop (percent of source, BEFORE zoom/scale) + cx = layer.get("crop_x") + cy = layer.get("crop_y") + cw = layer.get("crop_w") + ch = layer.get("crop_h") + if cx is not None and cy is not None and cw is not None and ch is not None: + try: + cx_f = float(cx) / 100.0 + cy_f = float(cy) / 100.0 + cw_f = float(cw) / 100.0 + ch_f = float(ch) / 100.0 + layer_filters.append( + f"crop=in_w*{cw_f}:in_h*{ch_f}:in_w*{cx_f}:in_h*{cy_f}" + ) + except Exception: + # If parsing fails, skip cropping instead of blowing up. + pass + + # Zoom INSIDE fixed box: + # 1) scale up to zoom * box size + # 2) crop center back down to box_w x box_h + scaled_w = _even(int(box_w * zoom)) + scaled_h = _even(int(box_h * zoom)) + if scaled_w < 2: + scaled_w = 2 + if scaled_h < 2: + scaled_h = 2 + + layer_filters.append(f"scale={scaled_w}:{scaled_h}") + + if zoom > 1.0: + layer_filters.append( + f"crop={box_w}:{box_h}:(iw-{box_w})/2:(ih-{box_h})/2" + ) + + # Force RGBA and apply opacity + layer_filters.append("format=rgba") + layer_filters.append(f"colorchannelmixer=aa={opacity}") - # scale + alpha filter_parts.append( - f"{lay_in}" - f"scale={target_w}:-1," - f"format=rgba,colorchannelmixer=aa={opacity}" - f"{lay_alpha}" + f"{in_label}{','.join(layer_filters)}{layer_label}" ) - # overlay + # Overlay on top of current composite filter_parts.append( - f"{current_label}{lay_alpha}" + f"{current_label}{layer_label}" f"overlay=x={x_expr}:y={y_expr}:format=auto" f"{next_label}" ) @@ -256,20 +356,16 @@ def run(args): filter_complex = ";".join(filter_parts) - # 4) Build main ffmpeg command - command = [ - "ffmpeg", - "-err_detect", "ignore_err", - "-fflags", "+discardcorrupt+genpts", - ] + # 3) Build main ffmpeg command + command = ["ffmpeg", "-hide_banner", "-loglevel", "error"] - for _typ, src in inputs_for_ffmpeg: - command.extend(["-i", src]) + for path in input_files: + command.extend(["-i", path]) command.extend([ "-filter_complex", filter_complex, "-map", out_label, - "-map", "0:a", + "-map", "0:a?", "-c:v", "libx264", "-profile:v", "high", "-level:v", "4.2", @@ -280,5 +376,4 @@ def run(args): ]) final_cmd = (command[:1] + ["-y"] + command[1:]) if args.force else command - run_ffmpeg_with_progress(final_cmd, args.input, args.output)