Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Script to quickly convert and crop videos from within mpv

#README:

This script for mpv intends to offer the fastest and simplest way to convert parts of a video—while you’re watching it and not really more work intensive than making a screenshot. A short demonstration: https://d.maxfile.ro/omdwzyhkoa.webm

##Installation:

You need:

  • yad (at least 0.26) (AUR)
  • mpv (at least 0.4)
  • notify-send (optional, to send a notification when the encode is done) (to be found in libnotify on Arch)
  • mkvmerge (optional, for stream copy)
  • imagemagick (optional, for gifs)
  • linux

Install it by putting the script into ~/.config/mpv/scripts/ or ~/.mpv/scripts/ (mpv 0.8+) and ~/.config/mpv/lua/ ~/.mpv/lua/ respectively (mpv 0.4 through 0.7)

##Usage:

alt + w activates the script. First activation marks one end of the clip, the second one the other, order does not matter.

Cropping doesn’t really work well with the OSC.

##Configuration:

Configuration works in the same way as configuring the OSC does. You can bind it to another hotkey in your input.conf with <key> script_message convert_script and set some options in mpv/lua-settings/convert_script.conf or with --script-opts (mpv 0.7 and earlier: --lua-opts)

audio_bitrate
Default: 112
In kbps. The script tries to compensate for audio when going for a file size limit (2pass). You’ll only need to change this if you modify mpv’s audio encoding options, for example with oacopts-add=qscale=0 in the [encoding] section of your mpv.conf (in which case this option would need 64). 

bitrate_multiplier
Default: 0.975
To make sure the file won’t go over the target file size, set it to 1 if you don’t care.

output_directory
Default: $HOME
Where the script saves your converted files. 

use_pwd_instead
Default: no
Use your working directory as the output directory. Overrides output_directory.

use_same_dir
Default: no
Use the directory of the original video file. Overrides output_directory.

libvpx_threads
Default: 4
Number of threads to use for encoding, libvpx only because it apparently doesn’t have auto detection. 

crop_individually
Default: yes
Whether you want only the last crop argument to count—no—or you want to crop all segments individually—yes. In that case the final video will inherit the aspect ratio of the first segment. 

libvpx_options
Default: --ovcopts-add=cpu-used=0,auto-alt-ref=1,lag-in-frames=25,quality=good
Higher quality at the expense of processing time.

legacy_yad
Default: no
If you don’t want to upgrade your yad. Features like appending segments won’t be available. 

libvpx_fps
Default: --oautofps
FPS settings (or any other settings really) for libvpx encoding. Set it to --ofps=24000/1001 for example. 

Warning: Some of these options aren’t very robust and setting them to bogus values will break the script.

