Not an endorsement.. but

Did you know that various AI chatbots can actually write GMIC scripts?
I thought GMIC was too obscure (sorry David :slight_smile: ) but I tried one and it knew how to do it.
Might be fun for you experienced guys to see how good or bad they are at it.

Even when you frown on it or don’t need it I was thinking: what if you use it to add comments as to what each line does? Seems that might be convenient - if it works.
Or how about using it to exchange long form for short form or vice versa?

I saw that David Tschumperle has in the past remarked he uses chatbots to translate, so he’s not adverse to them it seems, but did he think of using it for such things I wonder. Commenting code and using long-form to illustrate things is a pesky thing I’m sure, but what if a bot can do it?

Yes, actually that is the first thing I do when testing a new LLM model : ask for the creation of some custom G’MIC scripts. I think this is a good test because G’MIC is not a common programming language, and there is not a lot of documentation about it.

A few weeks ago, I’ve been surprised by Google Gemini 2.5, which was able to produce some basic G’MIC scripts to draw things in images. That is the first time a LLM produced a working G’MIC command out-of-the-box, without requiring additionnal iterations of prompting.

Definitely, those things are really useful for a lot of tasks. The progress made by LLMs those last few years is really impressive. I keep an eye on them, and I test them when I can. They are the future of human-machine interaction.

1 Like

That is interesting. If I had a LLM buddy that played nice, I am sure I would be more motivated to dive into G’MIC again, or at least update/refine my existing filters/commands.

Here is a typical example of interaction I’m trying (here with Gemini 2.5) :

I’m pretty sure this can be useful indeed, to learn some basics of the G’MIC language!

I actually used ChatGPT to solve some few problems (After verifications). So, it can be a good tool of last resort.

Another example, which I find really impressive :
Prompt:

Here is the definition of a g'mic command that I don't understand : "add_copymark :
foreach { nm={n} 0 => $nm . =>[0] {n} k[0] }
". Can you explain me what does it do, globally and step by step ?

Answer (Google Gemini 2.5):

Model

