[Tinypy] Tips & docs on Avidemux .py scripting

Started by butterw, January 17, 2021, 01:18:41 PM

Previous topic - Next topic

butterw

Avidemux Scripting Tips

Avidemux uses python syntax Tinypy scripts (.py) for its project files and custom user scripts (which can be added to the Auto and Custom menus):
https://www.avidemux.org/admWiki/doku.php?id=using:tinypy
Tinypy can be used for batch processing, to apply an output settings template on the loaded video, or to add commands to the custom menu in Avidemux.

The simplest way to get a working a .py script is just to save your current project (File > Project Script > Save As Project...)
It is then possible to edit/modify the python script as desired.
The script can then be run with  (File > Project Script > Run Project...)

In this thread I'll post a few tips that could be useful to first-time users and that are not mentionned in the previous link.
Please posts your own tips, examples, or links to relevant threads.

Thread index:
#Some functions
#File Management
#Segments
#Saved project script files (.py)

butterw

#1
Using the Interactive Shell:
To test a script it is easier (and safer) to first try with the interactive shell (Tools > Scripting Shell) where you can execute blocks of code.
- to display the value of x you need to use print(x) or for more elegant output: print("x: ", x) and Enter a blank line after.
- or it could be shortened by defining a shorter custom print function pp:
def pp(*args): print("  ", *args) - if you evaluate an undefined variable or function name XXX, you will get an Exception KeyError: XXX
- The shell variables are persistent until you close Avidemux (this is not the case for variables in scripts).
- ! In Python, indentation is syntax. Incorrect indentation or mixing tabs and spaces will cause ("tokenize") errors.
- ! Tinypy only supports a very small subset of Python. Many built-in functions missing: type, isinstance, dir, etc.
- no string manipulation beyond (+, s.replace("ab", "cde")) is possible !!!, len(s) and the in operator work
- tuples are converted to lists
- functions do not support named parameters 
- 1e3 evals to 1 !!!
- import math works , it's also possible to write your functions in a separate file (but it's simpler to just copy/paste your code in your source file)
- The list of all available functions can be obtained from the shell, by typing help(), Classname.help() (ex: Editor.help())
- ! Python bindings are not yet available for many Avidemux menu commands.
- ! With some bindings the preview and markers will not update in the GUI until you close the shell.
- a basic dialog box (with checkbox, combo-box, integer spin-box, File Select) can be displayed for user input and info.
 

Display a popup info message:
gui = Gui()
## !!! Affected by Preferences > Message Level: if set to Error Only, use displayError instead of displayInfo
# gui.displayInfo("Append", "Done") # Displays a popup message
gui.displayError("Append", "almost Done..."+"\n"+"fully Done")
! You need to ensure the title and the message to display in the popup are strings.
msg = str(2/3) # "0.666667"
gui.displayError("Test", msg)

A simple script loading a video, the useful info is in # comments:
#PY  <- Needed to identify #
# !!! No very long lines: line cannot exceed 200 chars: split it and add "\" at the end of each split line.

## Open file tmp1.mp4 (fname must be correct !!!) and displays a message
fname = "B:/Videos/out/tmp1.mp4" #a str variable, on Windows use / OR  filepaths will need to be escaped : "B:\\Videos\\out\\tmp1.mp4"
adm = Avidemux()
if not adm.loadVideo(fname):
    raise("Cannot load " + fname) #Exception displayed in msgbox popup
w = adm.getWidth() #the width of loaded video
h = adm.getHeight()
print(" res:", w, "*", h) #prints to admlog.txt (Help > Advanced > Open Application Log)

The loaded video replaces the existing video, the A,B markers are set to the first and the last frame (end) of the video respectively. Current Position is set to the first frame. DefaultSettings.py file is loaded.

butterw

