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 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 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); } }