updated lagkage, docs and examples

This commit is contained in:
cskonopka
2025-11-24 15:33:14 -05:00
parent 21336bfaea
commit 53351a9c87
52 changed files with 2228 additions and 544 deletions

View File

@@ -1,402 +1,179 @@
LAGKAGE CORE FUNCTIONALITY EXAMPLES (v2) 1) Logo in top-right corner
======================================== ----------------------------
videobeaux -P lagkage \
Program: -i media/base.mp4 \
lagkage (Videobeaux) -o out/ex01_layout_ex01_logo_corner.mp4 \
--layout-json lagkage_layouts/layout_ex01_logo_corner.json \
Base usage pattern: --audio-mode base \
videobeaux -P lagkage -i media/base.mp4 -o out/NAME.mp4 --layout-json lagkage_layouts/LAYOUT.json --force --force
This file contains 20+ examples demonstrating: 2) Center GIF sticker
- place vs free positioning ----------------------
- images / videos / GIFs videobeaux -P lagkage \
- sequence_direction variations -i media/base.mp4 \
- per-layer zoom -o out/ex02_layout_ex02_gif_center_sticker.mp4 \
- per-layer crop --layout-json lagkage_layouts/layout_ex02_gif_center_sticker.json \
- combinations of the above --audio-mode base \
--force
------------------------------------------------------------ 3) Dual PIP videos with per-layer gains (all audio)
EXAMPLE 01 Minimal logo + sticker (place + free) ----------------------------------------------------
------------------------------------------------------------ videobeaux -P lagkage \
-i media/base.mp4 \
Layout: -o out/ex03_layout_ex03_dual_pip_videos.mp4 \
lagkage_layouts/layout_minimal.json --layout-json lagkage_layouts/layout_ex03_dual_pip_videos.json \
--audio-mode all \
Description: --force
- Top-right logo bug using place mode
- Free-position looping GIF sticker at lower-left area 4) Lower-third band from image
-------------------------------
Command: videobeaux -P lagkage \
videobeaux -P lagkage -i media/base.mp4 \ -i media/base.mp4 \
-o out/ex01_minimal_logo_sticker.mp4 \ -o out/ex04_layout_ex04_lower_third_band.mp4 \
--layout-json lagkage_layouts/layout_minimal.json \ --layout-json lagkage_layouts/layout_ex04_lower_third_band.json \
--force --audio-mode base \
--force
------------------------------------------------------------ 5) Center zoomed crop PIP
EXAMPLE 02 Same layout, different base --------------------------
------------------------------------------------------------ videobeaux -P lagkage \
-i media/base.mp4 \
Layout: -o out/ex05_layout_ex05_center_zoom_crop_pip.mp4 \
lagkage_layouts/layout_minimal.json --layout-json lagkage_layouts/layout_ex05_center_zoom_crop_pip.json \
--audio-mode all \
Description: --force
- Reuses the same layout with a different base video (e.g. a show cut)
6) Scatter GIFs + logo (random sequence)
Command: -----------------------------------------
videobeaux -P lagkage -i media/base_show.mp4 \ videobeaux -P lagkage \
-o out/ex02_minimal_logo_sticker_show.mp4 \ -i media/base.mp4 \
--layout-json lagkage_layouts/layout_minimal.json \ -o out/ex06_layout_ex06_scatter_gifs_logo.mp4 \
--force --layout-json lagkage_layouts/layout_ex06_scatter_gifs_logo.json \
--audio-mode base \
--force
------------------------------------------------------------
EXAMPLE 03 Dual PIP + logo + lower third 7) Logo grid around edges
------------------------------------------------------------ --------------------------
videobeaux -P lagkage \
Layout: -i media/base.mp4 \
lagkage_layouts/layout_pip_dual.json -o out/ex07_layout_ex07_logo_grid_edges.mp4 \
--layout-json lagkage_layouts/layout_ex07_logo_grid_edges.json \
Description: --audio-mode base \
- Logo in top-left --force
- Left and right PIP cameras using free mode
- Image lower-third band 8) Story mode: band + PIP + logo (external audio)
--------------------------------------------------
Command: videobeaux -P lagkage \
videobeaux -P lagkage -i media/base.mp4 \ -i media/base.mp4 \
-o out/ex03_pip_dual_show.mp4 \ -o out/ex08_layout_ex08_story_mode_external_audio.mp4 \
--layout-json lagkage_layouts/layout_pip_dual.json \ --layout-json lagkage_layouts/layout_ex08_story_mode_external_audio.json \
--force --audio-mode external --audio-src media/music_track.wav \
--force
------------------------------------------------------------ 9) Split screen: left zoomed, right normal (all audio)
EXAMPLE 04 Dual PIP, backward stacking -------------------------------------------------------
------------------------------------------------------------ videobeaux -P lagkage \
-i media/base.mp4 \
Layout: -o out/ex09_layout_ex09_split_screen.mp4 \
lagkage_layouts/layout_pip_dual.json --layout-json lagkage_layouts/layout_ex09_split_screen.json \
(Edit JSON: "sequence_direction": "backward") --audio-mode all \
--force
Description:
- Same positions as Example 03 10) Full showcase: GIFs + videos + logo (all audio)
- Backward stacking puts higher layer_number on top ---------------------------------------------------
- Good for making sure logo always ends above other overlays videobeaux -P lagkage \
-i media/base.mp4 \
Command: -o out/ex10_layout_ex10_full_showcase.mp4 \
videobeaux -P lagkage -i media/base.mp4 \ --layout-json lagkage_layouts/layout_ex10_full_showcase.json \
-o out/ex04_pip_dual_backward.mp4 \ --audio-mode all \
--layout-json lagkage_layouts/layout_pip_dual.json \ --force
--force
11) Silent graphics-only output
-------------------------------
------------------------------------------------------------ videobeaux -P lagkage \
EXAMPLE 05 Stickers + VHS noise overlay -i media/base.mp4 \
------------------------------------------------------------ -o out/ex11_layout_ex11_silent_graphics_only.mp4 \
--layout-json lagkage_layouts/layout_ex11_silent_graphics_only.json \
Layout: --audio-mode none \
lagkage_layouts/layout_stickers.json --force
Description: 12) JSON-only audio with different linear gains
- Full-frame looping VHS noise (low opacity) -----------------------------------------------
- Circus and bunny GIF stickers videobeaux -P lagkage \
- Floating logo -i media/base.mp4 \
-o out/ex12_layout_ex12_json_only_audio_gains.mp4 \
Command: --layout-json lagkage_layouts/layout_ex12_json_only_audio_gains.json \
videobeaux -P lagkage -i media/base.mp4 \ --audio-mode json_only \
-o out/ex05_stickers_vhs_noise.mp4 \ --force
--layout-json lagkage_layouts/layout_stickers.json \
--force 13) Backward sequence stacked logos
-----------------------------------
videobeaux -P lagkage \
------------------------------------------------------------ -i media/base.mp4 \
EXAMPLE 06 Freeform showcase (cams, gifs, masks, noise) -o out/ex13_layout_ex13_backward_stack.mp4 \
------------------------------------------------------------ --layout-json lagkage_layouts/layout_ex13_backward_stack.json \
--audio-mode base \
Layout: --force
lagkage_layouts/layout_freeform_showcase.json
14) Place mode quadrants with different sizes
Description: ---------------------------------------------
- Multiple layers: videobeaux -P lagkage \
- top-right logo, top-left screen -i media/base.mp4 \
- left/right PIP cams, mask on edge, lower band -o out/ex14_layout_ex14_place_quadrants.mp4 \
- circus & bunny GIFs, center VHS noise --layout-json lagkage_layouts/layout_ex14_place_quadrants.json \
- All positioned using free mode coordinates --audio-mode base \
--force
Command:
videobeaux -P lagkage -i media/base.mp4 \ 15) UI-style small stickers around center
-o out/ex06_freeform_showcase.mp4 \ -----------------------------------------
--layout-json lagkage_layouts/layout_freeform_showcase.json \ videobeaux -P lagkage \
--force -i media/base.mp4 \
-o out/ex15_layout_ex15_ui_stickers.mp4 \
--layout-json lagkage_layouts/layout_ex15_ui_stickers.json \
------------------------------------------------------------ --audio-mode base \
EXAMPLE 07 3-up horizontal video grid --force
------------------------------------------------------------
16) Zoom showcase at multiple levels (all audio)
Layout: ------------------------------------------------
lagkage_layouts/layout_grid_3up.json videobeaux -P lagkage \
-i media/base.mp4 \
Description: -o out/ex16_layout_ex16_zoom_showcase.mp4 \
- Three video tiles left/center/right --layout-json lagkage_layouts/layout_ex16_zoom_showcase.json \
- Small logo in top-left --audio-mode all \
- Good for side-by-side comparison or triptych views --force
Command: 17) Crop focus 'face cam' PIP
videobeaux -P lagkage -i media/base.mp4 \ -----------------------------
-o out/ex07_grid_3up.mp4 \ videobeaux -P lagkage \
--layout-json lagkage_layouts/layout_grid_3up.json \ -i media/base.mp4 \
--force -o out/ex17_layout_ex17_crop_focus.mp4 \
--layout-json lagkage_layouts/layout_ex17_crop_focus.json \
--audio-mode all \
------------------------------------------------------------ --force
EXAMPLE 08 4-up grid (quad cam)
------------------------------------------------------------ 18) Off-screen pushes (half visible)
------------------------------------
Layout: videobeaux -P lagkage \
lagkage_layouts/layout_grid_4up.json -i media/base.mp4 \
-o out/ex18_layout_ex18_offscreen_push.mp4 \
Description: --layout-json lagkage_layouts/layout_ex18_offscreen_push.json \
- Four camera feeds in a 2x2 grid --audio-mode base \
- Classic multi-cam surveillance / gallery layout --force
Command: 19) Media path using 'media/' root
videobeaux -P lagkage -i media/base.mp4 \ ----------------------------------
-o out/ex08_grid_4up.mp4 \ videobeaux -P lagkage \
--layout-json lagkage_layouts/layout_grid_4up.json \ -i media/base.mp4 \
--force -o out/ex19_layout_ex19_media_path_root.mp4 \
--layout-json lagkage_layouts/layout_ex19_media_path_root.json \
--audio-mode base \
------------------------------------------------------------ --force
EXAMPLE 09 Heavy VHS wash + mask + logo
------------------------------------------------------------ 20) Many audio clips premixed with different gains
--------------------------------------------------
Layout: videobeaux -P lagkage \
lagkage_layouts/layout_vhs_heavy.json -i media/base.mp4 \
-o out/ex20_layout_ex20_audio_premix.mp4 \
Description: --layout-json lagkage_layouts/layout_ex20_audio_premix.json \
- VHS noise strongly overlayed (higher opacity) --audio-mode all \
- Center mask image --force
- 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