local assdraw = require 'mp.assdraw'
local msg = require 'mp.msg'
local opt = require 'mp.options'
local utils = require 'mp.utils'
-- default options, convert_script.conf is read
local options = {
bitrate_multiplier = 0.975, -- to make sure the file won’t go over the target file size, set it to 1 if you don’t care
output_directory = "$HOME",
use_pwd_instead = false, -- overrides output_directory
use_same_dir = false, -- puts the output files where the original video was
libvpx_threads = 4, -- libvpx only
crop_individually = true,
libvpx_options = "--ovcopts-add=cpu-used=0,auto-alt-ref=1,lag-in-frames=25,quality=good",
libvpx_vp9_options = "",
legacy_yad = false, -- if you don’t want to upgrade to at least yad 0.18
libvpx_fps = "--oautofps", -- --ofps=24000/1001 for example
audio_bitrate = 112, -- mpv default, in kbps
}
read_options(options, "convert_script")
options.output_directory = '"' .. options.output_directory .. '"'
yad_table = {}
-------------------------------------
-- Rectangle selection and drawing --
-------------------------------------
alpha = 180
rect_x1, rect_x2, rect_y1, rect_y2 = 0, 0, 0, 0
function render()
ass = assdraw.ass_new()
ass:draw_start()
ass:append(string.format("{\\1a&H%X&}", alpha))
ass:rect_cw(rect_x1, rect_y1, rect_x2, rect_y2)
ass:pos(0, 0)
ass:draw_stop()
mp.set_osd_ass(width, height, ass.text)
end
function tick()
if set_mouse_area then
mp.set_mouse_area(0, 0, width, height, "draw_rectangle")
end
end
function rect_button_press_event(mouse, event)
if event == "down" then
rect_x1, rect_y1 = mp.get_mouse_pos()
rect_x2, rect_y2 = rect_x1, rect_y1
button_pressed = true
elseif event == "up" then
button_pressed = nil
if (rect_x1 == rect_x2) and (rect_y1 == rect_y2) then
mp.set_osd_ass(width, height, "")
end
if rect_x2 > rect_x1 then
rect_width = rect_x2 - rect_x1
else
rect_width = rect_x1 -rect_x2
rect_x1 = rect_x2
end
if rect_y2 > rect_y1 then
rect_height = rect_y2 - rect_y1
else
rect_height = rect_y1 -rect_y2
rect_y1 = rect_y2
end
if rect_x1 < 0 then
rect_x1 = 0
end
if rect_y1 < 0 then
rect_y1 = 0
end
if (rect_width + rect_x1) >= tonumber(width) then
rect_width = width - rect_x1
end
if (rect_height + rect_y1) >= tonumber(height) then
rect_height = height - rect_y1
end
call_gui()
end
end
function mouse_move()
if button_pressed then
rect_x2, rect_y2 = mp.get_mouse_pos()
render()
end
end
-----------------
-- Main script --
-----------------
function convert_script_hotkey_call ()
if mp.get_property("playback-time") then
playback_time = "playback-time"
else
playback_time = "time-pos"
end
set_mouse_area = true
width = mp.get_property("dwidth")
height = mp.get_property("dheight")
mp.set_osd_ass(width, height, "")
if timepos1 then
timepos2 = mp.get_property_native(playback_time)
timepos2_humanreadable = mp.get_property_osd(playback_time)
if tonumber(timepos1) > tonumber(timepos2) then
length = timepos1-timepos2
start = timepos2
start_humanreadable = timepos2_humanreadable
end_humanreadable = timepos1_humanreadable
msg.info("End frame set")
elseif tonumber(timepos2) > tonumber(timepos1) then
length = timepos2-timepos1
start = timepos1
start_humanreadable = timepos1_humanreadable
end_humanreadable = timepos2_humanreadable
msg.info("End frame set")
else
msg.error("Both frames are the same, ignoring the second one")
mp.osd_message("Both frames are the same, ignoring the second one")
timepos2 = nil
return
end
timepos1 = nil
call_gui()
else
timepos1 = mp.get_property_native(playback_time)
timepos1_humanreadable = mp.get_property_osd(playback_time)
msg.info("Start frame set")
mp.osd_message("Start frame set")
end
end
------------
-- Encode --
------------
function preparations()
video = mp.get_property("path")
video = string.gsub(video, "'", "'\\''")
if options.use_pwd_instead then
local pwd = os.getenv("PWD")
pwd = string.gsub(pwd, "'", "'\\''")
options.output_directory = "'" .. pwd .. "'"
end
if options.use_same_dir then
options.output_directory = "'" .. utils.split_path(video) .. "'"
end
local filename_ext = mp.get_property_osd("media-title")
filename_ext = string.gsub(filename_ext, "'", "'\\''")
local filename = string.gsub(filename_ext, "%....$","")
metadata_title = filename
if string.len(filename) > 230 then
filename = mp.get_property("options/title")
if filename == 'mpv - ${media-title}' or string.len(filename) > 230 then
filename = 'output'
end
end
filename = filename .. " " .. start_humanreadable .. "-" .. end_humanreadable .. extension
filename = string.gsub(filename, ":", ".")
full_output_path = options.output_directory .. "/'" .. filename .. "'"
end
function encode(enc)
set_mouse_area = nil
if rect_width == nil or rect_width == 0 then
crop = ""
if not aspect_first then
aspect_first = mp.get_property_native("video-params/aspect")
end
else
-- odd numbers for video resolution are a bad idea
rect_width = round(rect_width) - (math.fmod(round(rect_width), 2))
rect_height = round(rect_height) - (math.fmod(round(rect_height), 2))
crop = rect_width .. ":" .. rect_height .. ":" .. round(rect_x1) .. ":" .. round(rect_y1)
if not aspect_first then
aspect_first = rect_width / rect_height
width_first = rect_width
height_first = rect_height
if not no_scale then
if scale_width then
width_first = scale
else
height_first = scale
end
end
end
rect_width, rect_height = nil
end
local sid = mp.get_property("sid")
local sub_visibility = mp.get_property("sub-visibility")
local aid = ""
aid = " --aid=" .. mp.get_property("aid")
local af = mp.get_property("af")
local vf = mp.get_property("vf")
if string.len(vf) > 0 then
vf = vf .. ","
end
local sub_file_table = mp.get_property_native("options/sub-file")
local sub_file = ""
for index, param in pairs(sub_file_table) do
sub_file = sub_file .. " --sub-file='" .. string.gsub(tostring(param), "'", "'\\''") .. "'"
end
local audio_file_table = mp.get_property_native("options/audio-file")
local audio_file = ""
for index, param in pairs(audio_file_table) do
audio_file = audio_file .. " --audio-file='" .. string.gsub(tostring(param), "'", "'\\''") .. "'"
end
if mp.get_property_native("mute") then
audio_file = ""
aid = ""
audio = "--no-audio"
end
local sub_auto = mp.get_property("options/sub-auto")
local sub_delay = mp.get_property("sub-delay")
local hr_seek_demuxer_offset = mp.get_property_native("options/hr-seek-demuxer-offset")
preparations()
if options.crop_individually then
vf = vf .. 'sub,crop=' .. crop .. ',scale=SCALE…VAR'
else
vf = vf .. 'sub,crop=CROP…VAR,scale=SCALE…VAR'
end
local mpv_options = "'" .. video .. "' --start=+" .. start .. ' --length=' .. length
.. ' ' .. sub_file .. ' --sid=' .. sid .. ' --sub-visibility=' .. sub_visibility
.. ' --sub-delay=' .. sub_delay .. ' --sub-auto=' .. sub_auto .. aid
.. ' --vf-add=' .. vf .. ' --hr-seek-demuxer-offset=' .. hr_seek_demuxer_offset
.. ' --af=' .. af .. ' ' .. audio_file
if not (ovc == "gif") then
ovc_c = ' --ovc=' .. ovc
else
ovc_c = ' '
end
local encode_options = ' ' .. ovc_c
if ovc == "libvpx" or ovc == "libvpx-vp9" then
encode_options = encode_options .. ' ' .. options.libvpx_fps .. ' --ovcopts-add=threads=' .. options.libvpx_threads .. ' '
if ovc == "libvpx" then
encode_options = encode_options .. options.libvpx_options
else
encode_options = encode_options .. options.libvpx_vp9_options
--libvpx-vp9 produces slightly bigger files
if bitrate then
bitrate = bitrate * 0.93
end
end
end
if twopass then
encode_options = encode_options .. ' --ovcopts-add=b=' .. bitrate
else
if not (ovc == "gif") then
encode_options = encode_options .. ' --ovcopts-add=crf=' .. tostring(crf)
end
if ovc == "libvpx" or ovc == "libvpx-vp9" then
encode_options = encode_options .. ' --ovcopts-add=b=10000000'
end
end
if advanced then
encode_options = ' ' .. advanced_output .. advanced_encode
end
if not segments then
segments = ""
segment_count = 0
end
segment_count = segment_count + 1
segments = segments .. " --\\{ " .. mpv_options .. " --\\}"
local full_command = '( mpv' .. segments
if twopass then
encode_options = encode_options .. ' --ovcopts-add=flags=+pass'
full_command = full_command .. ' --no-audio ' .. encode_options .. '1'
end
if ovc == "gif" then
local handle = io.popen("mktemp -d")
tmpfolder = handle:read("*a")
handle:close()
tmpfolder = string.gsub(tmpfolder, '\n', '')
encode_options = encode_options .. ' --no-keep-open --vo=image:format=png:outdir=' .. tmpfolder
else
full_command = full_command .. ' --o=' .. full_output_path
end
if twopass then
full_command = full_command .. ' && mpv' .. segments .. ' ' .. audio .. ' ' .. encode_options .. '2'
.. ' --o=' .. full_output_path
.. ' && rm ' .. full_output_path .. '-vo-lavc-pass1.log'
else
full_command = full_command .. ' ' .. audio .. ' ' .. encode_options
end
if not framestep then
framestep = 1
dither = "+dither"
fuzz = "1%"
end
local delay = framestep * 4
if ovc == "gif" then
full_command = full_command .. ' --vf-add=lavfi=graph=\\"framestep=' .. framestep .. '\\" && convert '
.. tmpfolder .. '/*.png -set delay ' .. delay .. ' -loop 0 -fuzz ' .. fuzz .. '% ' .. dither .. ' -layers optimize '
.. full_output_path .. ' && rm -rf ' .. tmpfolder .. ' && notify-send "Gif done") & disown'
else
full_command = full_command .. ' && notify-send --hint int:transient:1 -t 3500 "Encoding done"; mkvpropedit '
.. full_output_path .. ' -s title="' .. metadata_title .. '") & disown'
end
if enc then
-- mpv parses --{ --} last and --no-audio gets overwritten by --aid, so remove them
if audio == "--no-audio" then
full_command = string.gsub(full_command, "--aid=%d* ", " ")
end
local dwidth, dheight = mp.get_property_native("dwidth"), mp.get_property_native("dheight")
if width_first then
if scale_width then
scale = width_first
else
scale = height_first
end
end
if scale_width then
local scale_2 = round(scale / aspect_first)
scale = scale .. ":" .. (scale_2 - (math.fmod(scale_2, 2)))
else
local scale_2 = round(scale * aspect_first)
scale = (scale_2 - math.fmod(scale_2, 2)) .. ":" .. scale
end
if no_scale and not width_first then
scale = dwidth .. ":" .. dheight
end
full_command = string.gsub(full_command, "CROP…VAR", crop)
full_command = string.gsub(full_command, "SCALE…VAR", scale)
msg.info(full_command)
os.execute(full_command)
clear()
end
end
function encode_copy(enc)
preparations()
if not mkvmerge_parts then
mkvmerge_parts = "--split parts:"
sep = ""
end
mkvmerge_parts = mkvmerge_parts .. sep .. math.floor(start) .. "s-" .. math.ceil(start + length) .. "s"
sep = ",+"
if enc then
local command = "mkvmerge '" .. video .. "' " .. mkvmerge_parts .. " -o " .. full_output_path
msg.info(command)
os.execute(command)
clear()
end
end
function clear()
mkvmerge_parts = nil
aspect_first = nil
height_first = nil
width_first = nil
sep = nil
segments = nil
segments_length = 0
end
---------
-- GUI --
---------
function call_gui ()
mp.disable_key_bindings("draw_rectangle")
mp.resume_all()
extension = ""
local factor = 1024
local format_dropdown_content
local mode_dropdown_content
local resize_to_width_instead
local include_audio
local yad_offset_10 = 0
if not scale_sav then
scale_sav = 540
end
if not yad_table[5] then
yad_table[5] = ""
end
if not options.legacy_yad then
if yad_table[5]:find("(MiB)") then
mode_dropdown_content = "'Target file size (KiB)!^Target file size (MiB)!CRF'"
elseif yad_table[5]:find("(KiB)") then
mode_dropdown_content = "'^Target file size (KiB)!Target file size (MiB)!CRF'"
elseif yad_table[5]:find("CRF") then
mode_dropdown_content = "'Target file size (KiB)!Target file size (MiB)!^CRF'"
else
mode_dropdown_content = "'^Target file size (KiB)!Target file size (MiB)!CRF'"
end
if yad_table[7] == "vp8/webm" then
format_dropdown_content = "'^vp8/webm!vp9/webm!h264/mkv!h264/mp4!h265/mkv!gif!stream copy/mkv'"
elseif yad_table[7] == "vp9/webm" then
format_dropdown_content = "'vp8/webm!^vp9/webm!h264/mkv!h264/mp4!h265/mkv!gif!stream copy/mkv'"
elseif yad_table[7] == "h264/mkv" then
format_dropdown_content = "'vp8/webm!vp9/webm!^h264/mkv!h264/mp4!h265/mkv!gif!stream copy/mkv'"
elseif yad_table[7] == "h264/mp4" then
format_dropdown_content = "'vp8/webm!vp9/webm!h264/mkv!^h264/mp4!h265/mkv!gif!stream copy/mkv'"
elseif yad_table[7] == "h265/mkv" then
format_dropdown_content = "'vp8/webm!vp9/webm!h264/mkv!h264/mp4!^h265/mkv!gif!stream copy/mkv'"
elseif yad_table[7] == "gif" then
format_dropdown_content = "'vp8/webm!vp9/webm!h264/mkv!h264/mp4!h265/mkv!^gif!stream copy/mkv'"
elseif yad_table[7] == "stream copy/mkv" then
format_dropdown_content = "'vp8/webm!vp9/webm!h264/mkv!h264/mp4!h265/mkv!gif!^stream copy/mkv'"
else
format_dropdown_content = "'^vp8/webm!vp9/webm!h264/mkv!h264/mp4!h265/mkv!gif!stream copy/mkv'"
end
else
mode_dropdown_content = "'Target file size (KiB)!Target file size (MiB)!CRF'"
format_dropdown_content = "'vp8/webm!vp9/webm!h264/mkv!h264/mp4!h265/mkv!gif!stream copy/mkv'"
yad_offset_10 = -2
end
if (yad_table[2] == "TRUE") then
resize_to_width_instead = '"true"'
else
resize_to_width_instead = '"false"'
end
if (yad_table[4] == "TRUE") then
include_audio = '"true"'
else
include_audio = '"false"'
end
if (not yad_table[6]) then
yad_table[6] = '"3072"'
end
local yad_command = [[LC_NUMERIC=C yad --title="Convert Script" --center --form --fixed --always-print-result \
--name "convert script" --class "Convert Script" --field="Resize to height:NUM" "]] .. scale_sav --yad_table 1
.. [[" --field="Resize to width instead:CHK" ]] .. resize_to_width_instead .. " " --yad_table 2
if options.legacy_yad then
yad_command = yad_command .. [[--field="Don't resize at all:CHK" "false" ]]
else
yad_command = yad_command
.. [[--field="Don't resize at all:BTN" "@bash -c 'if ]] .. '[[ "a%1" == "a0.000000" ]]'
.. [[; then printf '\''1:1\n2:false'\''; else printf '\''1:0.000000\n1:@disabled@\n2:@disabled@'\''; fi'" ]]
end
yad_command = yad_command .. '--field="Include audio:CHK" ' .. include_audio .. ' ' --yad_table 4
if yad_ret then
yad_command = yad_command
.. [[--field="2pass:CHK" "false" ]] --yad_table 5
.. [[--field="Encode options::CBE" '! --ovcopts=b=2000,cpu-used=0,auto-alt-ref=1,lag-in-frames=25,quality=good,threads=4' ]] --yad_table 6
.. [[--field="Output format::CBE" ' --ovc=libx264! --oautofps --of=webm --ovc=libvpx' ]]
.. [[--field="Simple:FBTN" 'bash -c "echo \"simple\" && kill -s SIGUSR1 \"$YAD_PID\""' ]]
advanced = true
else
yad_command = yad_command
.. '--field="Mode::CB" ' .. mode_dropdown_content --yad_table 5
.. ' --field="Value::NUM" ' .. yad_table[6] --yad_table 6
.. ' --field="Output format::CB" ' .. format_dropdown_content --yad_table 7
if not options.legacy_yad then
yad_command = yad_command .. [[ --field="Advanced:FBTN" 'bash -c "echo \"advanced\" && kill -s SIGUSR1 \"$YAD_PID\""' ]]
end
end
if not options.legacy_yad then
yad_command = yad_command
.. [[--field="Append another segment:FBTN" 'bash -c "echo \"append\" && kill -s SIGUSR1 \"$YAD_PID\""' ]]
end
yad_command = yad_command .. [[ --button="Crop:1" --button="gtk-cancel:2" --button="gtk-ok:0"; ret=$? && echo $ret]]
if gif_dialog then
yad_command = [[echo $(LC_NUMERIC=C yad --title="Gif settings" --name "convert script" --class "Convert Script" \
--center --form --always-print-result --separator="…" \
--field="Fuzz Factor:NUM" '1!0..100!0.5!1' \
--field="Framestep:NUM" '3!1..3!1' \
--field="Dither:CB" 'None!E-Dither!Ordered Dither' \
--button="Ok:0")]]
end
local handle = io.popen(yad_command)
local yad = handle:read("*a")
handle:close()
if yad == "127\n" then
msg.error("Error: Cannot find yad!")
mp.osd_message("Error: Cannot find yad!")
return
end
yad_table = {}
local function helper(line) table.insert(yad_table, line) return "" end
helper((yad:gsub("(.-)|", helper)))
for i, e in pairs(yad_table) do
msg.debug(i .. "" .. e)
end
if not gif_dialog then
yad_ret = tonumber(yad_table[10+yad_offset_10])
if yad_table[1]:find("append") then
yad_table[1] = string.gsub(yad_table[1], "append\n", "")
yad_ret = -1
end
if yad_table[1]:find("advanced") then
yad_table[1] = string.gsub(yad_table[1], "advanced\n", "")
yad_ret = -2
if yad_table[7]:find("gif") then
gif_dialog = true
end
end
if yad_table[1]:find("simple") then
yad_table[1] = string.gsub(yad_table[1], "simple\n", "")
yad_ret = -2
end
scale = tonumber(yad_table[1]) - (math.fmod(tonumber(yad_table[1]), 2 ))
scale_sav = scale
if (yad_table[2] == "FALSE") and (tonumber(yad_table[1]) > 0) then
scale_width = false
no_scale = false
elseif yad_table[1] == "0.000000" then
no_scale = true
else
scale_width = true
no_scale = false
end
if yad_table[3] == "TRUE" then
no_scale = true
end
if yad_table[4] == "FALSE" then
audio = "--no-audio"
else
audio = ""
end
if not segments_length then
segments_length = 0
end
segments_length = segments_length + length
if (yad_table[5] == "TRUE") or yad_table[5]:find("Target") then
twopass = true
else
twopass = false
end
if advanced then
advanced_encode = yad_table[6]
advanced_output = yad_table[7]
yad_table[6] = '"3072"'
else
if yad_table[7]:find("webm") then
extension = ".webm"
elseif yad_table[7]:find("mkv") then
extension = ".mkv"
elseif yad_table[7]:find("mp4") then
extension = ".mp4"
end
if yad_table[7]:find("h264") then
ovc = "libx264"
elseif yad_table[7]:find("h265") then
ovc = "libx265"
elseif yad_table[7]:find("vp8") then
ovc = "libvpx"
elseif yad_table[7]:find("vp9") then
ovc = "libvpx-vp9"
elseif yad_table[7]:find("gif") then
ovc = "gif"
extension = ".gif"
twopass = false
elseif yad_table[7]:find("stream copy") then
ovc = "stream copy"
end
if yad_table[5]:find("(MiB)") then
factor = 1048576
end
if twopass then
local total_bitrate = yad_table[6] * factor
if audio == "" then
total_bitrate = total_bitrate - ( options.audio_bitrate * 1000 * segments_length / 8 )
end
bitrate = math.floor(total_bitrate*8/segments_length*options.bitrate_multiplier)
else
crf = yad_table[6]
end
end
if yad_ret == 1 then
mp.enable_key_bindings("draw_rectangle")
yad_ret = nil
return
end
else
gif_dialog = nil
helper((yad:gsub("(.-)…", helper)))
fuzz = yad_table[2]
framestep = yad_table[3]
if yad_table[4] == "None" then
dither = "+dither"
elseif yad_table[4] == "Ordered Dither" then
dither = "-ordered-dither o8x8,20"
else
dither = ""
end
yad_table[7] = "gif"
end
mp.set_osd_ass(width, height, "")
if yad_ret == 0 then
if ovc == "stream copy" then
encode_copy(true)
else
encode(true)
end
elseif yad_ret == -1 then
if ovc == "stream copy" then
encode(false)
encode_copy(false)
else
encode(false)
encode_copy(false)
end
end
if yad_ret == -2 then
if advanced then
advanced = false
yad_ret = false
end
call_gui()
end
if yad_ret == 2 then
clear()
end
yad_ret = nil
advanced = nil
end
function round(n)
return math.floor((math.floor(n*2) + 1)/2)
end
mp.set_key_bindings({
{"mouse_move", mouse_move},
{"mouse_btn0", function(e) rect_button_press_event("mouse_btn0", "up") end, function(e) rect_button_press_event("mouse_btn0", "down") end},
}, "draw_rectangle", "force")
mp.add_key_binding("alt+w", "convert_script", convert_script_hotkey_call)
mp.register_event("tick", tick)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.