Detect video chapters

This recipe adds chapters on scene changes.

Chapters are a good indication of major moments in any edited video or raw source. Video file can be improved a lot by adding this metadata to make the video more convenient.

Download the recipe from the extended collection here:
https://magnetron.app/recipes/detect-video-chapters

For Windows & macOS

FFmpeg has a nice function for automatic scene change detection. In this recipe we combine this feature with adding these scene changes as chapter metadata to the file. Making longer video files easier to skip through.

You can run this recipe with a magnetron.app license key.
With magnetron.dev you can also use the source to create your own custom processes.

Take a closer look at the complete source below

the dialog

(added July 2023)

header={
  "recipe_version": "1.12",
  "title": "Detect video chapters",
  "description": "Add chapters to a video using ffmpeg automatic scene change detection",
  "tags": "default",
  "chef": "BeatRig",
  "dependencies": "ffprobe,ffmpeg",
  "spice": "BQ==:G+Hy36kJcnQQYYavb/JV2Il6eCYr6h4hRJHCF9rRbhX5BcazL6cuTgUMdz0xA50VWA5PmmV0Q7aWkiUtNFtbk991l+t0e8IBXMReMIT1GNjnZZDv7n2YRAo0xcSQbY0ixjG6LLH4wGKnspKAoA/v69iLn6N2IH0XzIZwTGdHSo4=",
  "flavour": "SQ++6SwhLs7yy/tRoTktVDMwiWHexUzcQFH//4T9yRyKzAzfAQmwBCIa+GJUb4HCLyV0KlXi1PqZ/n1n67hkjGnNkC7PM4jTrkBZuFuJ51f3e0GSaBaZvTg3HUcAywPxDgGVL6uiO5wNZaZAVehf2udtu0yrIdAeQSuLJi0bGl0=",
  "time": 1694210484,
  "core_version": "0.5.7",
  "magnetron_version": "1.0.261",
  "functions": "main",
  "uuid": "d18f14dbd9af4b43be368a3b9afbabd2",
  "instructions": "Drop a video file and hit run to make a copy with chapters added",
  "type": "default",
  "os": "windows,macOS",
  "palette": "Clean Slate"
};

//----------------------------------------------------------------------------
function getDur(infile)
{
    var probeoutput = cmd("ffprobe", [infile]);
    var dur_tc = findSubString(probeoutput, "Duration: ", ", s");    
    return calculateSeconds(dur_tc);    
}

function calculateSeconds(dur_tc)
{
    var hours = (dur_tc.substring(0, 2));
    var minutes = (dur_tc.substring(3, 3+2));
    var seconds = (dur_tc.substring(6, 6+2));
    if (hours.substring(0, 1) == "0")
        hours = hours.substring(1,2);
    if (minutes.substring(0, 1) == "0")
        minutes = minutes.substring(1,2);
    if (seconds.substring(0, 1) == "0")
        seconds = seconds.substring(1,2);
    hours = parseInt(hours);
    minutes = parseInt(minutes);
    seconds = parseInt(seconds);
    
    totalsec = (hours * 60 * 60) + (minutes * 60) + seconds;
    return totalsec;
}

function convertExpNotFloat(str)
{
    pos = (str + "").indexOf('e'); // [coefficient, exponent]
    if (pos > 0)
    {
        x = toFixed(str, 1); // allow 1 dec to negate exponent
        return x.substring(0, x.length-2); // cut off . and decimal
    }    
    return str;
}