#2
!!! New functions have been added in v2.7.7 (in bold)
Attempting to use the new functions in earlier versions of Avidemux will fail with TinyPy:Exception Key Error:xxx, where xxx is the new command.
Note: filepath designates a str, ex: "B:/Videos/out/tmp1.mp4"

# Loading/Appending/Saving
adm.loadVideo(filepath)
adm.appendVideo(filepath) #Multiple source files !
adm.clearSegments() #n=0, end=0
adm.addSegment(source_int, start_pts, duration) #source_int=0 if there is only one source file
ed.nbSegments() #0 if no video is loaded, n>1 if the source has multiple segments
! input can be a project, edits generate multiple segments even if there is only one source video file.
ed.nbVideos() #the number of source video files. Appending the same video multiple times counts as multiple source videos
ed.getVideoDuration()   ##Current end, pts after last frame. real video duration is: end-firstframe
adm.save(filepath) # overwrites, 0 if it couldn't save.
adm.saveAudio(int_idx, filepath) #Saves the raw audio (ex: .aac) with the corresponding track index.   
! What is saved depends on A, B Marker values. To save to the previous frame, set markerB to ptsB-1   
! Values of end and current position should be coherent when saving.

# Video File Properties
adm.getWidth(); adm.getHeight()
adm.getFps1000() # average frame rate multiplied by 1000, so a 25fps video will return 25000
adm.getVideoCodec() #ex: H264
adm.audioTotalTracksCount()

# Audio
adm.audioClearTracks()  ##Disable all Audio tracks (Mute)
adm.audioTracksCount() ##Current number of Audio tracks
adm.audioAddExternal(filepath) ## Load an external Audio track

# Get Position timestamps (Pts in micro-seconds)
ed.getCurrentPts()
ed.getPrevKFramePts(-1) #previous Keyframe based on current position, returns -1 on the first KeyFrame
ed.getNextKFramePts(-1) #next Keyframe based on current position, -1 on/after last KeyFrame
## or (pts) #previous/next Keyframe based on pts value.

# Seeking << updates
adm.seekFrame(n) #seeks with n frames offset, ex: 1, 10, -1  updates the GUI. Returns 0 if the requested seek is limited by the first/last video frame.
adm.seekKeyFrame(int) #ex: -1 prev, +1 next, +10 Keyframes #updates the preview from interactive shell, ! always returns None 
## (0): no-op if current frame is a keyframe, otherwise seeks to previous keyframe.
adm.setCurrentPts(pts) #expects exact pts, seeks to the last keyframe before the given time otherwise. pts must be in [0, end] range, returns 0 otherwise.

# Get and Set the A, B selection markers
adm.markerA #default value: 0
adm.markerB #default value: end

# Saving images:
adm.saveJpeg(filepath); adm.savePng(filepath); adm.saveBmp(filepath)

# Video Codec, Filters
adm.videoCodec("Copy") #no re-encoding Copy Mode
adm.videoCodec("x264") ##set video codec (codec parameters will be set to Avidemux defaults, crf=20. The full x264 codec configuration is a long list of parameters -> for a more compact approach set a Profile), returns 1 if parameters were applied.
adm.videoCodecSetProfile("x264", "iPhone") #set video codec + the settings of profile iPhone.json, returns 1 if the profile could be loaded
adm.clearVideoFilters()  #removes all video filters

eumagga0x2a

The following sample script will try to cut currently loaded video in approximately equal chunks in copy mode with a single audio track, files are named 00.mp4 up to 99.mp4, the muxer configuration is not included:

adm = Avidemux()
ed = Editor()
gui = Gui()
# If no video is loaded, we will crash. The check below doesn't work.
if 0 == ed.nbSegments():
    gui.displayError("No video loaded!")
    return
# video
adm.videoCodec("Copy")
# audio
tracks = adm.audioTracksCount()
if tracks > 0:
    adm.audioClearTracks()
    adm.setSourceTrackLanguage(0,"Unknown")
    adm.audioAddTrack(0)
    adm.audioCodec(0, "copy")