405
docs/docs-lagkage.md Normal file
View File

@@ -0,0 +1,405 @@
# `lagkage` JSONdriven layered compositor for videobeaux
`lagkage` is a videobeaux program that lets you build complex, multilayer video compositions from a **single JSON file**.
You define:
- A **base video** (passed via `-i` as usual)
- A set of **layers** (images, GIFs, or videos) in a JSON layout file
- Perlayer properties: position, size, opacity, zoom, crop, audio gain
- Global behavior: sequence ordering, audio mix mode
`lagkage` then builds an ffmpeg filtergraph that:
- Scales, crops, and blends each layer into the base frame
- Treats GIFs as animated overlays (preprocessed to loop for the base duration)
- Handles audio according to your chosen mode (base/json/all/external/none)
- Optionally applies **perlayer audio gain**, so you can do a rough premix
directly from the JSON without opening a DAW
---
## 1. CLI usage
From your videobeaux project root:
```bash
videobeaux -P lagkage \
-i media/base.mp4 \
-o out/composite.mp4 \
--layout-json lagkage_layouts/layout_ex01_logo_corner.json \
--force
```
### Arguments
- `-i, --input`
Base video to composite onto. **Required**.
- `-o, --output`
Final rendered video. **Required**.
- `--layout-json PATH`
Path to a JSON file that describes all overlay layers. **Required**.
- `--sequence-direction {forward,backward,random}`
Optional override for the order in which layers are applied. If omitted, the
layout JSONs own `sequence_direction` is used.
- `--audio-mode {base,all,json_only,external,none}`
How to construct the final audio track:
- `base` (default) only the base videos audio. JSON layers are visual only.
- `all` mix **base audio + all JSON layer audio** together.
- `json_only` ignore the base audio, use only JSON layer audio.
- `external` ignore base + JSON audio, use the file supplied via `--audio-src`.
- `none` no audio at all (silent output).
- `--audio-src PATH`
Path to an external audio file. Only used when `--audio-mode=external`.
- `--force`
From the global videobeaux CLI: forces overwrite of existing outputs and adds
`-y` to ffmpeg under the hood.
---
## 2. Layout JSON format
A layout JSON looks like this at the top level:
```json
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "logo",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_right",
"size": 18,
"opacity": 0.95
}
]
}
```
### 2.1 Top level
- `sequence_direction` (string, optional)
Controls how `lagkage` orders the layers before applying them:
- `"forward"` smallest `layer_number` first (default)
- `"backward"` largest `layer_number` first
- `"random"` random permutation of layers
You can override this perrun using `--sequence-direction` on the CLI.
- `layers` (array, **required**)
Each element is a **layer object** describing one overlay media item.
### 2.2 Perlayer fields
All layers share a common set of fields. Some are optional depending on mode.
#### Identity and source
- `layer_number` (int, **recommended**)
Used for ordering and for sequence modes. Lowest layer_number is “first” in
forward mode. If omitted, it defaults to 0 for sorting, but its best to set
explicit numbers.
- `name` (string, optional)
Short humanreadable label for the layer. Not used by the code, but useful
for documentation.
- `filename` (string, **required**)
Path to the media file for this layer. Resolution rules:
- Absolute paths (`/Users/you/...`) are used asis.
- URLs (`https://...`) are passed directly to ffmpeg.
- Paths starting with `'../media/'` are normalized to `media/...` **relative
to your project root**.
- Paths starting with `'media/'` are also treated as projectroot relative.
- Any other relative path is treated as **layoutrelative**:
resolved against the folder that contains the JSON.
- `type` (string, **required**)
The kind of media:
- `"img"` PNG/JPEG/etc.; single frame, no audio
- `"gif"` animated GIF; preprocessed to a looping RGBA video
- `"video"` standard video file; may contain audio
This mainly affects whether we run the GIF preprocessing pipeline.
#### Positioning: `mode`, `place`, `pos_x`, `pos_y`
A layer can be positioned in **place** mode (logical corners/center) or in
**free** mode (explicit pixel coordinates).
- `mode` (string, **required**)
- `"place"` snap the layer into a logical slot:
- uses `place`
- `"free"` place the layer at exact coordinates:
- uses `pos_x`, `pos_y`
- `place` (string, required if `mode="place"`)
One of:
- `"top_left"`
- `"top_right"`
- `"bottom_left"`
- `"bottom_right"`
- `"center"`
- `pos_x`, `pos_y` (int, required if `mode="free"`)
Pixel coordinates in the **base videos pixel space**. (0,0) is topleft of
the base frame. These are the topleft of the scaled/cropped layer box.
You *can* push a layer partially or fully offscreen by using negative values
or values greater than the base width/height.
#### Size and opacity
- `size` (float, optional; default `100`)
Percentage of the **base video width** that this layer should occupy. The
layer is scaled to:
```text
box_w = base_width * (size / 100)
box_h = box_w / aspect_ratio
```
Both are forced to even integers (necessary for some codecs).
- `opacity` (float, optional; default `1.0`)
Range `[0.0, 1.0]`. Implemented using `colorchannelmixer` on the layers
alpha channel before overlay:
- `1.0` fully opaque
- `0.5` half transparent
- `0.0` fully transparent (invisible)
#### Zoom and crop
These operate in **layer space** before the final overlay.
- `zoom` (float, optional; default `1.0`)
- `1.0` no zoom (layer fits exactly inside the computed `box_w x box_h`)
- `>1.0` zoom in; layer is scaled up to `zoom * box` then centercropped
back down to the box. This gives the effect of “zooming inside” a fixed
PIP area.
- `crop_x`, `crop_y`, `crop_w`, `crop_h` (float, optional)
**Percentages** of the original source width/height, all in `[0,100]`.
If all four are present, lagkage applies:
```text
crop=in_w*crop_w/100 : in_h*crop_h/100 : in_w*crop_x/100 : in_h*crop_y/100
```
This happens *before* zoom/scale. Use this to punch out a region from a
larger frame (for example, turning a fullframe video into a “face cam” PIP).
#### Perlayer audio gain
These fields only matter if:
- the layers media actually has audio (e.g. `type: "video"` with an audio stream),
- and `--audio-mode` is `all` or `json_only` so layer audio is in the mix.
- `audio_gain_db` (float, optional)
Gain in decibels applied to the layers audio *before* mixing:
- `0` keep original level
- `-6` roughly “half as loud” perceptually
- `+3` a bit louder
- `+12` very loud / likely to clip unless sources are quiet
- `audio_gain` (float, optional)
Linear multiplier, converted internally to dB as:
```text
audio_gain_db = 20 * log10(audio_gain)
```
Only used if `audio_gain_db` is not set.
For example, `audio_gain: 0.5` ≈ `-6.02 dB`.
---
## 3. Audio behavior in detail
### 3.1 Modes
- `--audio-mode base` (default)
- Map `0:a?` (base audio only).
- JSON layers are *visual only*; perlayer audio gains are ignored.
- `--audio-mode all`
- Take audio from:
- base input (index 0) if it has audio
- all JSON layers whose preprocessed media has audio
- Apply **perlayer gains** (where specified) on JSON layers.
- Use `amix` to combine all sources into a single output stream.
- `--audio-mode json_only`
- Ignore base audio completely.
- Mix only JSON layer audio (with perlayer gains) via `amix`.
- `--audio-mode external`
- Append `--audio-src PATH` as an extra ffmpeg input.
- Map only that inputs audio (`N:a?`) to the output.
- JSON layer audio and base audio are ignored.
- `--audio-mode none`
- Add `-an` to ffmpeg.
- Output video is silent.
### 3.2 When do perlayer gains matter?
Perlayer audio gains (`audio_gain_db` / `audio_gain`) are applied:
- only on JSON layers that actually have an audio stream, and
- only when `--audio-mode` is `all` or `json_only`.
They do **not** affect:
- the base input audio (theres currently no `base_audio_gain_db`),
- the external audio when `--audio-mode=external`,
- GIF overlays (their preprocessed videos are silent),
- image layers (no audio).
---
## 4. GIF handling
GIFs are preprocessed before the main pass so the compositor can treat them
like normal video streams with alpha.
For each `type: "gif"` layer:
1. lagkage runs a helper ffmpeg command that:
- loops the GIF (`-stream_loop -1`, `-ignore_loop 0`)
- clamps to the base duration using `-t base_duration`
- forces even dimensions with `scale=trunc(iw/2)*2:trunc(ih/2)*2`
- converts to RGBA pixels (`format=rgba`)
- writes a `.mov` with `qtrle` codec and **alpha preserved**
2. The resulting `.mov` is used as the actual overlay source.
This is why animated GIFs loop smoothly across the entire base clip and retain
their transparency when overlaid.
---
## 5. Path resolution rules
Given a `layout_exNN.json` at:
```text
lagkage_layouts/layout_exNN_*.json
```
and a layer `filename` value, lagkage resolves paths as follows:
1. If `filename` contains `"://"` → treat as URL and pass directly to ffmpeg.
2. If `filename` is an absolute path (e.g. `/Users/you/file.mp4`) → use asis.
3. If `filename` starts with `"../media/"` → normalize to `media/...` relative
to your project root.
4. If `filename` starts with `"media/"` → treat as projectroot relative.
5. Otherwise → resolve relative to the **layout JSONs directory**.
---
## 6. Example layouts & commands
This repo includes a set of example layouts and matching commands that showcase:
- place vs free mode
- cropping and zoom
- animated GIF overlays
- multiple layers with different sizes & opacities
- different `sequence_direction` values
- all audio modes, including perlayer gain
See:
- `lagkage_layouts/` 20 example layout JSON files (layout_ex01_*.json … layout_ex20_*.json)
- `lagkage_examples_full.txt` example commands, one per layout
You can copy one example, tweak the filenames to match your media, and then
iterate from there.
---
## 7. Minimal “hello lagkage” example
`lagkage_layouts/layout_ex01_logo_corner.json`:
```json
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "logo_corner",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_right",
"size": 18,
"opacity": 0.95
}
]
}
```
Command:
```bash
videobeaux -P lagkage -i media/base.mp4 -o out/ex01_logo_corner.mp4 --layout-json lagkage_layouts/layout_ex01_logo_corner.json --audio-mode base --force
```
This is the simplest setup:
- Base video at `media/base.mp4`
- One PNG logo (`reem.png`) in the topright corner
- Base audio only
- No zoom/crop or audio mixing yet
From here, add more layers, switch to `audio-mode=all`, and start using
`audio_gain_db` to explore full lagkage functionality.

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,16 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "example",
"filename": "../media/sample.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 200,
"size": 20,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,28 @@
{
"sequence_direction": "forward",
"layers": [{
"layer_number": 1,
"name": "loud_clip",
"filename": "../media/base.mp4",
"type": "video",
"mode": "free",
"pos_x": 200,
"pos_y": 250,
"size": 30,
"opacity": 1.0,
"audio_gain_db": -1
},
{
"layer_number": 2,
"name": "quiet_clip",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 100,
"pos_y": 150,
"size": 30,
"opacity": 1.0,
"audio_gain_db": 0
}
]
}