function main()
{
    items = getItems();
    files = getFiles();
    items.numfiles = 1;

    if (files.length > 0)
    {
        inputpath = files[0]["path"];
        var inputinfo = getPathInfo(inputpath);
        if (inputinfo)
            outputpath = inputinfo.folder;// + info.sep + info.basename + "-chapters." + info.ext;
    }
    else
    {
        inputpath = "";
        outputpath = "";
    }
    echo(outputpath);

    form = {
        "header" : {
            "type" : "text",
            "label" : "This process will add chapters in videos at scene changes",
            "just" : "l",
            "bounds" : { 
                "x": 10, 
                "y" : 5, 
                "w" : 600, 
            }, 
        },
        "file1_header" : {
            "type" : "text",
            "label" : "source file",
            "just" : "l",
            "bounds" : { 
                "w" : 120, 
            }, 
        },
        "inputfile" : {
            "type" : "fileselect",
            "path" : inputpath,
            "editable" : false,
            "dir" : false,
            "saving" : false,
            "label" : "source file",
            "bounds" : {
                "x" : 130,
                "y" : -1, 
                "w" : 470,
            }, 
        },
        "sens_header" : {
            "type" : "text",
            "label" : "detection sensitivity",
            "just" : "l",
            "bounds" : { 
                "w" : 180,
            }, 
        },
        "sens": { 
            "type": "slider", 
            "style": "slider_h",
            "visible": true, 
            "range" : { 
                "min": 0.01, 
                "max" : 1., 
                "interval" : 0.01, 
                "decimals" : 2 
            }, 
            "bounds" : {
                "x" : 190,
                "y" : -1, 
                "w" : 280,
            }, 
            "value": 0.45 
        }, 
        "sens_label" : {
            "type" : "text",
            "label" : "default",
            "just" : "c",
            "bounds" : { 
                "x": 480, 
                "y" : -1, 
                "w" : 120, 
            }, 
        },

        "chapterlength_header" : {
            "type" : "text",
            "label" : "minimum chapter secs",
            "just" : "l",
            "bounds" : { 
                "w" : 180,
            }, 
        },
        "chapterlength": { 
            "type": "slider", 
            "style": "slider_h",
            "visible": true, 
            "range" : { 
                "min": 1, 
                "max" : 60 * 30, 
                "interval" : 1, 
                "decimals" : 0 
            }, 
            "bounds" : {
                "x" : 190,
                "y" : -1, 
                "w" : 360,
            }, 
            "value": 15
        },
        "file2_header" : {
            "type" : "text",
            "label" : "output folder",
            "just" : "l",
            "bounds" : { 
                "w" : 120, 
            }, 
        },
        "outputfile" : {
            "type" : "fileselect",
            "path" : outputpath,
            "editable" : false,
            "dir" : true,
            "saving" : true,
            "label" : "select output folder",
            "bounds" : {
                "x" : 130,
                "y" : -1, 
                "w" : 470,
            }, 
        },
        "okay" : {
            "type" : "button",
            "label" : "Start",
            "bounds" : { 
                "w" : 280, 
            }, 
            "returns" : 1
        },
        "cancel" : {
            "type" : "button",
            "label" : "cancel",
            "bounds" : { 
                "y": -1,
                "x": 320,
                "w" : 280
            }, 
            "returns" : 0
        },
    };
    
    var r = dialog(form);
    echo(objectToString(r));

    if (r.okay == false)
        return;

    if (r.inputfile != "undefined")
    {
        var info = getPathInfo(r.inputfile);
        if (r.outputfile == "")
            outpath = info.foldder + gvar.pss + info.basename + "-chapters." + info.ext;
        else outpath = r.outputfile + gvar.pss + info.basename + "-chapters." + info.ext;

        items.totalsec = getDur(info.path);
        items.fileindex = i;
        items.part = 0;
        items.terminated = false;
        items.numfiles = 1;
        setItems(items);
        echo("total sec:" + totalsec);

        if(isCanceled())
            return;

        setMainMessage("Detect scene changes");
        setProgress(10);

        sensitivity = r.sens;
        ffmpegArgs = ["-y",
            "-i",
            r.inputfile,
            "-vf",
            "select='gt(scene," + toFixed(1. - sensitivity, 2) + ")',showinfo"
        ];

        ffmpegArgs.push("-f");
        ffmpegArgs.push("null");
        ffmpegArgs.push("-");


        echo(objectToString(ffmpegArgs));
        var rawScenedata = (cmd("ffmpeg", ffmpegArgs));
        echo(rawScenedata);
        var sceneSecs = searchRegEx(rawScenedata, "pts_time:\s*([^\s]+)", "i", -1);
        items = getItems();
        
        if (items.terminated)
            abort("failed to process file\ntry using the large muxing queue option");

        if(isCanceled())
            return;

        setMainMessage("Filter scenes");
        setProgress(80);

        var sceneSecsFilter = [];
        var sec=0.;
        for (j=0; j<sceneSecs.length; j++)
        {
            x = parseFloat(sceneSecs[j]);
            if (x - sec > r.chapterlength) // minimal 10 sec
            {
                sceneSecsFilter.push(x);
                sec = x;
            }
        }
        if (parseFloat(items.totalsec) > sceneSecsFilter[sceneSecsFilter.length-1])
            sceneSecsFilter.push(items.totalsec); // add total length as end
        else sceneSecsFilter.push(sceneSecsFilter[sceneSecsFilter.length-1] + 1);
        echo("scene secs: " + objectToString(sceneSecsFilter));

        var chapters = "\n";
        for (j=0;j<sceneSecsFilter.length-1;j++)
        {
          /*[CHAPTER]
            TIMEBASE=1/1000
            START=1
            END=448000
            title=Magnetron */
            start = convertExpNotFloat(sceneSecsFilter[j] * 1000.);
            end = convertExpNotFloat((sceneSecsFilter[j+1] * 1000.) - 1);

            chapters += "[CHAPTER]\nTIMEBASE=1/1000\nSTART=" + start + 
                        "\nEND=" + end + 
                        "\ntitle=Chapter-" + (j + 1) + "\n\n";
        }

        items.part = 1;
        setItems(items);

        if(isCanceled())
            return;
    
        setMainMessage("get original metadata");
        setProgress(90);

        metadata_txt_path = folders.temp + gvar.pss + "metadata.txt";
        makeFolder(folders.temp);        
        ffmpegArgsExtract = ["-y",
            "-i",
            info.path,
            "-f",
            "ffmetadata",
            metadata_txt_path
        ];
        echo(objectToString(ffmpegArgsExtract));        
        var rawExtractData = cmd("ffmpeg", ffmpegArgsExtract);
        echo(rawExtractData);

        // add metadata
        appendFile(metadata_txt_path, chapters);
        echo(chapters);
        
        setMainMessage("mux new metadata into video");
        setProgress(95);

        ffmpegArgs2 = ["-y",            
            '-i',
            info.path,
            '-i',
            metadata_txt_path,
            '-map',
            '0',
            '-map_metadata',
            '1',
            '-c',
            'copy',
            outpath
        ];
        echo(objectToString(ffmpegArgs2));
        echo(cmd("ffmpeg", ffmpegArgs2));
    }

    setProgress(100);
    setMainMessage("done");
}