# choose output directory
outdir = gui.dirSelect("Select the target folder")
if outdir is None:
    gui.displayError("No output folder selected, bye")
    return
# choose chunk duration (1 to 10 minutes)
spin = DFInteger("Chunk duration in minutes:",1,10)
spin.value = 2
dialog = DialogFactory("Split video in chunks")
dialog.addControl(spin)
if 1 != dialog.show():
    return
# set some variables
chunk = spin.value
chunk *= 60        # chunk duration in seconds
chunk *= 1000000  # chunk duration in microseconds (us)
duration = ed.getVideoDuration() # video duration in us
start = 0
end = chunk
sep = "/"
ext = ".mp4"
count = 1
zero = "0"
# loop!
while True:
    adm.markerA = start
    if end > duration:
        end = duration
    adm.markerB = end
    if count > 9:
        zero = ""
    outfile = outdir + sep + zero + str(count) + ext
    if 1 != adm.save(outfile):
        gui.displayError("Error saving "+outfile)
        return
    if end >= duration:
        break
    start += chunk
    end += chunk
    count += 1
    if count > 99:
        gui.displayError("Number of chunks exceeds max = 99")
        return
# done
gui.displayInfo("Everything done","")

butterw

#4
File Management

# Directory and file selection dialogs. They return filepaths, or None if cancel was chosen
folder = gui.dirSelect("Select folder")
filepath = gui.fileReadSelect("Select input file")
filepath = ed.getRefVideoName(0)  #filepath of the first loaded file
gui.fileWriteSelect("Select output file") # If the file already exists, you'll be prompted to overwrite it
gui.fileWriteSelectEx("Select output file", "mp4") # same as gui.fileWriteSelect if extension is "": no filter is applied
gui.fileReadSelectEx("Select input file", "mp4")

# Filepath
lst=get_folder_content("/home/foo/videos", "avi") # returns a list of filepaths, None if no files match or the folder isn't found
- get_file_size(filepath) #in bytes. A float equal to 2**32-1 is returned if the filepath isn't found
- dirname(filepath); basename(filepath)
- splitext(filepath_with_ext) #returns a list of two elements [root, ext]
filepath is split into root + extension. The period between root and extension is dropped.

# Examples
fpath = "b:/Videos/file1.mp4"
dirname(fpath) #"b:/Videos"
bname = basename(fpath) #"file1.mp4"
#dirname/basename functions return None if the filepath isn't found
if fpath is not None: ext=splitext(fpath)[1] #"mp4"
splitext(fpath)[0] #"b:/Videos/file1"
splitext(bname)[0] #"file1"
fname_root, ext = splitext(fpath)

#ex: fileWriteSelect(); fileWriteSelect("Title"); fileWriteSelect(ext="mp4"); fileWriteSelect("Title", "mp4")
def fileWriteSelect(title="Please Select output file", ext=None):
# this custom function uses the container extension from the GUI by default in the fileWriteSelect Dialog
if ext is None: ext=adm.getOutputExtension()
return gui.fileWriteSelectEx(title, ext)

#ex: filelist = get_folder_content("b:/Videos", "mp4"); totalFilesize(filelist) #117.456000
def totalFilesize(filelist):
# returns total Filesize in MiBytes (1e6 Bytes)
# filelist: a list of filepath strings
out = 0.0
for fpath in filelist:
out+= get_file_size(fpath)/1000000.0
return out
 

butterw

#5
Some basic code blocks

# import math
# math.pow(10, 6) ##1e6
# math.sqrt(16.5)
adm=Avidemux(); ed=Editor(); gui=Gui()
sec = 1000*1000

#shorter pretty print for interactive shell
def pp(*args): print("  ", *args)   
def pt(*args): #print times in s instead of micro-seconds
res=""
for elt in args: res+= "  " + str(elt*0.000001)
print(res)
def gd(): return ed.getVideoDuration() #get end
def gt(): return ed.getCurrentPts()