View File

@@ -0,0 +1,15 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "logo_corner",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_right",
"size": 18,
"opacity": 0.95
}
]
}

View File

@@ -0,0 +1,15 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "gif_center",
"filename": "../media/circus.gif",
"type": "gif",
"mode": "place",
"place": "center",
"size": 30,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,30 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "pip_left",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 80,
"pos_y": 200,
"size": 28,
"opacity": 1.0,
"audio_gain_db": -6
},
{
"layer_number": 2,
"name": "pip_right_zoom",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 1120,
"pos_y": 200,
"size": 28,
"opacity": 1.0,
"zoom": 1.5,
"audio_gain_db": -12
}
]
}

View File

@@ -0,0 +1,20 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "lower_third_band",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": 160,
"pos_y": 780,
"size": 65,
"opacity": 0.95,
"crop_x": 0,
"crop_y": 40,
"crop_w": 100,
"crop_h": 60
}
]
}

View File

@@ -0,0 +1,21 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "zoom_crop_pip",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "place",
"place": "center",
"size": 35,
"opacity": 1.0,
"zoom": 1.8,
"crop_x": 25,
"crop_y": 25,
"crop_w": 50,
"crop_h": 50,
"audio_gain_db": -6
}
]
}

View File

@@ -0,0 +1,49 @@
{
"sequence_direction": "random",
"layers": [
{
"layer_number": 1,
"name": "logo_small",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": 60,
"pos_y": 80,
"size": 10,
"opacity": 0.9
},
{
"layer_number": 2,
"name": "gif_top_left",
"filename": "../media/circus.gif",
"type": "gif",
"mode": "free",
"pos_x": -50,
"pos_y": -20,
"size": 20,
"opacity": 1.0
},
{
"layer_number": 3,
"name": "gif_bottom_right",
"filename": "../media/bunny.gif",
"type": "gif",
"mode": "free",
"pos_x": 1180,
"pos_y": 720,
"size": 18,
"opacity": 1.0
},
{
"layer_number": 4,
"name": "gif_left_mid",
"filename": "../media/circus.gif",
"type": "gif",
"mode": "free",
"pos_x": -120,
"pos_y": 400,
"size": 22,
"opacity": 0.8
}
]
}