![Thinking](https://www.gstatic.com/aistudio/watermark/watermark.png) Thoughts (experimental)

Auto

Expand to view model thoughts

chevron_right

The G'MIC command add_copymark is designed to append a "copymark" suffix to the names of selected images. This is particularly useful for distinguishing images that have been duplicated or modified. Let's break down its functionality, both in terms of its overall purpose and a step-by-step analysis of its definition.

### Global Functionality

In essence, the add_copymark command iterates through each of the currently selected images and renames them by adding a suffix. This prevents naming conflicts when you've made copies of images and want to keep track of the originals.

### Step-by-Step Explanation

The definition of the add_copymark command is as follows:

code Gmic

```
add_copymark :
  foreach { nm={n} 0 => $nm . =>[0] {n} k[0] }
```

Here is a detailed breakdown of each component of this command:

1. **foreach { ... }** : This initiates a loop that will execute the commands contained within the curly braces for each image in the current selection. G'MIC processes images in a list, and foreach allows for individual manipulation of each image in that list.[[1](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQE2Q60lZU9a_0U9idd79RjaaPnJWgrM_tLpSs7IN3h6A6hN--Zi8fhESfYXfKBeuPHVapmOdszg_VVqB0Rvk2YeZrJkMrU2TA7iM07hhHXGaDlKrGV-uGlUUf0IpgerLKhGwDHztC-JGYJnQ4Jr-xwxqEklVbUn-469H05fHRgn)]

2. **nm={n}** : Inside the foreach loop, this is the first action performed for each image.

  * {n} is a substitution expression that retrieves the name of the current image being processed in the loop.[[2](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQFZGIqzeO5mcSnH-CPTq5uB6laKH1GxbJy724isEDCVKeunLdvRaWBfIN2eCmUkPtslEYpRqjtetKdvYbLX_eK9Y2L94toqfyT-ROjNO-cihS6dMFxvGXMTLbAtENr1wv2zCf44UBFvyfXpJXw%3D)]

  * nm= is a variable assignment. It creates a new variable named nm and assigns the name of the current image to it.[[3](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQELgyPQH15RCFkVMLSjNQGrUZpwebt0LNNtrIMKQkbDBEMOFAXANlixNO8a6xYtevJYEOp-dYjJVjEZbCY3bQZUa1MPdh3Dg-MT8qkgtQk1yHKMJkGx9mEVPjmHeGpysD421g66tAwAl2QHemX-uueefA3U4tDljfSAgMQ%3D)] So, for each image, nm will hold that image's name.
3. **0 => $nm** : This part of the command renames a newly created image.

  * 0 creates a new, empty image.

  * => is a shortcut for the name command, which is used to assign a name to an image.[[4](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQHCIgJTW2l2fcgVB9KN8FuudOg0GhKz9fh4-mFOReBorNm70khaIKNcZ4VDitwSmxaRia8G6AltDVjQefZEBQqPz_2g-5NhYJQRCUouMHGIz2DBfouUoJmW0HcZRYsWLg%3D%3D)]

  * $nm retrieves the value of the variable nm (which, as we established, is the name of the current image from the foreach loop).

  * Therefore, 0 => $nm creates a new empty image and gives it the same name as the original image currently being processed.
4. **. =>[0] {n}** : This is where the actual renaming of the original image happens.

  * The dot . is a shortcut to select the last image in the list. In the context of the foreach loop, this refers to the original image currently being processed.

  * =>[0] uses the name command to assign a new name to the selected image (the original one). The [0] specifies that the name should be taken from the image at index 0, which is the newly created empty image.

  * {n} in this context appends the original name as a suffix. When used in the name command in this manner, it effectively renames the target image to have the name of the image at index 0, followed by its own original name. Since the image at index 0 has the original name, this results in the original name being appended to itself, with a separator. The "copymark" is the underscore that G'MIC automatically adds when concatenating names in this way.
5. **k[0]** : This is the final step within the loop for each image.

  * k is a shortcut for the keep command.[[5](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQGc6Hv89noy6dKAGLrG-9YgUZShgBEHqKNw07X2mAqnmxPcx6bW_i7b1jsLpCBeGf6-Vn5Qm4Gkbx0q-TtKq0GUtOjT0swx0wqy-_EMGFYZ7K03h5N0fJQWlFQm4Kab)]

  * [0] specifies that only the image at index 0 should be kept.

  * At this point in the loop, the image list for the current iteration contains the newly named original image and the temporary empty image. This command removes the temporary empty image, leaving only the renamed original image.

After the foreach loop completes this sequence for all selected images, the result is that every image in the original selection has been renamed with its original name appended as a suffix (e.g., "image_name_copy").

I would also be of the mindset that if you really persist, explain things to the LLM very carefully, it can often produce code that meets your wishes.

I used to do videos and put them on YouTube (of music by artists I liked but also my own experimental composition) - I would often (mainly in fact) write Python scripts which would produce batch files which ran in cmd (when I was using Windoze) with calls to gmic cli, using metadata I gleaned from using Sonic Annotator (which read an audio file and produced a CSV of, sometimes 10’s of thousands of values per file) to inform 1 or 2 parameters of, for instance, a Deformations filter like Distort Lens or otherwise to make the visuals match the audio in an ‘interesting’ way! See for example my video for this XTC song: XTC - Senses Working Overtime : Free Download, Borrow, and Streaming : Internet Archive

Now I ‘only’ have an Android tablet - I can’t use Sonic Annotator, but obviously I can use gmic from the command line, so I asked ChatGPT for a similar type of audio/video manipulation, I asked it to include a call to gmic, and after going back and forth about 6 or 7 times it eventually came up with this (pretty cool) Python script (the gmic call just uses the mul command but it’s integral to the results.)
(gmic call is on line 165 I believe)

#!/usr/bin/env python3
"""
Audio‑driven brightness modulator for video (Termux‑friendly)

Pipeline (single command):
1) Reads video FPS + duration via ffprobe.
2) Decodes audio to raw float32 mono via ffmpeg (no heavy Python deps).
3) Computes RMS per video frame and maps to [0..1] factor using dB scaling.
4) Extracts video frames with ffmpeg at exact FPS.
5) Multiplies each frame by its factor using G'MIC (brightness to black).
6) Reassembles processed frames with original audio via ffmpeg.

Usage:
  python audio_driven_brightness_termux.py \
      --video input.mp4 --audio audio.wav --out output.mp4 \
      [--floor-db -60] [--ceil-db 0] [--fps auto] [--sr 48000] \
      [--ext jpg] [--qscale 3] [--keep]

Notes:
- "floor-db" is the silence floor. Anything <= floor-db maps to factor=0 (black).
- "ceil-db" is the loud reference. Anything >= ceil-db maps to factor=1 (original).
- Works when audio and video durations are equal (or very close). Final video is clipped to the shorter stream.
- Requires: ffmpeg, gmic, python3 (stdlib only), and a POSIX shell (Termux OK).
"""

import argparse
import json
import math
import os
import shutil
import subprocess
import sys
from fractions import Fraction
from pathlib import Path

def run(cmd, **kwargs):
    """Run a command, raise on error, return CompletedProcess."""
    print("$", " ".join(cmd))
    return subprocess.run(cmd, check=True, **kwargs)


def capture(cmd):
    """Run a command and capture stdout as text."""
    out = subprocess.check_output(cmd)
    return out.decode("utf-8", "replace")


def probe_fps_duration(video_path):
    # Get r_frame_rate and duration via ffprobe JSON
    info = capture([
        "ffprobe", "-v", "error",
        "-select_streams", "v:0",
        "-show_entries", "stream=r_frame_rate:format=duration",
        "-of", "json",
        video_path
    ])
    data = json.loads(info)
    # FPS
    r = data["streams"][0]["r_frame_rate"]  # e.g. "25/1"
    num, den = r.split("/")
    fps = float(Fraction(int(num), int(den)))
    # Duration (seconds)
    duration = float(data["format"]["duration"]) if "format" in data and "duration" in data["format"] else None
    return fps, duration


def decode_audio_to_pcm(audio_path, sr=48000):
    """Decode to mono float32 PCM via ffmpeg and return bytes."""
    proc = subprocess.Popen(
        [
            "ffmpeg", "-y", "-i", audio_path,
            "-ac", "1", "-ar", str(sr), "-f", "f32le", "-",
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
    )
    raw = proc.stdout.read()
    proc.stdout.close()
    ret = proc.wait()
    if ret != 0:
        raise RuntimeError("ffmpeg failed to decode audio")
    return raw


def pcm_to_rms_per_frame(raw_bytes, sr, fps, duration_s):
    import array
    # Convert bytes to float32 array without NumPy
    floats = array.array('f')
    floats.frombytes(raw_bytes)
    total_samples = len(floats)
    if duration_s is None:
        duration_s = total_samples / sr
    n_frames = int(round(duration_s * fps))
    if n_frames <= 0:
        raise ValueError("Computed zero frames; check FPS/duration")
    samples_per_frame_f = sr / fps
    rms_list = []
    pos = 0
    for i in range(n_frames):
        start = int(round(i * samples_per_frame_f))
        end = int(round((i + 1) * samples_per_frame_f))
        if end > total_samples:
            end = total_samples
        if end <= start:
            # No samples in this slice (e.g., last partial frame); repeat last value or 0
            val = rms_list[-1] if rms_list else 0.0
            rms_list.append(val)
            continue
        # Compute RMS for [start:end)
        acc = 0.0
        cnt = end - start
        # Manual RMS (no NumPy)
        for s in floats[start:end]:
            acc += s * s
        mean_sq = acc / cnt
        rms = math.sqrt(mean_sq)
        rms_list.append(rms)
    return rms_list


def rms_to_db_factors(rms_list, floor_db=-60.0, ceil_db=0.0):
    """Map per-frame RMS to [0..1] via dB scaling with clamping."""
    factors = []
    eps = 1e-12
    for rms in rms_list:
        db = 20.0 * math.log10(max(rms, eps))  # -inf..0
        if db <= floor_db:
            f = 0.0
        elif db >= ceil_db:
            f = 1.0
        else:
            f = (db - floor_db) / (ceil_db - floor_db)
        factors.append(f)
    return factors


def write_text_lines(path, values, fmt="{:.6f}"):
    with open(path, "w") as f:
        for v in values:
            f.write((fmt.format(v) if isinstance(v, (float, int)) else str(v)) + "\n")


def extract_frames(video_path, out_dir, fps, ext="jpg", qscale=3):
    out_dir.mkdir(parents=True, exist_ok=True)
    pat = str(out_dir / f"frame_%06d.{ext}")
    # Use fps filter to force exact count at CFR
    run([
        "ffmpeg", "-y", "-i", video_path,
        "-vf", f"fps={fps}", "-qscale:v", str(qscale), pat
    ])
    return pat


def process_frames_with_gmic(frames_dir, out_dir, factors, ext="jpg"):
    out_dir.mkdir(parents=True, exist_ok=True)
    n = len(factors)
    for i, factor in enumerate(factors, start=1):
        inp = frames_dir / f"frame_{i:06d}.{ext}"
        outp = out_dir / f"frame_{i:06d}.{ext}"
        if not inp.exists():
            # Stop if frames ended early
            break
        # Multiply brightness uniformly across channels
        # Using -clamp afterwards to avoid over/underflow surprises
        run(["gmic", str(inp), "-mul", f"{factor:.6f}", "-o", str(outp)])



def assemble_video_from_frames(frames_dir, fps, audio_path, out_path, ext="jpg"):
    pat = str(frames_dir / f"frame_%06d.{ext}")
    cmd = [
        "ffmpeg", "-y", "-framerate", f"{fps}", "-i", pat,
        "-i", audio_path,
        "-shortest",
        "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18",
        "-c:a", "aac", "-b:a", "192k",
        out_path,
    ]
    run(cmd)


def main():
    ap = argparse.ArgumentParser(description="Audio‑driven brightness for video using ffmpeg+G'MIC (Termux‑friendly)")
    ap.add_argument("--video", required=True, help="Input video (no audio used from it)")
    ap.add_argument("--audio", required=True, help="External audio (same duration as video)")
    ap.add_argument("--out", required=True, help="Output video path")
    ap.add_argument("--floor-db", type=float, default=-60.0, help="Silence floor dB → factor 0")
    ap.add_argument("--ceil-db", type=float, default=0.0, help="Loud ref dB → factor 1")
    ap.add_argument("--fps", default="auto", help="Frames per second (default: auto from video)")
    ap.add_argument("--sr", type=int, default=48000, help="Audio sample rate for analysis")
    ap.add_argument("--ext", default="jpg", choices=["jpg", "png"], help="Intermediate frame format")
    ap.add_argument("--qscale", type=int, default=3, help="FFmpeg qscale for frame extraction (2=best, 5=ok)")
    ap.add_argument("--keep", action="store_true", help="Keep temp folders")
    args = ap.parse_args()

    for binname in ("ffmpeg", "ffprobe", "gmic"):
        if shutil.which(binname) is None:
            print(f"ERROR: Required tool not found: {binname}")
            sys.exit(2)

    video = args.video
    audio = args.audio
    out = args.out
    fps, duration = probe_fps_duration(video)
    if args.fps != "auto":
        fps = float(args.fps)
    if fps <= 0:
        raise ValueError("Invalid FPS from video; specify --fps")

    work = Path("_audbright_work")
    frames_dir = work / "frames"
    proc_dir = work / "proc"
    work.mkdir(exist_ok=True)

    print("[1/6] Decoding audio → PCM ...")
    raw = decode_audio_to_pcm(audio, sr=args.sr)

    print("[2/6] Computing RMS per frame ...")
    rms = pcm_to_rms_per_frame(raw, sr=args.sr, fps=fps, duration_s=duration)

    print("[3/6] Mapping RMS→dB→factors ...")
    factors = rms_to_db_factors(rms, floor_db=args.floor_db, ceil_db=args.ceil_db)
    write_text_lines(work / "factors.txt", factors)

    print("[4/6] Extracting frames ...")
    extract_frames(video, frames_dir, fps=fps, ext=args.ext, qscale=args.qscale)

    print("[5/6] Applying brightness with G'MIC ... (this can take time)")
    process_frames_with_gmic(frames_dir, proc_dir, factors, ext=args.ext)

    print("[6/6] Reassembling video ...")
    assemble_video_from_frames(proc_dir, fps=fps, audio_path=audio, out_path=out, ext=args.ext)

    if not args.keep:
        shutil.rmtree(work, ignore_errors=True)

    print("Done:", out)

if __name__ == "__main__":
    main()
    

It helped produce this: Erik Satie - Gnossiennes : Free Download, Borrow, and Streaming : Internet Archive
The input video was done using one of the ultrawarp filters combined with fx_morph_layers IIRC… Then the AI did the rest with the audio levels informing the brightness / opacity via gmic.

I’m interested to know how you cleared the copyright aspect of doing this?

@EspE1
Not sure really, i never had a problem until something from Stereolab was flagged… so I got back to YouTube explaining i used my own copy of the song, it was not a monetized channel so no revenue was being taken from the artist and that it was a kind of “transformative” work… as the combination of their music with my animations made it into a separate artwork (I think I was stretching it with that one…).
Anyway, that seemed to work, as I didn’t have a problem after that, with most artists, except with stuff by the Cocteau Twins for some reason.