eumagga0x2a

Quote from: butterw on January 18, 2021, 09:52:33 AM# Seeking
adm.seekKeyFrame(int) ##ex: -1, +1, +10 Keyframes #updates the preview from interactive shell
ed.nextFrame()

# Saving images:
adm.saveJpeg(filepath); adm.savePng(filepath); adm.saveBmp(filepath)

It is worth noting that ed.nextFrame() contrary to adm.seekKeyFrame doesn't update the preview buffer adm.saveJpeg and the other two methods use as source. This means that while it is possible to export each N-th keyframe as an image, doing the same for each N-th frame is not possible.

butterw

#7
Quote from: eumagga0x2a on January 17, 2021, 10:35:20 PM# choose output directory
outdir = gui.dirSelect("Select the target folder")
if outdir is None:
    gui.displayError("No output folder selected, bye")
    return

- gui.dirSelect("Select the target folder") generates an exception if Cancel is chosen (at least on Windows).
- Also gui.displayError requires 2 string parameters.

The correct code (to display the error message) is as follows:
try:
    outdir=gui.dirSelect("Select the target folder")
except:
    gui.displayError("No output folder selected", "bye");
    return


I'm running a script from the GUI. Is there a way to get the name and path of the loaded video needed for adm.save(filepath) ?
The closest I get is opening the filesave dialog with gui.fileWriteSelect("Output Filename"), but the extension is not auto-selected.
ex:
- loaded: "B:/videos/myvideo.mp4"
- gui.fileWriteSelect default value: B:/videos/myvideo.*

out=gui.dirSelect("Dir") opens the input dir by default.


eumagga0x2a

Quote from: butterw on January 20, 2021, 12:18:43 PM- gui.dirSelect("Select the target folder") generates an exception if Cancel is chosen (at least on Windows).

It shouldn't. Does your Avidemux build include this commit?

Quote from: butterw on January 20, 2021, 12:18:43 PM- Also gui.displayError requires 2 string parameters.

Oops, indeed. Thank you for pointing out. A fixed version below, this time I really tried it :-D

The check for video not loaded actually works now.

adm = Avidemux()
ed = Editor()
gui = Gui()
if 0 == ed.nbSegments():
    gui.displayError("Error","No video loaded!")
    return
# video
adm.videoCodec("Copy")
# audio
tracks = adm.audioTracksCount()
if tracks > 0:
    adm.audioClearTracks()
    adm.setSourceTrackLanguage(0,"Unknown")
    adm.audioAddTrack(0)
    adm.audioCodec(0, "copy")
# choose output directory
outdir = gui.dirSelect("Select the target folder")
if outdir is None:
    gui.displayError("Error","No output folder selected, bye")
    return
# choose chunk duration (1 to 10 minutes)
spin = DFInteger("Chunk duration in minutes:",1,10)
spin.value = 2
dialog = DialogFactory("Split video in chunks")
dialog.addControl(spin)
if 1 != dialog.show():
    return
# set some variables
chunk = spin.value
chunk *= 60        # chunk duration in seconds
chunk *= 1000000  # chunk duration in microseconds (us)
duration = ed.getVideoDuration() # video duration in us
start = 0
end = chunk
sep = "/"
ext = ".mp4"
count = 1
zero = "0"
# loop!
while True:
    adm.markerA = start
    if end > duration:
        end = duration
    adm.markerB = end
    if count > 9:
        zero = ""
    outfile = outdir + sep + zero + str(count) + ext
    if 1 != adm.save(outfile):
        gui.displayError("Error","Error saving "+outfile)
        return
    if end >= duration:
        break
    start += chunk
    end += chunk
    count += 1
    if count > 99:
        gui.displayError("Error","Number of chunks exceeds max = 99")
        return
# done
gui.displayInfo("Everything done","")