// ---------------------------------------------------------------------
function cmd_callback(cmd_name, cmd_output)
{
    if(cmd_name == "ffmpeg" && cmd_output.length > 0)
    {
        packet = findSubString(cmd_output, "Error submitting a ", " to the muxer"); // packet
        if (packet != "packet")
        {
            items = getItems();
            var progressSec = calculateSeconds(findSubString(cmd_output, "time=", " bitrate"));
            progress = 10. + Math.round((progressSec / items.totalsec * (70. / 2. / items.numfiles)) + ((70. / items.numfiles) * items.fileindex) + (70. / 2. / items.numfiles * items.part));
            setProgress(progress);
            setMainMessage((progress) + "%");
        }
        else
        {
            items = getItems();
            items.terminated = true;
            setItems(items);
            return { "terminate": true };    
        }
    }

    return {
        "terminate": isCanceled(),
        "input": ""
    };
}

function dialog_callback(props)
{
    if (props["name"] == "inputfile")
    {
        var info = getPathInfo(props["path"]);
        var newprops;
        newprops = { "outputfile" : { "path" : info.folder } };
        
        dialog(newprops);
    }
    if (props["name"] == "sens")
    {
        var newprops;
        if (props["value"] < 0.25)
            newprops = { "sens_label" : { "label" : "few chapters" } };
        else if (props["value"] < 0.5)
            newprops = { "sens_label" : { "label" : "less chapters" } };
        else if (props["value"] < 0.75)
            newprops = { "sens_label" : { "label" : "more chapters" } };
        else newprops = { "sens_label" : { "label" : "many chapters" } };
        
        dialog(newprops);
    }
}