View File

@@ -0,0 +1,45 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "tl_logo",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_left",
"size": 10,
"opacity": 0.9
},
{
"layer_number": 2,
"name": "tr_logo",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_right",
"size": 10,
"opacity": 0.9
},
{
"layer_number": 3,
"name": "bl_logo",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "bottom_left",
"size": 10,
"opacity": 0.9
},
{
"layer_number": 4,
"name": "br_logo",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "bottom_right",
"size": 10,
"opacity": 0.9
}
]
}

View File

@@ -0,0 +1,43 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "lower_band",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": 120,
"pos_y": 780,
"size": 70,
"opacity": 0.9,
"crop_x": 0,
"crop_y": 50,
"crop_w": 100,
"crop_h": 50
},
{
"layer_number": 2,
"name": "pip_story",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 80,
"pos_y": 160,
"size": 30,
"opacity": 1.0,
"zoom": 1.2,
"audio_gain_db": -9
},
{
"layer_number": 3,
"name": "logo_corner",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_left",
"size": 14,
"opacity": 0.95
}
]
}

View File

@@ -0,0 +1,34 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "left_zoomed",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 0,
"pos_y": 0,
"size": 50,
"opacity": 1.0,
"zoom": 1.6,
"crop_x": 10,
"crop_y": 10,
"crop_w": 80,
"crop_h": 80,
"audio_gain_db": -8
},
{
"layer_number": 2,
"name": "right_normal",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 960,
"pos_y": 0,
"size": 50,
"opacity": 1.0,
"audio_gain_db": -8
}
]
}

View File

@@ -0,0 +1,62 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "logo_corner",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_right",
"size": 14,
"opacity": 0.95
},
{
"layer_number": 2,
"name": "pip_left",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 80,
"pos_y": 220,
"size": 26,
"opacity": 1.0,
"audio_gain_db": -10
},
{
"layer_number": 3,
"name": "pip_right_zoom",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 1180,
"pos_y": 220,
"size": 26,
"opacity": 1.0,
"zoom": 1.4,
"audio_gain_db": -12
},
{
"layer_number": 4,
"name": "gif_circus",
"filename": "../media/circus.gif",
"type": "gif",
"mode": "free",
"pos_x": 420,
"pos_y": 260,
"size": 18,
"opacity": 1.0
},
{
"layer_number": 5,
"name": "gif_bunny",
"filename": "../media/bunny.gif",
"type": "gif",
"mode": "free",
"pos_x": 1320,
"pos_y": 260,
"size": 16,
"opacity": 1.0
}
]
}

View File