Quote from: butterw on January 20, 2021, 12:18:43 PMI'm running a script from the GUI. Is there a way to get the name and path of the loaded video needed for adm.save(filepath) ?

No, I don't think so. The full path can be obtained in Avidemux using admCoreUtils::getLastReadFile(std::string &fullpath), but it is not wired into Python scripting interface.

Quote from: butterw on January 20, 2021, 12:18:43 PMThe closest I get is opening the filesave dialog with gui.fileWriteSelect("Output Filename"), but the extension is not auto-selected.

Regarding the file dialog, gluing fileWriteSelect via pyFileSelWrite to GUI_FileSelReadExtension is on my todo list (probably post-release).

butterw

Quote from: eumagga0x2a on January 21, 2021, 12:33:22 AM
Quote from: butterw on January 20, 2021, 12:18:43 PM- gui.dirSelect("Select the target folder") generates an exception if Cancel is chosen (at least on Windows).

It shouldn't. Does your Avidemux build include this commit?
Indeed, it's fixed in the latest dev build.

butterw

#10
Split scripts

- eumagga0x2a export script saves in ex:2min chunks.
- A script could split in a chosen number of parts on the same principle.

For custom splits using the GUI preview, it's possible to split in up to 4 parts by using the current position and the Markers as split points.
! To ensure there is no overlap between parts (with Copy mode Save) markerA needs to be set on a keyframe. Marker B needs to be set on the frame before the keyframe.

ed.getPrevKFramePts(pts) has recently been added in dev version (v2.7.7-29 Jan 2021).
This could for example be used to check whether a position timestamp is a keyframe:
pts = ed.getCurrentPts()
if pts == ed.getPrevKFramePts(pts+1): print("current frame is a keyframe")

ed.getCurrentFlags()
returns 16 for I-FRM (2)
16384 for B
0 for P
-1 for ?, there is no frame


I've written a simple script to allow the user to split on Markers (set on keyframes in the GUI) without overlap between parts.
My problem is that I need to know the timestamp of the frame before the keyframe to be able to split without overlapping the marker B frame.
It seems currently the only way to do this would be to iterate ed.nextFrame() from the previous keyframe ? 

butterw

I see some new methods have just been added for seeking.

I'm assuming it is preferable to avoid seeking when possible and just look-up Pts ?
If it's easy to do I would suggest adding:
getNextPts(n=1) #get the Pts of a next (or previous) nth Frame
 






eumagga0x2a

The main addition is seekFrame(int N) in Avidemux() which allows to display a frame N frames from the current (so that seekFrame(0) is no-op).

adm = Avidemux()
adm.seekFrame(-1)

displays the previous frame.

The main change is getCurrentPts() in Editor() returning the PTS of the displayed picture, not the maximum PTS output by the decoder.

Therefore please don't use nextFrame() in Editor() unless you just need to test that decoding succeeds. Current PTS returned by getCurrentPts() is not updated on nextFrame(). Please use adm.seekFrame(1) instead.

A minor change is seekKeyFrame(int N) counting from the closest earlier keyframe if the current position is not at a keyframe. This means, adm.seekKeyFrame(0) is no-op when the current picture is a keyframe, will seek to the previous keyframe if not.

Quote from: butterw on January 26, 2021, 09:55:47 AMI'm assuming it is preferable to avoid seeking when possible and just look-up Pts ?

This would be preferable by far, but very unreliable before the entire video has been decoded. This is also the reason why 2.5.x frame-based approach turned out to be not viable with modern codecs.

eumagga0x2a

Quote from: butterw on January 23, 2021, 04:27:19 PMed.getPrevKFramePts(pts) has recently been added in dev version.
If I understand correctly, this could for example be used to check whether a position timestamp is a keyframe:
pts = ed.getCurrentPts()
if pts == ed.getPrevKFramePts(pts+1): print("current frame is a keyframe")

Such workarounds should become unnecessary once getCurrentFrameFlags() gets wired to the Python interface.