@@ -1,76 +1,75 @@
{ {
"sequence_direction": "forward", "sequence_direction": "forward",
"layers": [ "layers": [{
{ "layer_number": 1,
"layer_number": 1, "name": "corner_logo",
"name": "corner_logo", "filename": "../media/reem.png",
"filename": "../media/reem.png", "type": "img",
"type": "img", "mode": "place",
"mode": "place", "place": "top_left",
"place": "top_left", "size": 14,
"size": 14, "opacity": 0.95
"opacity": 0.95 },
}, {
{ "layer_number": 2,
"layer_number": 2, "name": "pip_left",
"name": "pip_left", "filename": "../media/schwwaaa.mp4",
"filename": "../media/schwwaaa.mp4", "type": "video",
"type": "video", "mode": "free",
"mode": "free", "pos_x": 120,
"pos_x": 120, "pos_y": 220,
"pos_y": 220, "size": 24,
"size": 24, "opacity": 1.0
"opacity": 1.0 },
}, {
{ "layer_number": 3,
"layer_number": 3, "name": "pip_right_zoom",
"name": "pip_right_zoom", "filename": "../media/faith.mp4",
"filename": "../media/faith.mp4", "type": "video",
"type": "video", "mode": "free",
"mode": "free", "pos_x": 180,
"pos_x": 1180, "pos_y": 220,
"pos_y": 220, "size": 24,
"size": 24, "opacity": 1.0,
"opacity": 1.0, "zoom": 1.5
"zoom": 1.5 },
}, {
{ "layer_number": 4,
"layer_number": 4, "name": "lower_third_band",
"name": "lower_third_band", "filename": "../media/gross.png",
"filename": "../media/gross.png", "type": "img",
"type": "img", "mode": "free",
"mode": "free", "pos_x": 260,
"pos_x": 260, "pos_y": 780,
"pos_y": 780, "size": 54,
"size": 54, "opacity": 0.95
"opacity": 0.95 },
}, {
{ "layer_number": 5,
"layer_number": 5, "name": "circus_zoomcrop",
"name": "circus_zoomcrop", "filename": "../media/circus.gif",
"filename": "../media/circus.gif", "type": "gif",
"type": "gif", "mode": "free",
"mode": "free", "pos_x": 420,
"pos_x": 420, "pos_y": 260,
"pos_y": 260, "size": 18,
"size": 18, "opacity": 1.0,
"opacity": 1.0, "zoom": 1.8,
"zoom": 1.8, "crop_x": 20,
"crop_x": 20, "crop_y": 20,
"crop_y": 20, "crop_w": 60,
"crop_w": 60, "crop_h": 60
"crop_h": 60 },
}, {
{ "layer_number": 6,
"layer_number": 6, "name": "bunny_free",
"name": "bunny_free", "filename": "../media/bunny.gif",
"filename": "../media/bunny.gif", "type": "gif",
"type": "gif", "mode": "free",
"mode": "free", "pos_x": 1320,
"pos_x": 1320, "pos_y": 260,
"pos_y": 260, "size": 16,
"size": 16, "opacity": 1.0
"opacity": 1.0 }
} ]
]
} }

View File

@@ -0,0 +1,26 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "logo_center",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "center",
"size": 25,
"opacity": 0.9
},
{
"layer_number": 2,
"name": "gif_bottom",
"filename": "../media/circus.gif",
"type": "gif",
"mode": "free",
"pos_x": 400,
"pos_y": 700,
"size": 22,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,29 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "clip_soft",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 120,
"pos_y": 200,
"size": 30,
"opacity": 1.0,
"audio_gain": 0.5
},
{
"layer_number": 2,
"name": "clip_loud",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 980,
"pos_y": 200,
"size": 30,
"opacity": 1.0,
"audio_gain": 2.0
}
]
}

View File

@@ -0,0 +1,38 @@
{
"sequence_direction": "backward",
"layers": [
{
"layer_number": 1,
"name": "logo1",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": 100,
"pos_y": 100,
"size": 12,
"opacity": 0.7
},
{
"layer_number": 2,
"name": "logo2",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": 140,
"pos_y": 140,
"size": 14,
"opacity": 0.7
},
{
"layer_number": 3,
"name": "logo3",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": 180,
"pos_y": 180,
"size": 16,
"opacity": 0.7
}
]
}

View File

@@ -0,0 +1,45 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "tl_small",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_left",
"size": 8,
"opacity": 0.9
},
{
"layer_number": 2,
"name": "tr_medium",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "top_right",
"size": 12,
"opacity": 0.9
},
{
"layer_number": 3,
"name": "bl_big",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "bottom_left",
"size": 20,
"opacity": 0.9
},
{
"layer_number": 4,
"name": "br_huge",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "bottom_right",
"size": 26,
"opacity": 0.9
}
]
}

View File

@@ -0,0 +1,37 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "center_logo",
"filename": "../media/reem.png",
"type": "img",
"mode": "place",
"place": "center",
"size": 18,
"opacity": 1.0
},
{
"layer_number": 2,
"name": "gif_left",
"filename": "../media/bunny.gif",
"type": "gif",
"mode": "free",
"pos_x": 300,
"pos_y": 400,
"size": 12,
"opacity": 1.0
},
{
"layer_number": 3,
"name": "gif_right",
"filename": "../media/circus.gif",
"type": "gif",
"mode": "free",
"pos_x": 1300,
"pos_y": 400,
"size": 12,
"opacity": 1.0
}
]
}

View File

@@ -0,0 +1,44 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "zoom1",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 200,
"pos_y": 150,
"size": 24,
"opacity": 1.0,
"zoom": 1.0,
"audio_gain_db": -6
},
{
"layer_number": 2,
"name": "zoom2",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 600,
"pos_y": 150,
"size": 24,
"opacity": 1.0,
"zoom": 1.5,
"audio_gain_db": -12
},
{
"layer_number": 3,
"name": "zoom3",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 1000,
"pos_y": 150,
"size": 24,
"opacity": 1.0,
"zoom": 2.0,
"audio_gain_db": -18
}
]
}

View File

@@ -0,0 +1,22 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "crop_focus",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 1400,
"pos_y": 500,
"size": 18,
"opacity": 1.0,
"crop_x": 35,
"crop_y": 20,
"crop_w": 30,
"crop_h": 40,
"zoom": 1.2,
"audio_gain_db": -8
}
]
}

View File

@@ -0,0 +1,27 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "left_half_off",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": -200,
"pos_y": 300,
"size": 40,
"opacity": 0.9
},
{
"layer_number": 2,
"name": "right_half_off",
"filename": "../media/reem.png",
"type": "img",
"mode": "free",
"pos_x": 1600,
"pos_y": 300,
"size": 40,
"opacity": 0.9
}
]
}

View File

@@ -0,0 +1,15 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "logo_media_root",
"filename": "media/reem.png",
"type": "img",
"mode": "place",
"place": "top_left",
"size": 18,
"opacity": 0.95
}
]
}

View File

@@ -0,0 +1,41 @@
{
"sequence_direction": "forward",
"layers": [
{
"layer_number": 1,
"name": "clip1_bg",
"filename": "../media/schwwaaa.mp4",
"type": "video",
"mode": "free",
"pos_x": 80,
"pos_y": 180,
"size": 22,
"opacity": 1.0,
"audio_gain_db": -12
},
{
"layer_number": 2,
"name": "clip2_mid",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 600,
"pos_y": 220,
"size": 22,
"opacity": 1.0,
"audio_gain_db": -6
},
{
"layer_number": 3,
"name": "clip3_fg",
"filename": "../media/faith.mp4",
"type": "video",
"mode": "free",
"pos_x": 1120,
"pos_y": 260,
"size": 22,
"opacity": 1.0,
"audio_gain_db": -3
}
]
}

Binary file not shown.

BIN
media/gross.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 KiB

BIN
media/music_track.wav Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

View File

@@ -18,7 +18,7 @@ srt==3.5.3
tqdm==4.67.1 tqdm==4.67.1
typing_extensions==4.14.1 typing_extensions==4.14.1
urllib3==2.5.0 urllib3==2.5.0
-e git+ssh://git@github.com/vondas-network/videobeaux.git@09684ac5b5173f312d59b780e03c3ad5f1bf25e9#egg=videobeaux videobeaux @ file:///Users/tgm/Documents/SPLASH/videobeaux
videogrep==2.3.0 videogrep==2.3.0
vosk==0.3.44 vosk==0.3.44
websockets==15.0.1 websockets==15.0.1

View File

@@ -0,0 +1,306 @@
# videobeaux/programs/lagkage.py
#
# Compose multiple visual layers (images/gifs/videos) on top of a base video
# using a single JSON layout file.
#
# GIF layers are preprocessed into finite videos that loop for roughly the base
# video duration, so the main overlay graph stays simple and stable.
import json
import os
import random
import subprocess
import tempfile
from pathlib import Path
from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress
def register_arguments(parser):
parser.description = (
"Compose a base video with layered media defined by a JSON layout "
"(images / videos / GIFs) using the lagkage compositor."
)
parser.add_argument(
"--layout-json",
dest="layout_json",
required=True,
type=str,
help="Path to layout JSON describing layers.",
)
parser.add_argument(
"--sequence-direction",
dest="sequence_direction",
choices=["forward", "backward", "random"],
default=None,
help="Override sequence_direction from JSON (forward|backward|random).",
)
parser.add_argument(
"--audio-mode",
dest="audio_mode",
choices=["base", "all", "json_only", "external", "none"],
default="base",
help=(
"How to build the audio track: "
"base (default base video audio only), "
"all (base + all JSON media audio mixed), "
"json_only (only JSON media audio), "
"external (use --audio-src), "
"none (no audio)."
),
)
parser.add_argument(
"--audio-src",
dest="audio_src",
type=str,
help="External audio file when --audio-mode=external.",
)
# ----------------- ffprobe helpers -----------------
def _probe_base_info(path: str):
"""
Return (width, height, duration_seconds) for the base video.
"""
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-show_entries",
"format=duration",
"-of",
"json",
path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"ffprobe failed for base {path}: {proc.stderr}")
info = json.loads(proc.stdout)
width = int(info["streams"][0]["width"])
height = int(info["streams"][0]["height"])
duration = float(info["format"]["duration"])
return width, height, duration
def _probe_video_size(path: str):
"""
Return (width, height) of the first video/image stream.
"""
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=s=x:p=0",
path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"ffprobe size failed for {path}: {proc.stderr}")
line = proc.stdout.strip()
if not line:
raise RuntimeError(f"ffprobe size returned empty output for {path}")
w_str, h_str = line.split("x")
return int(w_str), int(h_str)
def _probe_has_audio(path: str) -> bool:
"""
Return True if the file has at least one audio stream.
"""
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=index",
"-of",
"csv=p=0",
path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
return proc.returncode == 0 and proc.stdout.strip() != ""
# ----------------- small helpers -----------------
def _even(x: int) -> 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:
"""
Convert GIF to a looping RGBA video with even dimensions and alpha.
Returns path to the temp video file.
- 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)
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",
"-y",
"-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(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
def _resolve_layer_path(filename: str, layout_path: Path) -> str:
"""
Resolve the media path for a layer in a way that:
- Leaves absolute paths and URLs alone.
- Treats '../media/...' as project-root 'media/...'.
- Treats 'media/...' as-is (relative to current working dir).
- For simple basenames, keeps layout-relative behavior.
"""
# URLs
if "://" in filename:
return filename
# Absolute path
if os.path.isabs(filename):
return filename
# Special-case ../media/... -> media/...
if filename.startswith("../media/"):
return os.path.normpath("media/" + filename[len("../media/"):])
# Special-case media/... -> as-is (cwd-relative)
if filename.startswith("media/"):
return filename
# Everything else: layout-relative
return str((layout_path.parent / filename).resolve())
# ----------------- main entry -----------------
def run(args):
layout_path = Path(args.layout_json)
if not layout_path.exists():
raise FileNotFoundError(f"Layout JSON not found: {layout_path}")
audio_mode = getattr(args, "audio_mode", "base") or "base"
audio_src = getattr(args, "audio_src", None)
if audio_mode == "external" and not audio_src:
raise ValueError("--audio-mode=external requires --audio-src")
# 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 lagkage.")
# Probe base
base_w, base_h, base_duration
::contentReference[oaicite:0]{index=0}

View File

@@ -3,14 +3,15 @@
# Compose multiple visual layers (images/gifs/videos) on top of a base video # Compose multiple visual layers (images/gifs/videos) on top of a base video
# using a single JSON layout file. # using a single JSON layout file.
# #
# GIF layers are preprocessed into finite videos that loop for roughly the base # GIF layers are preprocessed into finite-length videos that loop for roughly
# video duration, so the main overlay graph stays simple and stable. # the base video duration, so the main overlay graph stays simple and stable.
import json import json
import os import os
import random import random
import subprocess import subprocess
import tempfile import tempfile
import math
from pathlib import Path from pathlib import Path
from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress
@@ -35,11 +36,40 @@ def register_arguments(parser):
default=None, default=None,
help="Override sequence_direction from JSON (forward|backward|random).", help="Override sequence_direction from JSON (forward|backward|random).",
) )
parser.add_argument(
"--audio-mode",
dest="audio_mode",
choices=["base", "all", "json_only", "external", "none"],
default="base",
help=(
"How to build the audio track: "
"base (default base video audio only), "
"all (base + all JSON media audio mixed), "
"json_only (only JSON media audio), "
"external (use --audio-src), "
"none (no audio)."
),
)
parser.add_argument(
"--audio-src",
dest="audio_src",
type=str,
help="External audio file when --audio-mode=external.",
)
# ----------------- ffprobe helpers ----------------- # ----------------- ffprobe helpers -----------------
def _run_ffprobe(cmd):
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(
f"ffprobe failed (code {proc.returncode}): {proc.stderr.strip()}"
)
return proc.stdout
def _probe_base_info(path: str): def _probe_base_info(path: str):
""" """
Return (width, height, duration_seconds) for the base video. Return (width, height, duration_seconds) for the base video.
@@ -58,14 +88,17 @@ def _probe_base_info(path: str):
"json", "json",
path, path,
] ]
proc = subprocess.run(cmd, capture_output=True, text=True) out = _run_ffprobe(cmd)
if proc.returncode != 0: data = json.loads(out)
raise RuntimeError(f"ffprobe failed for base {path}: {proc.stderr}")
info = json.loads(proc.stdout)
width = int(info["streams"][0]["width"]) streams = data.get("streams") or []
height = int(info["streams"][0]["height"]) if not streams:
duration = float(info["format"]["duration"]) raise RuntimeError(f"No video stream found in {path!r}")
s0 = streams[0]
width = int(s0.get("width") or 0)
height = int(s0.get("height") or 0)
duration = float(data["format"].get("duration") or 0.0)
return width, height, duration return width, height, duration
@@ -85,16 +118,34 @@ def _probe_video_size(path: str):
"csv=s=x:p=0", "csv=s=x:p=0",
path, path,
] ]
proc = subprocess.run(cmd, capture_output=True, text=True) out = _run_ffprobe(cmd)
if proc.returncode != 0: line = out.strip()
raise RuntimeError(f"ffprobe size failed for {path}: {proc.stderr}")
line = proc.stdout.strip()
if not line: if not line:
raise RuntimeError(f"ffprobe size returned empty output for {path}") raise RuntimeError(f"ffprobe size returned empty output for {path}")
w_str, h_str = line.split("x") w_str, h_str = line.split("x")
return int(w_str), int(h_str) return int(w_str), int(h_str)
def _probe_has_audio(path: str) -> bool:
"""
Return True if the file has at least one audio stream.
"""
cmd = [
"ffprobe",
"-v",
"error",
"-select_streams",
"a:0",
"-show_entries",
"stream=index",
"-of",
"csv=p=0",
path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
return proc.returncode == 0 and proc.stdout.strip() != ""
# ----------------- small helpers ----------------- # ----------------- small helpers -----------------
@@ -196,6 +247,54 @@ def _compute_overlay_box(base_w: int, src_w: int, src_h: int, size_pct: float):
return target_w, target_h return target_w, target_h
def _resolve_layer_path(filename: str, layout_path: Path) -> str:
"""
Resolve the media path for a layer in a way that:
- Leaves absolute paths and URLs alone.
- Treats '../media/...' as project-root 'media/...'.
- Treats 'media/...' as-is (relative to current working dir).
- For simple basenames, keeps layout-relative behavior.
"""
# URLs
if "://" in filename:
return filename
# Absolute path
if os.path.isabs(filename):
return filename
# Special-case ../media/... -> media/...
if filename.startswith("../media/"):
return os.path.normpath("media/" + filename[len("../media/"):])
# Special-case media/... -> as-is (cwd-relative)
if filename.startswith("media/"):
return filename
# Everything else: layout-relative
return str((layout_path.parent / filename).resolve())
def _load_layout(layout_path: Path):
data = json.loads(layout_path.read_text())
if "layers" not in data or not data["layers"]:
raise ValueError("Layout JSON has no 'layers' array or it's empty.")
return data
def _resolve_sequence(layout: dict, cli_seq: str):
seq = cli_seq or layout.get("sequence_direction") or "forward"
if seq not in ("forward", "backward", "random"):
seq = "forward"
layers = sorted(layout["layers"], key=lambda L: L.get("layer_number", 0))
if seq == "backward":
layers = list(reversed(layers))
elif seq == "random":
layers = random.sample(layers, len(layers))
return seq, layers
# ----------------- main entry ----------------- # ----------------- main entry -----------------
@@ -204,53 +303,39 @@ def run(args):
if not layout_path.exists(): if not layout_path.exists():
raise FileNotFoundError(f"Layout JSON not found: {layout_path}") raise FileNotFoundError(f"Layout JSON not found: {layout_path}")
# Load layout JSON audio_mode = getattr(args, "audio_mode", "base") or "base"
layout = json.loads(layout_path.read_text()) audio_src = getattr(args, "audio_src", None)
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 if audio_mode == "external" and not audio_src:
seq_dir = args.sequence_direction or layout.get("sequence_direction", "forward") raise ValueError("--audio-mode=external requires --audio-src")
if seq_dir not in ("forward", "backward", "random"):
seq_dir = "forward"
# Order layers by layer_number, then adjust sequence if needed layout = _load_layout(layout_path)
ordered_layers = sorted(layers, key=lambda L: L.get("layer_number", 0)) seq, ordered_layers = _resolve_sequence(layout, args.sequence_direction)
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 base_input = args.input
if not base_input: if not base_input:
raise ValueError("Global --input (base video) is required for lagkage.") raise ValueError("Global --input (base video) is required for lagkage.")
# Probe base # 1) Probe base video info once
base_w, base_h, base_duration = _probe_base_info(base_input) base_w, base_h, base_duration = _probe_base_info(base_input)
base_has_audio = _probe_has_audio(base_input)
# Temp dir for GIF preprocess # 2) Prepare inputs for main ffmpeg call
tmp_dir = Path(tempfile.mkdtemp(prefix="lagkage_gifs_")) tmp_dir = Path(tempfile.mkdtemp(prefix="lagkage_gifs_"))
# 1) Build input list for ffmpeg input_files = [base_input] # index 0 = base
# input 0 = base, 1..N = overlays overlay_specs = [] # (input_index, layer_dict, src_w, src_h)
input_files = [base_input] overlay_audio_indices = [] # which overlay inputs actually have audio
overlay_specs = [] # (input_index, layer_dict, src_w, src_h) audio_vol_db_by_index = {} # per-input gain in dB
for idx, layer in enumerate(ordered_layers, start=1): for idx, layer in enumerate(ordered_layers, start=1):
filename = layer.get("filename") filename = layer.get("filename")
if not filename: if not filename:
raise ValueError(f"Layer missing 'filename': {layer}") raise ValueError(f"Layer missing 'filename': {layer}")
# Relative paths are relative to the layout JSON directory. src = _resolve_layer_path(filename, layout_path)
if not os.path.isabs(filename):
src = str(layout_path.parent / filename)
else:
src = filename
layer_type = (layer.get("type") or "").lower() layer_type = (layer.get("type") or "").lower()
# If GIF, pre-process to finite-length video that loops to base duration
if layer_type == "gif": if layer_type == "gif":
overlay_src = _preprocess_gif(src, base_duration, tmp_dir, idx) overlay_src = _preprocess_gif(src, base_duration, tmp_dir, idx)
else: else:
@@ -262,10 +347,51 @@ def run(args):
in_index = len(input_files) - 1 in_index = len(input_files) - 1
overlay_specs.append((in_index, layer, src_w, src_h)) overlay_specs.append((in_index, layer, src_w, src_h))
# 2) Build filter_complex if _probe_has_audio(overlay_src):
overlay_audio_indices.append(in_index)
# Per-layer audio gain: audio_gain_db or audio_gain (linear)
gain_db = None
if "audio_gain_db" in layer:
try:
gain_db = float(layer["audio_gain_db"])
except Exception:
gain_db = None
elif "audio_gain" in layer:
try:
g = float(layer["audio_gain"])
if g > 0:
gain_db = 20.0 * math.log10(g)
except Exception:
gain_db = None
if gain_db is not None:
audio_vol_db_by_index[in_index] = gain_db
# external audio as extra input
external_audio_index = None
if audio_mode == "external":
if not os.path.isabs(audio_src):
audio_src_resolved = audio_src # cwd-relative
else:
audio_src_resolved = audio_src
input_files.append(audio_src_resolved)
external_audio_index = len(input_files) - 1
# figure out which indices feed audio mix
audio_mix_indices = []
if audio_mode == "all":
if base_has_audio:
audio_mix_indices.append(0)
audio_mix_indices.extend(overlay_audio_indices)
elif audio_mode == "json_only":
audio_mix_indices.extend(overlay_audio_indices)
# base/external/none handled later
# 3) Build filter_complex (video + optional audio mix)
filter_parts = [] filter_parts = []
# Start with base video # video chain: start from base
current_label = "[0:v]" current_label = "[0:v]"
for idx, (in_index, layer, src_w, src_h) in enumerate(overlay_specs, start=1): for idx, (in_index, layer, src_w, src_h) in enumerate(overlay_specs, start=1):
@@ -280,16 +406,15 @@ def run(args):
if zoom < 1.0: if zoom < 1.0:
zoom = 1.0 zoom = 1.0
# Compute final overlay box size on the base # compute overlay box size
box_w, box_h = _compute_overlay_box(base_w, src_w, src_h, size_pct) box_w, box_h = _compute_overlay_box(base_w, src_w, src_h, size_pct)
mode = (layer.get("mode") or "place").lower() mode = (layer.get("mode") or "place").lower()
if mode == "free": if mode == "free":
x_expr = str(int(layer.get("pos_x", 0))) x_expr = str(int(layer.get("pos_x", 0)))
y_expr = str(int(layer.get("pos_y", 0))) y_expr = str(int(layer.get("pos_y", 0)))
else: else:
place = layer.get("place", "center") place = (layer.get("place") or "center").lower()
px, py = _compute_place_coordinates(place, base_w, base_h, box_w, box_h) px, py = _compute_place_coordinates(place, base_w, base_h, box_w, box_h)
x_expr, y_expr = str(px), str(py) x_expr, y_expr = str(px), str(py)
@@ -299,7 +424,7 @@ def run(args):
layer_filters = [] layer_filters = []
# Optional crop (percent of source, BEFORE zoom/scale) # optional crop (percent of source BEFORE zoom/scale)
cx = layer.get("crop_x") cx = layer.get("crop_x")
cy = layer.get("crop_y") cy = layer.get("crop_y")
cw = layer.get("crop_w") cw = layer.get("crop_w")
@@ -317,7 +442,7 @@ def run(args):
# If parsing fails, skip cropping instead of blowing up. # If parsing fails, skip cropping instead of blowing up.
pass pass
# Zoom INSIDE fixed box: # zoom inside fixed box:
# 1) scale up to zoom * box size # 1) scale up to zoom * box size
# 2) crop center back down to box_w x box_h # 2) crop center back down to box_w x box_h
scaled_w = _even(int(box_w * zoom)) scaled_w = _even(int(box_w * zoom))
@@ -338,11 +463,9 @@ def run(args):
layer_filters.append("format=rgba") layer_filters.append("format=rgba")
layer_filters.append(f"colorchannelmixer=aa={opacity}") layer_filters.append(f"colorchannelmixer=aa={opacity}")
filter_parts.append( filter_parts.append(f"{in_label}{','.join(layer_filters)}{layer_label}")
f"{in_label}{','.join(layer_filters)}{layer_label}"
)
# Overlay on top of current composite # overlay
filter_parts.append( filter_parts.append(
f"{current_label}{layer_label}" f"{current_label}{layer_label}"
f"overlay=x={x_expr}:y={y_expr}:format=auto" f"overlay=x={x_expr}:y={y_expr}:format=auto"
@@ -351,29 +474,97 @@ def run(args):
current_label = next_label current_label = next_label
out_label = "[out_v]" out_v_label = "[out_v]"
filter_parts.append(f"{current_label}format=yuv420p{out_label}") filter_parts.append(f"{current_label}format=yuv420p{out_v_label}")
# audio mix (for all/json_only + per-layer gain)
audio_filter_output = None
if audio_mode in ("all", "json_only") and len(audio_mix_indices) >= 1:
if len(audio_mix_indices) == 1:
# Single audio source: optionally apply volume, no amix needed
idx = audio_mix_indices[0]
gain_db = audio_vol_db_by_index.get(idx)
if gain_db is not None:
audio_filter_output = "[out_a]"
filter_parts.append(
f"[{idx}:a]volume={gain_db}dB{audio_filter_output}"
)
else:
# direct map of the input's audio stream
audio_filter_output = f"{idx}:a"
else:
# Multiple sources: per-input volume (if any), then amix
audio_inputs = []
for idx in audio_mix_indices:
gain_db = audio_vol_db_by_index.get(idx)
if gain_db is not None:
lbl = f"[av{idx}]"
filter_parts.append(
f"[{idx}:a]volume={gain_db}dB{lbl}"
)
audio_inputs.append(lbl)
else:
audio_inputs.append(f"[{idx}:a]")
audio_filter_output = "[out_a]"
filter_parts.append(
"".join(audio_inputs)
+ f"amix=inputs={len(audio_mix_indices)}:normalize=0{audio_filter_output}"
)
filter_complex = ";".join(filter_parts) filter_complex = ";".join(filter_parts)
# 3) Build main ffmpeg command # 4) Build main ffmpeg command
command = ["ffmpeg", "-hide_banner", "-loglevel", "error"] command = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
for path in input_files: for path in input_files:
command.extend(["-i", path]) command.extend(["-i", path])
command.extend([ command.extend(["-filter_complex", filter_complex, "-map", out_v_label])
"-filter_complex", filter_complex,
"-map", out_label, # audio mapping according to mode
"-map", "0:a?", if audio_mode == "base":
"-c:v", "libx264", command.extend(["-map", "0:a?"])
"-profile:v", "high", elif audio_mode in ("all", "json_only"):
"-level:v", "4.2", if len(audio_mix_indices) == 0:
"-pix_fmt", "yuv420p", # no audio
"-movflags", "+faststart", pass
"-c:a", "aac", elif len(audio_mix_indices) == 1:
args.output, if audio_filter_output == "[out_a]":
]) command.extend(["-map", audio_filter_output])
else:
# audio_filter_output is like "N:a"
command.extend(["-map", audio_filter_output])
else:
command.extend(["-map", audio_filter_output or "0:a?"])
elif audio_mode == "external":
if external_audio_index is not None:
command.extend(["-map", f"{external_audio_index}:a?"])
elif audio_mode == "none":
command.append("-an")
# video codec settings
command.extend(
[
"-c:v",
"libx264",
"-profile:v",
"high",
"-level:v",
"4.2",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
]
)
# audio codec if not explicitly disabled
if audio_mode != "none":
command.extend(["-c:a", "aac"])
command.append(args.output)
final_cmd = (command[:1] + ["-y"] + command[1:]) if args.force else command final_cmd = (command[:1] + ["-y"] + command[1:]) if args.force else command
run_ffmpeg_with_progress(final_cmd, args.input, args.output) run_ffmpeg_with_progress(final_cmd, args.input, args.output)