Enhance Avisynth video workflow and metadata tools

This commit is contained in:
ajp_anton
2026-07-30 03:20:19 +00:00
parent d60ce51b14
commit 100cad1cb5
26 changed files with 2350 additions and 1161 deletions
+409 -64
View File
@@ -4,18 +4,43 @@
# small and dependency-light unless the visible scripts explicitly document the
# required plugins.
function MBT_MarkSource(clip c, int source_id, int "matrix")
function MBT_MarkSource(clip c, int source_id, int "matrix", int "primaries", int "transfer", int "color_range", float "hdr_peak", float "absolute_timestamp_end_seconds", float "source_duration_seconds")
{
# Attach source identity to each frame. Python reads these properties after
# running the user-edited script and maps frames back to ffprobe timestamps.
# Timestamp/duration variables are supplied by generated ConditionalReader
# files when available.
matrix = Default(matrix, MBT_GetMatrixCode(c))
# Timing values are attached separately after ConditionalReader has supplied
# their per-frame variables.
matrix = Defined(matrix) ? matrix : 2
primaries = Defined(primaries) ? primaries : 2
transfer = Defined(transfer) ? transfer : 2
color_range = Defined(color_range) ? color_range : 0
hdr_peak = Default(hdr_peak, 1000.0)
absolute_timestamp_end_seconds = Default(absolute_timestamp_end_seconds, 0.0)
source_duration_seconds = Default(source_duration_seconds, 0.0)
runtime = """
last.propSet("mbt_source_id", """ + String(source_id) + """).\
propSet("mbt_source_frame", current_frame).\
propSet("mbt_drop_frame", 0).\
propSet("mbt_absolute_timestretch", 1.0).\
propSet("mbt_absolute_timestamp_end_seconds", """ + String(absolute_timestamp_end_seconds) + """).\
propSet("mbt_source_duration_seconds", """ + String(source_duration_seconds) + """).\
propSet("mbt_absolute_timestamp_start_seconds", """ + String(absolute_timestamp_end_seconds - source_duration_seconds) + """).\
propSet("_Matrix", """ + String(matrix) + """).\
propSet("mbt_hdr_peak", """ + String(hdr_peak) + """).\
propSet("_Primaries", """ + String(primaries) + """).\
propSet("_Transfer", """ + String(transfer) + """).\
propSet("_ColorRange", """ + String(color_range) + """)
"""
source = c.PropSet("mbt_hdr_peak", hdr_peak).\
MBT_SetColor(MBT_MatrixNameFromCode(matrix), primaries, transfer, color_range)
return source.ScriptClip(runtime)
}
function MBT_MarkTiming(clip c)
{
runtime = """
source_start = propGetFloat("mbt_absolute_timestamp_start_seconds")
last.propSet("mbt_absolute_timestamp_seconds", source_start + mbt_frame_time_seconds).\
propSet("mbt_frame_time_seconds", mbt_frame_time_seconds).\
propSet("mbt_relative_timestamp", mbt_relative_timestamp).\
propSet("mbt_absolute_timestamp", mbt_absolute_timestamp).\
@@ -25,6 +50,86 @@ function MBT_MarkSource(clip c, int source_id, int "matrix")
return c.ScriptClip(runtime)
}
function MBT_AbsoluteTimeStretch(clip c, float factor)
{
Assert(factor > 0.0, "MBT_AbsoluteTimeStretch factor must be greater than zero.")
runtime = """
source_end = propGetFloat("mbt_absolute_timestamp_end_seconds")
source_duration = propGetFloat("mbt_source_duration_seconds")
previous_factor = propGetFloat("mbt_absolute_timestretch")
total_factor = previous_factor * """ + String(factor) + """
Assert(source_end > 0.0 && source_duration > 0.0, "MBT_AbsoluteTimeStretch requires source end timestamp and duration metadata.")
source_start = source_end - source_duration * total_factor
stretched_timestamp = source_start + mbt_frame_time_seconds * total_factor
last.propSet("mbt_absolute_timestamp_start_seconds", source_start).\
propSet("mbt_absolute_timestamp_seconds", stretched_timestamp).\
propSet("mbt_absolute_timestamp", MBT_FormatUtcTimestamp(stretched_timestamp)).\
propSet("mbt_absolute_timestretch", total_factor)
"""
return c.ScriptClip(runtime)
}
function MBT_IsLeapYear(int year)
{
return year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
}
function MBT_DaysInYear(int year)
{
return MBT_IsLeapYear(year) ? 366 : 365
}
function MBT_DaysInMonth(int year, int month)
{
return month == 2 ? (MBT_IsLeapYear(year) ? 29 : 28)
\ : (month == 4 || month == 6 || month == 9 || month == 11) ? 30 : 31
}
function MBT_YearFromEpochDays(int days, int "year")
{
year = Default(year, 1970)
return days < MBT_DaysInYear(year) ? year : MBT_YearFromEpochDays(days - MBT_DaysInYear(year), year + 1)
}
function MBT_DayOfYear(int days, int "year")
{
year = Default(year, 1970)
return days < MBT_DaysInYear(year) ? days : MBT_DayOfYear(days - MBT_DaysInYear(year), year + 1)
}
function MBT_MonthFromDayOfYear(int year, int day, int "month")
{
month = Default(month, 1)
return day < MBT_DaysInMonth(year, month) ? month : MBT_MonthFromDayOfYear(year, day - MBT_DaysInMonth(year, month), month + 1)
}
function MBT_DayOfMonth(int year, int day, int "month")
{
month = Default(month, 1)
return day < MBT_DaysInMonth(year, month) ? day + 1 : MBT_DayOfMonth(year, day - MBT_DaysInMonth(year, month), month + 1)
}
function MBT_Pad(int value, int width)
{
text = String(value)
return StrLen(text) >= width ? text : "0" + MBT_Pad(value, width - 1)
}
function MBT_FormatUtcTimestamp(float timestamp)
{
whole_seconds = Int(Floor(timestamp))
microseconds = Round((timestamp - whole_seconds) * 1000000.0)
whole_seconds = microseconds >= 1000000 ? whole_seconds + 1 : whole_seconds
microseconds = microseconds >= 1000000 ? 0 : microseconds
days = Int(Floor(whole_seconds / 86400.0))
day_seconds = whole_seconds - days * 86400
year = MBT_YearFromEpochDays(days)
day = MBT_DayOfYear(days)
month = MBT_MonthFromDayOfYear(year, day)
return MBT_Pad(year, 4) + "-" + MBT_Pad(month, 2) + "-" + MBT_Pad(MBT_DayOfMonth(year, day), 2) + "T" + \
MBT_Pad(day_seconds / 3600, 2) + ":" + MBT_Pad((day_seconds % 3600) / 60, 2) + ":" + MBT_Pad(day_seconds % 60, 2) + "." + MBT_Pad(microseconds, 6) + "Z"
}
function MBT_AudioSource(clip video, string source_path, string audio_cache_path)
{
audio = FFAudioSource(source_path, cachefile=audio_cache_path)
@@ -94,12 +199,17 @@ function MBT_Info(clip c, int "size", int "align")
align = Default(align, 7)
runtime = """
drop_value = propGetInt("mbt_drop_frame") == 0 ? "False" : "True"
matrix = MBT_MatrixNameFromCode(propGetInt("_Matrix"))
transfer = propGetInt("_Transfer")
transfer_name = transfer == 16 ? "PQ" : (transfer == 18 ? "HLG" : "SDR")
range_name = propGetInt("_ColorRange") == 1 ? "full range" : "limited range"
text = "Absolute timestamp: " + propGetString("mbt_absolute_timestamp") + Chr(10) + \
"Relative timestamp: " + propGetString("mbt_relative_timestamp") + Chr(10) + \
Chr(10) + \
"Source frame: " + String(propGetInt("mbt_source_frame")) + Chr(10) + \
"Frame duration: " + propGetString("mbt_frame_duration") + Chr(10) + \
"Drop frame: " + drop_value
"Drop frame: " + drop_value + Chr(10) + \
"Color: " + matrix + ", " + transfer_name + ", " + range_name
Subtitle(last, text, size=""" + String(size) + """, align=""" + String(align) + """)
"""
return c.ScriptClip(runtime)
@@ -176,8 +286,8 @@ function Resize(clip v, int "w", int "h", float "dar", float "xcrop", float "ycr
crop_w = cropped_w - crop_extra_l - crop_extra_r
crop_h = cropped_h - crop_extra_t - crop_extra_b
input_matrix = Default(input_matrix, v.IsRGB ? MBT_DefaultMatrix(wo, ho) : MBT_GetMatrixName(v))
output_matrix = Default(output_matrix, MBT_DefaultMatrix(w, h))
input_matrix = Default(input_matrix, v.IsRGB ? MBT_DefaultMatrix(wo, ho) : mbt_color_matrix)
output_matrix = Default(output_matrix, MBT_DefaultOutputMatrix(w, h))
return v.MBT_LinearResize(w, h, crop_l, crop_t, crop_w, crop_h, linear=linear, input_matrix=input_matrix, output_matrix=output_matrix)
}
@@ -200,51 +310,85 @@ function MBT_LinearResize(clip src, int tw, int th,
p = Default(p , 30.0)
bitdepth = Default(bitdepth , src.BitsPerComponent)
linear = Defined(linear) ? linear : (sw / Float(tw) >= 1.5 || sh / Float(th) >= 1.5)
input_matrix = Default(input_matrix, src.IsRGB ? MBT_DefaultMatrix(src.Width, src.Height) : MBT_GetMatrixName(src))
output_matrix = Default(output_matrix, MBT_DefaultMatrix(tw, th))
Assert(
\ !linear || (FunctionExists("y_gamma_to_linear") && FunctionExists("y_linear_to_gamma")),
\ "Resize(linear=true) requires y_gamma_to_linear and y_linear_to_gamma. Load the missing dependency or call Resize(..., linear=false)."
\)
input_matrix = Default(input_matrix, src.IsRGB ? MBT_DefaultMatrix(src.Width, src.Height) : mbt_color_matrix)
output_matrix = Default(output_matrix, MBT_DefaultOutputMatrix(tw, th))
clp = (src.IsRGB ? (src.HasAlpha ? src.ConvertToPlanarRGBA
\ : src.ConvertToPlanarRGB)
\ : src.ConvertToPlanarRGB(matrix=input_matrix))
clp = clp.ConvertBits(32)
return MBT_IsHDR()
\ ? (linear
\ ? src.MBT_HDRLinearResize(tw, th, sl, st, sw, sh, hkernel, vkernel, b, c, taps, p, bitdepth, input_matrix, output_matrix)
\ : src.MBT_HDRResize(tw, th, sl, st, sw, sh, hkernel, vkernel, b, c, taps, p, input_matrix, output_matrix))
\ : src.MBT_SDRResize(tw, th, sl, st, sw, sh, hkernel, vkernel, b, c, taps, p, bitdepth, linear, input_matrix, output_matrix)
}
function MBT_HDRResize(clip src, int tw, int th, float sl, float st, float sw, float sh,
\ string hkernel, string vkernel, float b, float c, int "taps", float "p",
\ string "input_matrix", string "output_matrix")
{
Assert(input_matrix == output_matrix,
\ "HDR Resize preserves its color system. Use MBT_ToSDR before Resize to change it.")
return src.MBT_ResizePlanes(tw, th, sl, st, sw, sh, hkernel, vkernel, b, c, taps, p).PropCopy(src)
}
function MBT_SDRResize(clip src, int tw, int th, float sl, float st, float sw, float sh,
\ string hkernel, string vkernel, float b, float c, int "taps", float "p", int "bitdepth",
\ bool "linear", string "input_matrix", string "output_matrix")
{
Assert(!linear || (FunctionExists("y_gamma_to_linear") && FunctionExists("y_linear_to_gamma")),
\ "Resize(linear=true) requires y_gamma_to_linear and y_linear_to_gamma. Load the missing dependency or call Resize(..., linear=false).")
clp = (src.IsRGB ? (src.HasAlpha ? src.ConvertToPlanarRGBA : src.ConvertToPlanarRGB) : src.ConvertToPlanarRGB(matrix=input_matrix)).ConvertBits(32)
clp = linear ? clp.y_gamma_to_linear : clp
if (hkernel != vkernel) {
htaps = Default(taps, (hkernel == "Lanczos" ? 3 : 4))
vtaps = Default(taps, (vkernel == "Lanczos" ? 3 : 4))
clp = clp.MBT_LinearResize(tw, src.Height, sl, 0, sw, 0, hkernel, b, c, htaps, p, linear=false, input_matrix=input_matrix, output_matrix=input_matrix)
clp = clp.MBT_LinearResize(tw, th, 0, st, 0, sh, vkernel, b, c, vtaps, p, linear=false, input_matrix=input_matrix, output_matrix=output_matrix)
}
else {
taps = Default(taps, (hkernel == "Lanczos" ? 3 : 4))
clp =
\ (hkernel == "Bicubic" ) ? clp.BicubicResize(tw, th, b=b, c=c, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Bilinear" ) ? clp.BilinearResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Blackman" ) ? clp.BlackmanResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps)
\ : (hkernel == "Gauss" ) ? clp.GaussResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, p=p)
\ : (hkernel == "Lanczos" ) ? clp.LanczosResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps)
\ : (hkernel == "Lanczos4" ) ? clp.Lanczos4Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Point" ) ? clp.PointResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Sinc" ) ? clp.SincResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps)
\ : (hkernel == "Spline16" ) ? clp.Spline16Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Spline36" ) ? clp.Spline36Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Spline64" ) ? clp.Spline64Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "CatmullRom" ) ? clp.BicubicResize(tw, th, b=0.0, c=0.5, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : Assert(false, "Invalid resize kernel.")
}
clp = clp.MBT_ResizePlanes(tw, th, sl, st, sw, sh, hkernel, vkernel, b, c, taps, p)
clp = linear ? clp.y_linear_to_gamma : clp
clp = src.IsRGB ? (src.HasAlpha ? clp.ConvertToPlanarRGBA : clp.ConvertToPlanarRGB)
\ : (src.Is420 ? clp.ConvertToYUV420(matrix=output_matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix)
\ : (src.Is422 ? clp.ConvertToYUV422(matrix=output_matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix)
\ : (src.Is444 ? clp.ConvertToYUV444(matrix=output_matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) : clp.ConvertBits(bitdepth).MBT_SetMatrix(output_matrix))))
clp = src.IsRGB ? clp.ConvertBits(bitdepth) : clp
\ : (src.Is420 ? clp.ConvertToYUV420(matrix=output_matrix)
\ : (src.Is422 ? clp.ConvertToYUV422(matrix=output_matrix) : clp.ConvertToYUV444(matrix=output_matrix)))
global mbt_color_matrix = output_matrix
global mbt_color_primaries = MBT_PrimariesForMatrix(output_matrix)
global mbt_color_transfer = 1
return src.IsRGB ? clp.ConvertBits(bitdepth).PropCopy(src) : clp.ConvertBits(bitdepth).PropCopy(src).MBT_SetSDRColor(output_matrix, mbt_color_range)
}
return clp
function MBT_HDRLinearResize(clip src, int tw, int th, float sl, float st, float sw, float sh,
\ string hkernel, string vkernel, float b, float c, int "taps", float "p", int "bitdepth",
\ string "input_matrix", string "output_matrix")
{
Assert(!src.IsRGB, "HDR Resize currently requires planar YUV input.")
Assert(input_matrix == output_matrix,
\ "HDR Resize preserves its color system. Use MBT_ToSDR before Resize to change it.")
Assert(mbt_color_primaries == 9,
\ "HDR Resize(linear=true) currently requires Rec2020 primaries. P3 HDR needs mastering-primary support.")
Assert(FunctionExists("ConvertYUVtoLinearRGB") && FunctionExists("ConvertLinearRGBtoYUV"),
\ "HDR Resize(linear=true) requires HDRTools. Load HDRTools or call Resize(..., linear=false).")
clp = src.MBT_LinearHDR(MBT_HDRMode(), mbt_color_range)
clp = clp.MBT_ResizePlanes(tw, th, sl, st, sw, sh, hkernel, vkernel, b, c, taps, p)
clp = clp.ConvertLinearRGBtoYUV(Color=0, OutputMode=MBT_YuvOutputMode(src), HDRMode=MBT_HDRMode(), OOTF=false, fullrange=mbt_color_range == 1)
return clp.ConvertBits(bitdepth).PropCopy(src).MBT_SetColor(input_matrix, mbt_color_primaries, mbt_color_transfer, mbt_color_range)
}
function MBT_ResizePlanes(clip clp, int tw, int th, float sl, float st, float sw, float sh,
\ string hkernel, string vkernel, float b, float c, int "taps", float "p")
{
if (hkernel != vkernel) {
htaps = Default(taps, hkernel == "Lanczos" ? 3 : 4)
vtaps = Default(taps, vkernel == "Lanczos" ? 3 : 4)
clp = clp.MBT_ResizePlanes(tw, clp.Height, sl, 0, sw, clp.Height, hkernel, hkernel, b, c, htaps, p)
return clp.MBT_ResizePlanes(tw, th, 0, st, clp.Width, sh, vkernel, vkernel, b, c, vtaps, p)
}
taps = Default(taps, hkernel == "Lanczos" ? 3 : 4)
return
\ (hkernel == "Bicubic" ) ? clp.BicubicResize(tw, th, b=b, c=c, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Bilinear" ) ? clp.BilinearResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Blackman" ) ? clp.BlackmanResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps)
\ : (hkernel == "Gauss" ) ? clp.GaussResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, p=p)
\ : (hkernel == "Lanczos" ) ? clp.LanczosResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps)
\ : (hkernel == "Lanczos4" ) ? clp.Lanczos4Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Point" ) ? clp.PointResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Sinc" ) ? clp.SincResize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh, taps=taps)
\ : (hkernel == "Spline16" ) ? clp.Spline16Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Spline36" ) ? clp.Spline36Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "Spline64" ) ? clp.Spline64Resize(tw, th, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : (hkernel == "CatmullRom" ) ? clp.BicubicResize(tw, th, b=0.0, c=0.5, src_left=sl, src_top=st, src_width=sw, src_height=sh)
\ : Assert(false, "Invalid resize kernel.")
}
function MBT_DefaultMatrix(int w, int h)
@@ -252,6 +396,58 @@ function MBT_DefaultMatrix(int w, int h)
return (w >= 3840 || h >= 2160) ? "Rec2020" : ((w >= 1280 || h >= 720) ? "Rec709" : "Rec601")
}
function MBT_IsHDR()
{
return mbt_color_transfer == 16 || mbt_color_transfer == 18
}
function MBT_DefaultOutputMatrix(int w, int h)
{
return MBT_IsHDR() ? mbt_color_matrix : MBT_DefaultMatrix(w, h)
}
function MBT_GetPrimariesCode(clip c)
{
primaries = c.PropGetInt("_Primaries")
return primaries == 0 ? 2 : primaries
}
function MBT_GetTransferCode(clip c)
{
transfer = c.PropGetInt("_Transfer")
return transfer == 0 ? 2 : transfer
}
function MBT_GetRangeCode(clip c)
{
return c.PropGetInt("_ColorRange")
}
function MBT_PrimariesForMatrix(string matrix)
{
return matrix == "Rec2020" ? 9 : 1
}
function MBT_SDRColorForMatrix(string matrix)
{
return matrix == "Rec2020" ? 1 : (matrix == "Rec601" ? 3 : 2)
}
function MBT_YuvOutputMode(clip c)
{
return c.Is420 ? 2 : (c.Is422 ? 1 : 0)
}
function MBT_LinearHDR(clip c, int hdr_mode, int color_range)
{
return c.ConvertYUVtoLinearRGB(Color=0, OutputMode=2, HDRMode=hdr_mode, OOTF=false, fullrange=color_range == 1)
}
function MBT_HDRMode()
{
return mbt_color_transfer == 18 ? 2 : 0
}
function MBT_MatrixNameFromCode(int matrix)
{
return (matrix == 1) ? "Rec709"
@@ -278,33 +474,182 @@ function MBT_MatrixInt(string matrix)
\ : 2
}
function MBT_GetMatrixCode(clip c)
{
matrix = MBT_MatrixNameFromCode(c.PropGetInt("_Matrix"))
return (matrix == "") ? MBT_MatrixInt(MBT_DefaultMatrix(c.Width, c.Height)) : c.PropGetInt("_Matrix")
}
function MBT_GetMatrixName(clip c)
{
matrix = MBT_MatrixNameFromCode(MBT_GetMatrixCode(c))
return (matrix == "") ? MBT_DefaultMatrix(c.Width, c.Height) : matrix
}
function MBT_SetMatrix(clip c, string matrix)
{
return c.PropSet("_Matrix", MBT_MatrixInt(matrix))
}
function MBT_SetColor(clip c, string matrix, int primaries, int transfer, int color_range)
{
return c.PropSet("_Matrix", MBT_MatrixInt(matrix)).\
PropSet("_Primaries", primaries).\
PropSet("_Transfer", transfer).\
PropSet("_ColorRange", color_range)
}
function MBT_SetSDRColor(clip c, string matrix, int color_range)
{
return c.MBT_SetColor(matrix, MBT_PrimariesForMatrix(matrix), 1, color_range)
}
function MBT_CorrectMatrix(clip c, string "input_matrix", string "output_matrix")
{
input_matrix = Default(input_matrix, MBT_GetMatrixName(c))
output_matrix = Default(output_matrix, MBT_DefaultMatrix(c.Width, c.Height))
input_matrix = Default(input_matrix, mbt_color_matrix)
output_matrix = Default(output_matrix, MBT_DefaultOutputMatrix(c.Width, c.Height))
was_hdr = MBT_IsHDR()
Assert(!was_hdr || input_matrix == output_matrix,
\ "MBT_CorrectMatrix does not convert HDR color systems. Use MBT_ToSDR or an explicit HDR conversion.")
if (!was_hdr) {
global mbt_color_matrix = output_matrix
global mbt_color_primaries = MBT_PrimariesForMatrix(output_matrix)
global mbt_color_transfer = 1
}
return was_hdr ? c.MBT_SetMatrix(input_matrix) : c.MBT_CorrectSDRMatrix(input_matrix, output_matrix)
}
function MBT_CorrectSDRMatrix(clip c, string input_matrix, string output_matrix)
{
matrix = MBT_MatrixCodeForConvert(input_matrix) + ":auto=>" + MBT_MatrixCodeForConvert(output_matrix) + ":same"
bitdepth = c.BitsPerComponent
return c.IsRGB ? c
\ : (c.Is420 ? c.ConvertToYUV420(matrix=matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix)
\ : (c.Is422 ? c.ConvertToYUV422(matrix=matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix)
\ : (c.Is444 ? c.ConvertToYUV444(matrix=matrix).ConvertBits(bitdepth).MBT_SetMatrix(output_matrix) : c.MBT_SetMatrix(output_matrix))))
\ : (c.Is420 ? c.ConvertToYUV420(matrix=matrix).ConvertBits(bitdepth).PropCopy(c).MBT_SetSDRColor(output_matrix, mbt_color_range)
\ : (c.Is422 ? c.ConvertToYUV422(matrix=matrix).ConvertBits(bitdepth).PropCopy(c).MBT_SetSDRColor(output_matrix, mbt_color_range)
\ : (c.Is444 ? c.ConvertToYUV444(matrix=matrix).ConvertBits(bitdepth).PropCopy(c).MBT_SetSDRColor(output_matrix, mbt_color_range) : c.MBT_SetSDRColor(output_matrix, mbt_color_range))))
}
function MBT_ToSDR(clip c, float "hdr_peak", float "sdr_peak", string "method", float "exposure", bool "chroma_correction", string "output_matrix")
{
transfer = MBT_GetTransferCode(c)
primaries = MBT_GetPrimariesCode(c)
color_range = MBT_GetRangeCode(c)
hdr_mode = transfer == 18 ? 2 : 0
Assert(transfer == 16 || transfer == 18, "MBT_ToSDR requires PQ or HLG input.")
Assert(!c.IsRGB, "MBT_ToSDR currently requires planar YUV input.")
Assert(primaries == 9,
\ "MBT_ToSDR currently requires Rec2020 primaries. P3 HDR needs mastering-primary support.")
method = Default(method, "MPC")
Assert(
\ method == "MPC" || method == "Hable" || method == "Mobius" || method == "Reinhard" || method == "ACES" || method == "BT2446C",
\ "MBT_ToSDR method must be MPC, Hable, Mobius, Reinhard, ACES, or BT2446C.")
chroma_correction = Default(chroma_correction, false)
hdr_peak = Default(hdr_peak, hdr_mode == 0 ? 10000.0 : 1000.0)
sdr_peak = Default(sdr_peak, 125.0)
output_matrix = Default(output_matrix, MBT_DefaultMatrix(c.Width, c.Height))
return method == "BT2446C"
\ ? c.MBT_ToSDR_BT2446C(hdr_mode, color_range, hdr_peak, sdr_peak, chroma_correction, output_matrix)
\ : method == "MPC"
\ ? c.MBT_ToSDR_MPC(hdr_mode, color_range, hdr_peak, sdr_peak, exposure, output_matrix)
\ : c.MBT_ToSDR_RGB(hdr_mode, color_range, method, exposure, output_matrix)
}
function MBT_ToSDR_BT2446C(clip c, int hdr_mode, int color_range, float hdr_peak, float sdr_peak, bool chroma_correction, string output_matrix)
{
Assert(FunctionExists("ConvertYUVtoXYZ") && FunctionExists("ConvertXYZtoYUV") && FunctionExists("ConverXYZ_BT2446_C_HDRtoSDR"),
\ "MBT_ToSDR requires HDRTools. Load HDRTools before calling it.")
output_color = MBT_SDRColorForMatrix(output_matrix)
linear = c.ConvertYUVtoXYZ(Color=0, OutputMode=2, HDRMode=hdr_mode, OOTF=false, fullrange=color_range == 1)
linear = linear.ConverXYZ_BT2446_C_HDRtoSDR(ChromaC=chroma_correction, PQMode=hdr_mode == 0, Lhdr=hdr_peak, Lsdr=sdr_peak, pColor=0)
result = linear.ConvertXYZtoYUV(Color=output_color, OutputMode=MBT_YuvOutputMode(c), OOTF=false, fullrange=false, pColor=0)
return result.MBT_SetToSDRProperties(c, output_matrix)
}
function MBT_ToSDR_RGB(clip c, int hdr_mode, int color_range, string method, float "exposure", string "output_matrix")
{
Assert(FunctionExists("ConvertYUVtoLinearRGB") && FunctionExists("ConvertRGBtoXYZ") && FunctionExists("ConvertXYZtoYUV"),
\ "MBT_ToSDR requires HDRTools. Load HDRTools before calling it.")
Assert(
\ (method == "Hable" && FunctionExists("ConvertRGB_Hable_HDRtoSDR")) ||
\ (method == "Mobius" && FunctionExists("ConvertRGB_Mobius_HDRtoSDR")) ||
\ (method == "Reinhard" && FunctionExists("ConvertRGB_Reinhard_HDRtoSDR")) ||
\ (method == "ACES" && FunctionExists("ConvertRGB_ACES_HDRtoSDR")),
\ "MBT_ToSDR method is not provided by the loaded HDRTools plugin.")
linear = c.MBT_LinearHDR(hdr_mode, color_range)
scale = Defined(exposure) ? exposure : (method == "ACES" ? 1.0 : 2.0)
mapped = method == "Hable" ? linear.ConvertRGB_Hable_HDRtoSDR(exposure_R=scale)
\ : method == "Mobius" ? linear.ConvertRGB_Mobius_HDRtoSDR(exposure_R=scale)
\ : method == "Reinhard" ? linear.ConvertRGB_Reinhard_HDRtoSDR(exposure_R=scale)
\ : linear.ConvertRGB_ACES_HDRtoSDR(exposure_R=scale)
xyz = mapped.ConvertRGBtoXYZ(Color=1, OOTF=false, EOTF=false)
result = xyz.ConvertXYZtoYUV(Color=MBT_SDRColorForMatrix(output_matrix), OutputMode=MBT_YuvOutputMode(c), OOTF=false, fullrange=false, pColor=1)
return result.MBT_SetToSDRProperties(c, output_matrix)
}
function MBT_ToSDR_MPC(clip c, int hdr_mode, int color_range, float hdr_peak, float sdr_peak, float "exposure", string "output_matrix")
{
Assert(FunctionExists("ConvertYUVtoLinearRGB") && FunctionExists("ConvertRGBtoXYZ") && FunctionExists("ConvertXYZtoRGB") && FunctionExists("ConvertLinearRGBtoYUV"),
\ "MBT_ToSDR requires HDRTools. Load HDRTools before calling it.")
scale = Defined(exposure) ? exposure : hdr_peak / sdr_peak
output_color = MBT_SDRColorForMatrix(output_matrix)
linear = c.MBT_LinearHDR(hdr_mode, color_range)
xyz = linear.ConvertRGBtoXYZ(Color=1, OOTF=false, EOTF=false)
xyz = xyz.MBT_HableLuminance(scale)
rgb = xyz.ConvertXYZtoRGB(Color=output_color, OutputMode=0, OOTF=false, EOTF=false, pColor=1)
rgb = rgb.MBT_GamutMap(output_matrix)
result = rgb.ConvertLinearRGBtoYUV(Color=output_color, OutputMode=MBT_YuvOutputMode(c), OOTF=false, EOTF=true, fullrange=false)
return result.MBT_SetToSDRProperties(c, output_matrix)
}
function MBT_HableLuminance(clip xyz, float exposure)
{
luminance = CombinePlanes(xyz, planes="Y", source_planes="G", pixel_type="Y32")
mapped = luminance.Expr(MBT_HableExpression(exposure), format="Y32", clamp_float=true)
scale = Expr(luminance, mapped, "y x 0.000001 max /", format="Y32", clamp_float=false)
scale = MergeRGB(scale, scale, scale, pixel_type="RGBPS")
return Expr(xyz, scale, "x y *", "x y *", "x y *", format="RGBPS", clamp_float=false).PropCopy(xyz)
}
function MBT_HableExpression(float exposure)
{
return "x " + String(exposure) + " * T^ " + \
"T T 0.15 * 0.05 + * 0.004 + N^ " + \
"N T T 0.15 * 0.5 + * 0.06 + / 0.06666666666666667 - " + String(MBT_HableWhiteScale()) + " * 0 max 1 min"
}
function MBT_GamutMap(clip rgb, string matrix)
{
red = CombinePlanes(rgb, planes="Y", source_planes="R", pixel_type="Y32")
green = CombinePlanes(rgb, planes="Y", source_planes="G", pixel_type="Y32")
blue = CombinePlanes(rgb, planes="Y", source_planes="B", pixel_type="Y32")
luma = MBT_LumaExpression(matrix)
return Expr(
\ red, green, blue,
\ MBT_GamutExpression("x", luma),
\ MBT_GamutExpression("y", luma),
\ MBT_GamutExpression("z", luma),
\ format="RGBPS",
\ clamp_float=true
\).PropCopy(rgb)
}
function MBT_LumaExpression(string matrix)
{
return matrix == "Rec601" ? "x 0.299 * y 0.587 * + z 0.114 * +"
\ : matrix == "Rec2020" ? "x 0.2627 * y 0.678 * + z 0.0593 * +"
\ : "x 0.2126 * y 0.7152 * + z 0.0722 * +"
}
function MBT_GamutExpression(string channel, string luma)
{
return luma + " L^ " + \
"x y min z min N^ x y max z max X^ " + \
"X 1 > 1 L - X L - / 1 ? S^ " + \
"N 0 < L L N - / 1 ? T^ S T min S^ " + \
channel + " L - S * L +"
}
function MBT_HableWhiteScale()
{
# 1 / hable(4.8), using the normalized Hable curve constants.
return 1.7896902226524685
}
function MBT_SetToSDRProperties(clip c, clip source, string output_matrix)
{
global mbt_color_matrix = output_matrix
global mbt_color_primaries = MBT_PrimariesForMatrix(output_matrix)
global mbt_color_transfer = 1
global mbt_color_range = 0
return c.ConvertBits(source.BitsPerComponent).PropCopy(source).MBT_SetSDRColor(output_matrix, 0)
}
function DeShake(clip c, int "w", int "h", float "dar", float "crop", float "xcrop", float "ycrop", int "pos", bool "zoom", bool "rot", float "freq")
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
def avs_path(path: str) -> str:
return path.replace("\\", "/").replace('"', '\\"')
+51 -2
View File
@@ -36,6 +36,7 @@ static AVS_Library* avs_library = NULL;
#define avs_is_yv16 avs_library->avs_is_yv16
#define avs_is_yv24 avs_library->avs_is_yv24
#define avs_prop_get_int avs_library->avs_prop_get_int
#define avs_prop_get_float avs_library->avs_prop_get_float
#define avs_release_clip avs_library->avs_release_clip
#define avs_release_value avs_library->avs_release_value
#define avs_release_video_frame avs_library->avs_release_video_frame
@@ -217,6 +218,7 @@ static int require_frame_property_api(FILE* log) {
if (
avs_get_frame_props_ro
&& avs_prop_get_int
&& avs_prop_get_float
) {
return 1;
}
@@ -322,6 +324,13 @@ static int read_int_frame_prop(
int64_t* value_out
);
static int read_float_frame_prop(
AVS_ScriptEnvironment* env,
const AVS_VideoFrame* frame,
const char* name,
double* value_out
);
static int write_wav(AVS_Clip* clip, const AVS_VideoInfo* vi) {
if (!avs_has_audio(vi)) {
fprintf(stderr, "script has no audio\n");
@@ -463,6 +472,29 @@ static int read_int_frame_prop(
return 1;
}
static int read_float_frame_prop(
AVS_ScriptEnvironment* env,
const AVS_VideoFrame* frame,
const char* name,
double* value_out
) {
const AVS_Map* props = avs_get_frame_props_ro(env, frame);
if (!props) {
return 0;
}
int error = AVS_GETPROPERROR_SUCCESS;
double value = avs_prop_get_float(env, props, name, 0, &error);
if (error == AVS_GETPROPERROR_UNSET || error == AVS_GETPROPERROR_INDEX) {
return 0;
}
if (error != AVS_GETPROPERROR_SUCCESS) {
return -1;
}
*value_out = value;
return 1;
}
static int write_validation_row(
FILE* csv,
AVS_ScriptEnvironment* env,
@@ -473,11 +505,21 @@ static int write_validation_row(
int64_t source_frame = 0;
int64_t drop_frame = 0;
int64_t matrix = 0;
int64_t primaries = 0;
int64_t transfer = 0;
int64_t color_range = 0;
double absolute_timestretch = 0.0;
int source_id_status = read_int_frame_prop(env, frame, "mbt_source_id", &source_id);
int source_frame_status = read_int_frame_prop(env, frame, "mbt_source_frame", &source_frame);
int drop_frame_status = read_int_frame_prop(env, frame, "mbt_drop_frame", &drop_frame);
int matrix_status = read_int_frame_prop(env, frame, "_Matrix", &matrix);
if (source_id_status < 0 || source_frame_status < 0 || drop_frame_status < 0 || matrix_status < 0) {
int primaries_status = read_int_frame_prop(env, frame, "_Primaries", &primaries);
int transfer_status = read_int_frame_prop(env, frame, "_Transfer", &transfer);
int color_range_status = read_int_frame_prop(env, frame, "_ColorRange", &color_range);
int absolute_timestretch_status = read_float_frame_prop(
env, frame, "mbt_absolute_timestretch", &absolute_timestretch
);
if (source_id_status < 0 || source_frame_status < 0 || drop_frame_status < 0 || matrix_status < 0 || primaries_status < 0 || transfer_status < 0 || color_range_status < 0 || absolute_timestretch_status < 0) {
fprintf(stderr, "failed to read media-batch-tools frame properties at frame %d\n", output_frame);
return 12;
}
@@ -494,6 +536,13 @@ static int write_validation_row(
if (matrix_status > 0) {
fprintf(csv, "%lld", (long long)matrix);
}
fprintf(csv, ",%lld,%lld,%lld,",
primaries_status > 0 ? (long long)primaries : 0LL,
transfer_status > 0 ? (long long)transfer : 0LL,
color_range_status > 0 ? (long long)color_range : 0LL);
if (absolute_timestretch_status > 0) {
fprintf(csv, "%.17g", absolute_timestretch);
}
fputc('\n', csv);
return 0;
}
@@ -546,7 +595,7 @@ static int render_clip(
fprintf(stderr, "failed to open validation CSV for writing: %s\n", validation_csv_path);
return 11;
}
fputs("output_frame,source_id,source_frame,drop_frame,matrix\n", validation_csv);
fputs("output_frame,source_id,source_frame,drop_frame,matrix,primaries,transfer,color_range,absolute_timestretch\n", validation_csv);
fflush(validation_csv);
}
}
Binary file not shown.
Binary file not shown.
+23 -7
View File
@@ -15,6 +15,10 @@ class FrameIdentity:
source_frame: int | None
drop_frame: bool = False
matrix: int | None = None
primaries: int | None = None
transfer: int | None = None
color_range: int | None = None
absolute_timestretch: float | None = None
@dataclass(frozen=True)
@@ -112,23 +116,26 @@ def read_frame_identity_csv(path: Path) -> list[FrameIdentity]:
with path.open(newline="", encoding="utf-8") as handle:
reader = csv.reader(handle)
for line_number, row in enumerate(reader, start=1):
if line_number == 1 and row in (
["output_frame", "source_id", "source_frame", "drop_frame"],
["output_frame", "source_id", "source_frame", "drop_frame", "matrix"],
):
if line_number == 1 and row[0:4] == [
"output_frame", "source_id", "source_frame", "drop_frame"
]:
continue
if not row or all(not value.strip() for value in row):
continue
if len(row) not in {4, 5}:
if len(row) not in {4, 5, 6, 9}:
raise RuntimeError(
f"{path}:{line_number}: expected 4 or 5 CSV columns, got {len(row)}"
f"{path}:{line_number}: expected 4, 5, 6, or 9 CSV columns, got {len(row)}"
)
try:
output_frame = int(row[0])
source_id = _optional_int(row[1])
source_frame = _optional_int(row[2])
drop_frame = bool(int(row[3] or "0"))
matrix = _optional_int(row[4]) if len(row) == 5 else None
matrix = _optional_int(row[4]) if len(row) >= 5 else None
primaries = _optional_int(row[5]) if len(row) == 9 else None
transfer = _optional_int(row[6]) if len(row) == 9 else None
color_range = _optional_int(row[7]) if len(row) == 9 else None
absolute_timestretch = _optional_float(row[-1]) if len(row) >= 6 else None
except ValueError as exc:
raise RuntimeError(
f"{path}:{line_number}: invalid frame identity row: {row}"
@@ -140,6 +147,10 @@ def read_frame_identity_csv(path: Path) -> list[FrameIdentity]:
source_frame=source_frame,
drop_frame=drop_frame,
matrix=matrix,
primaries=primaries,
transfer=transfer,
color_range=color_range,
absolute_timestretch=absolute_timestretch,
)
)
return frames
@@ -150,6 +161,11 @@ def _optional_int(value: str) -> int | None:
return int(value) if value else None
def _optional_float(value: str) -> float | None:
value = value.strip()
return float(value) if value else None
def _format_int_list(values: list[int]) -> str:
if len(values) <= 10:
return ", ".join(str(value) for value in values)
+80 -26
View File
@@ -8,6 +8,7 @@ from datetime import timedelta, timezone
from pathlib import Path
from statistics import median
from tools.avisynth_paths import avs_path
from tools.video_formatting import (
format_frame_count,
format_framerate,
@@ -16,7 +17,13 @@ from tools.video_formatting import (
video_parts,
)
from tools.video_inputs import VideoInput
from tools.video_probe import VideoProbe
from tools.video_probe import (
VideoProbe,
avisynth_primaries_from_color_primaries,
avisynth_range_from_color_range,
avisynth_transfer_from_color_transfer,
hdr_kind,
)
WORKSPACE_DIR = Path("video_workspace")
@@ -197,7 +204,7 @@ def _visible_script(
item: VideoInput,
probe: VideoProbe,
) -> str:
import_path = _avs_path(os.path.relpath(source_script, visible_script.parent))
import_path = avs_path(os.path.relpath(source_script, visible_script.parent))
audio_script_name = f"{visible_script.stem}_audio.avs"
timing_kind = probe.timing.timing_kind.upper()
final_clip = _default_final_clip(probe)
@@ -220,24 +227,37 @@ def _visible_script(
"# Frame properties must be preserved.\n"
"\n"
"# Bundled helper functions:\n"
"# MBT_Drop(last, int start, int \"end\"), MBT_Undrop(...)\n"
"# MBT_DropEvery(last, int cycle, int offset0, ...), MBT_UndropEvery(...)\n"
"# MBT_Drop(clip c, int start, int \"end\"), MBT_Undrop(...)\n"
"# MBT_DropEvery(clip c, int cycle, int offset0, ...), MBT_UndropEvery(...)\n"
"# Similar syntax as Trim/SelectEvery. end defaults to -1, meaning one frame.\n"
"# Examples: MBT_Drop(last, 100), MBT_Drop(last, 100, -20),\n"
"# MBT_DropEvery(last, 4, 1, 3)\n"
"# MBT_Info(last, int \"size\", int \"align\")\n"
"# MBT_Info(clip c, int \"size\", int \"align\")\n"
"# Overlay source frame, video time, absolute time, duration, and drop marker.\n"
"# MBT_AbsoluteTimeStretch(clip c, float factor)\n"
"# Map source timestamps to real time, anchored to the metadata end timestamp.\n"
"# Example: MBT_AbsoluteTimeStretch(last, 1.0/8.0) for 8x slow motion.\n"
"# Resize(clip v, int \"w\", int \"h\", float \"dar\", float \"xcrop\", float \"ycrop\", int \"pos\", bool \"linear\", string \"input_matrix\", string \"output_matrix\")\n"
"# Resize/crop. linear auto-enables for large downscales unless set explicitly.\n"
"# MBT_CorrectMatrix(last)\n"
"# Convert to the resolution-expected matrix using _Matrix as input metadata.\n"
"# Resize/crop. HDR linear resizing requires HDRTools; linear=false preserves HDR unchanged.\n"
"# MBT_CorrectMatrix(clip c, string \"input_matrix\", string \"output_matrix\")\n"
"# Correct SDR matrix metadata. HDR color systems are preserved.\n"
"# MBT_ToSDR(clip c, float \"hdr_peak\", float \"sdr_peak\", string \"method\", float \"exposure\", bool \"chroma_correction\", string \"output_matrix\")\n"
"# HDR to SDR. MBT_ToSDR(10000, 125, \"MPC\") uses a 10,000 cd/m² PQ source\n"
"# peak and a 125 cd/m² SDR display target. Methods: MPC (default), Hable, Mobius,\n"
"# Reinhard, ACES, BT2446C. exposure overrides the method's default curve scale; chroma_correction\n"
"# applies only to BT2446C.\n"
"# MPC applies the Hable curve to XYZ luminance, then preserves chromaticity and compresses\n"
"# target-gamut chroma. Defaults: PQ hdr_peak=10000, HLG hdr_peak=1000, sdr_peak=125,\n"
"# chroma_correction=false, output_matrix=Rec709/Rec601 by output size.\n"
"# HDR10+ metadata is dynamic and is not used by HDRTools; this is a static tone map.\n"
"# Add LoadPlugin(\"path/to/HDRTools.dll\") below when using HDRTools.\n"
"# DeShake(clip c, int \"w\", int \"h\", float \"dar\", float \"crop\", float \"xcrop\", float \"ycrop\", int \"pos\", bool \"zoom\", bool \"rot\", float \"freq\")\n"
"# Stabilize through MVTools + DePan; those plugins must be loaded separately.\n"
"# Cropf(...)\n"
"# Cropf(clip c, float l, float t, float w, float h)\n"
"# Crop using fractional dimensions.\n"
"# RotateCrop(clip c, float angle, float \"dar\")\n"
"# Rotate with manyPlus Turn, then crop the largest background-free rectangle.\n"
"# FixContrast(...)\n"
"# FixContrast(clip c, int \"low\", int \"high\")\n"
"# Remap luma levels to limited-range video luma.\n"
"\n"
"# Optional audio edits:\n"
@@ -265,23 +285,23 @@ def _hidden_source_script(
duration_values: Path,
duration_label_values: Path,
) -> str:
source_path = _avs_path(str(item.path.resolve()))
import_path = _avs_path(os.path.relpath(helper_script, source_script.parent))
cache_path = _avs_path(
source_path = avs_path(str(item.path.resolve()))
import_path = avs_path(os.path.relpath(helper_script, source_script.parent))
cache_path = avs_path(
str(source_script.with_name(f"{item.collapsed_relative.name}.ffindex").resolve())
)
audio_cache_path = _avs_path(
audio_cache_path = avs_path(
str(source_script.with_name(f"{item.collapsed_relative.name}.audio.ffindex").resolve())
)
timestamp_values_path = _avs_path(str(timestamp_values.resolve()))
frame_time_values_path = _avs_path(str(frame_time_values.resolve()))
frame_time_label_values_path = _avs_path(str(frame_time_label_values.resolve()))
duration_values_path = _avs_path(str(duration_values.resolve()))
duration_label_values_path = _avs_path(str(duration_label_values.resolve()))
timestamp_values_path = avs_path(str(timestamp_values.resolve()))
frame_time_values_path = avs_path(str(frame_time_values.resolve()))
frame_time_label_values_path = avs_path(str(frame_time_label_values.resolve()))
duration_values_path = avs_path(str(duration_values.resolve()))
duration_label_values_path = avs_path(str(duration_label_values.resolve()))
if ffms2_plugin_path is None:
plugin_setup = "# FFMS2 is expected from Avisynth autoload or a previously loaded plugin.\n"
else:
plugin_setup = f'LoadPlugin("{_avs_path(str(ffms2_plugin_path.resolve()))}")\n'
plugin_setup = f'LoadPlugin("{avs_path(str(ffms2_plugin_path.resolve()))}")\n'
audio_setup = (
"try {\n"
" mbt_video_only = mbt_video_only\n"
@@ -310,16 +330,22 @@ def _hidden_source_script(
"\n"
f"{plugin_setup}"
f'Import("{import_path}")\n'
f'global mbt_color_matrix = "{probe.color_matrix or probe.expected_color_matrix or "Rec709"}"\n'
f"global mbt_color_primaries = {_primaries_code_for_source(probe)}\n"
f"global mbt_color_transfer = {_transfer_code_for_source(probe)}\n"
f"global mbt_color_range = {_range_code_for_source(probe)}\n"
f"global mbt_hdr_peak = {_hdr_peak_for_source(probe):.6f}\n"
"\n"
f'video = FFVideoSource("{source_path}", cachefile="{cache_path}")\n'
f"{validation_blank_setup}"
f"{audio_setup}"
f"last = MBT_MarkSource(source, {source_id}, {_matrix_code_for_source(probe)})\n"
f"last = MBT_MarkSource(source, {source_id}, {_matrix_code_for_source(probe)}, {_primaries_code_for_source(probe)}, {_transfer_code_for_source(probe)}, {_range_code_for_source(probe)}, {_hdr_peak_for_source(probe):.6f}, {_source_end_epoch_seconds(probe):.9f}, {(probe.duration_seconds or 0.0):.9f})\n"
f'last = ConditionalReader(last, "{timestamp_values_path}", "mbt_absolute_timestamp", false)\n'
f'last = ConditionalReader(last, "{frame_time_values_path}", "mbt_frame_time_seconds", false)\n'
f'last = ConditionalReader(last, "{frame_time_label_values_path}", "mbt_relative_timestamp", false)\n'
f'last = ConditionalReader(last, "{duration_values_path}", "mbt_frame_duration_seconds", false)\n'
f'last = ConditionalReader(last, "{duration_label_values_path}", "mbt_frame_duration", false)\n'
"last = MBT_MarkTiming(last)\n"
"last\n"
)
@@ -328,7 +354,7 @@ def _validation_script(
visible_script: Path,
validation_script: Path,
) -> str:
import_path = _avs_path(os.path.relpath(visible_script, validation_script.parent))
import_path = avs_path(os.path.relpath(visible_script, validation_script.parent))
return (
"# Generated by media-batch-tools. This file is regenerated by video_encode.py.\n"
"# The media-batch-tools runner reads frame identity properties from this clip.\n"
@@ -356,6 +382,15 @@ def _timestamp_values_file(probe: VideoProbe) -> str:
return "\n".join(lines) + "\n"
def _source_end_epoch_seconds(probe: VideoProbe) -> float:
if probe.metadata_end is None:
return 0.0
source_end = probe.metadata_end
if source_end.tzinfo is None:
source_end = source_end.replace(tzinfo=timezone.utc)
return source_end.timestamp()
def _frame_time_values_file(probe: VideoProbe) -> str:
lines = [
"# Generated by media-batch-tools for AviSynth ConditionalReader.",
@@ -453,6 +488,9 @@ def _format_video_summary(probe: VideoProbe) -> str:
bit_depth=probe.bit_depth,
)
parts.append(_format_matrix(probe))
detected_hdr = hdr_kind(probe.color_transfer, dynamic=probe.hdr10plus)
if detected_hdr is not None:
parts.append(detected_hdr)
return join_parts(parts)
@@ -471,6 +509,24 @@ def _matrix_code_for_source(probe: VideoProbe) -> int:
return _matrix_code(probe.color_matrix or probe.expected_color_matrix)
def _primaries_code_for_source(probe: VideoProbe) -> int:
return avisynth_primaries_from_color_primaries(probe.color_primaries)
def _transfer_code_for_source(probe: VideoProbe) -> int:
return avisynth_transfer_from_color_transfer(probe.color_transfer)
def _range_code_for_source(probe: VideoProbe) -> int:
return avisynth_range_from_color_range(probe.color_range)
def _hdr_peak_for_source(probe: VideoProbe) -> float:
# Zero means unknown. MBT_ToSDR then uses HDRTools' own HDR-mode default
# instead of pretending that an absent mastering display is 1000 cd/m².
return probe.hdr_peak_luminance or 0.0
def _matrix_code(matrix: str | None) -> int:
if matrix == "Rec709":
return 1
@@ -482,6 +538,8 @@ def _matrix_code(matrix: str | None) -> int:
def _default_final_clip(probe: VideoProbe) -> str:
if hdr_kind(probe.color_transfer) is not None:
return "last"
source_matrix = probe.color_matrix
expected_matrix = probe.expected_color_matrix
if source_matrix is None or expected_matrix is None or source_matrix == expected_matrix:
@@ -493,9 +551,5 @@ def _format_frame_count(probe: VideoProbe) -> str:
return format_frame_count(probe.stream_frame_count, len(probe.frame_timestamps))
def _avs_path(path: str) -> str:
return path.replace("\\", "/").replace('"', '\\"')
def helper_script_path() -> Path:
return Path(__file__).resolve().with_name(HELPER_SCRIPT_NAME)
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from typing import Any
def extract_hdr10plus_metadata(
*,
ffmpeg: Path,
hdr10plus_tool: Path,
source: Path,
output: Path,
) -> None:
"""Extract HDR10+ JSON without creating a full intermediate video file."""
output.parent.mkdir(parents=True, exist_ok=True)
ffmpeg_process = subprocess.Popen(
[
str(ffmpeg),
"-v", "error", "-i", str(source), "-map", "0:v:0",
"-c", "copy", "-bsf:v", "hevc_mp4toannexb", "-f", "hevc", "-",
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
assert ffmpeg_process.stdout is not None
try:
extracted = subprocess.run(
[str(hdr10plus_tool), "extract", "-o", str(output), "-"],
stdin=ffmpeg_process.stdout,
capture_output=True,
text=True,
)
finally:
ffmpeg_process.stdout.close()
ffmpeg_stderr = ffmpeg_process.stderr.read().decode(errors="replace")
ffmpeg_process.stderr.close()
ffmpeg_returncode = ffmpeg_process.wait()
if ffmpeg_returncode != 0:
raise RuntimeError(
f"FFmpeg could not extract the HEVC stream from {source}:\n{ffmpeg_stderr.strip()}"
)
if extracted.returncode != 0:
raise RuntimeError(
f"hdr10plus_tool could not extract metadata from {source}:\n"
f"{extracted.stderr.strip() or extracted.stdout.strip()}"
)
if not output.is_file():
raise RuntimeError(f"hdr10plus_tool did not create metadata JSON for {source}")
def select_hdr10plus_frames(
*,
source_json: Path,
source_frames: list[int],
output_json: Path,
) -> None:
"""Keep metadata for the encoded source frames and rebuild scene indices."""
payload = json.loads(source_json.read_text(encoding="utf-8"))
entries = payload.get("SceneInfo")
if not isinstance(entries, list) or not entries:
raise RuntimeError(f"HDR10+ JSON has no SceneInfo entries: {source_json}")
if not source_frames:
raise RuntimeError("Cannot create HDR10+ metadata for an empty output")
if min(source_frames) < 0 or max(source_frames) >= len(entries):
raise RuntimeError(
"HDR10+ metadata frame count does not cover the validated source frames "
f"({len(entries)} metadata entries, highest source frame {max(source_frames)})"
)
selected = [dict(entries[index]) for index in source_frames]
_reindex_scenes(selected)
payload["SceneInfo"] = selected
payload["SceneInfoSummary"] = _scene_summary(selected)
output_json.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
def _reindex_scenes(entries: list[dict[str, Any]]) -> None:
scene_id = 0
scene_frame_index = 0
previous: dict[str, Any] | None = None
for sequence_frame_index, entry in enumerate(entries):
if previous is not None and _scene_payload(entry) != _scene_payload(previous):
scene_id += 1
scene_frame_index = 0
entry["SceneFrameIndex"] = scene_frame_index
entry["SceneId"] = scene_id
entry["SequenceFrameIndex"] = sequence_frame_index
scene_frame_index += 1
previous = entry
def _scene_payload(entry: dict[str, Any]) -> dict[str, Any]:
return {
key: value
for key, value in entry.items()
if key not in {"SceneFrameIndex", "SceneId", "SequenceFrameIndex"}
}
def _scene_summary(entries: list[dict[str, Any]]) -> dict[str, list[int]]:
starts = [
index
for index, entry in enumerate(entries)
if entry.get("SceneFrameIndex") == 0
]
return {
"SceneFirstFrameIndex": starts,
"SceneFrameNumbers": [
next_start - start
for start, next_start in zip(starts, [*starts[1:], len(entries)])
],
}
+13 -12
View File
@@ -5,6 +5,7 @@ import subprocess
from pathlib import Path
from tools.exiftool import run_exiftool_command
from tools.filesystem import unique_path
EXCLUDED_COPY_TAGS = [
@@ -66,9 +67,16 @@ def copy_meaningful_metadata(
exiftool: str,
source: Path,
destination: Path,
*,
extra_excluded_tags: list[str] | None = None,
) -> None:
if destination.suffix.lower() in {".mp4", ".mov"}:
copy_meaningful_metadata_with_exiftool(exiftool, source, destination)
copy_meaningful_metadata_with_exiftool(
exiftool,
source,
destination,
extra_excluded_tags=extra_excluded_tags,
)
return
copy_container_metadata_with_ffmpeg(source, destination)
@@ -77,6 +85,8 @@ def copy_meaningful_metadata_with_exiftool(
exiftool: str,
source: Path,
destination: Path,
*,
extra_excluded_tags: list[str] | None = None,
) -> None:
args = [
"-overwrite_original",
@@ -84,6 +94,7 @@ def copy_meaningful_metadata_with_exiftool(
str(source),
"-all:all",
*EXCLUDED_COPY_TAGS,
*(extra_excluded_tags or []),
str(destination),
]
run_exiftool_command(
@@ -102,7 +113,7 @@ def copy_container_metadata_with_ffmpeg(source: Path, destination: Path) -> None
return
temp_output = destination.with_name(f"{destination.stem}.metadata-copy{destination.suffix}")
temp_output = _unique_path(temp_output)
temp_output = unique_path(temp_output)
command = [
ffmpeg,
"-y",
@@ -127,13 +138,3 @@ def copy_container_metadata_with_ffmpeg(source: Path, destination: Path) -> None
finally:
if temp_output.exists():
temp_output.unlink()
def _unique_path(path: Path) -> Path:
if not path.exists():
return path
for index in range(1, 10000):
candidate = path.with_name(f"{path.stem}-{index}{path.suffix}")
if not candidate.exists():
return candidate
raise RuntimeError(f"Could not find a unique temporary path for {path}")
+1 -1
View File
@@ -4,7 +4,7 @@ import re
from datetime import datetime, timedelta, timezone
TZ_RE = re.compile(r"^(?P<sign>[+-])(?P<h>\d{2})(?::?(?P<m>\d{2}))?$")
TZ_RE = re.compile(r"^(?P<sign>[+-])(?P<h>\d{1,2})(?::?(?P<m>\d{2}))?$")
def local_timezone() -> timezone:
+503 -271
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import os
import sys
from dataclasses import dataclass, replace
import tempfile
from dataclasses import dataclass, field, replace
from datetime import datetime, timezone
from pathlib import Path
@@ -14,17 +15,20 @@ from tools.avisynth_workspace import (
visible_script_path,
)
from tools.console import clear_screen, light_red, prompt_input, prompt_yes_no, red_strikethrough
from tools.video_color import ColorMetadata
from tools.filenames import format_rounded_filename_stem
from tools.hdr10plus import extract_hdr10plus_metadata, select_hdr10plus_frames
from tools.timezones import local_timezone, parse_timezone_offset
from tools.timezones import timezone_to_string
from tools.video_encode_output import (
ENCODER_PRESETS,
AudioEncodeOptions,
VideoEncodeResult,
VideoEncodeRequest,
VideoCodecOptions,
choose_video_pixel_format,
default_audio_bitrate,
encode_video_only_y4m,
ffmpeg_colorspace_from_matrix,
pixel_format_bit_depth,
print_encoder_statistics,
remux_video,
@@ -55,22 +59,18 @@ from tools.video_codec_constraints import (
)
from tools.video_inputs import VideoInput
from tools.video_formatting import chroma_family, matrix_name
from tools.video_options import (
OutputNamingOptions,
ask_audio_options,
ask_output_naming_options,
ask_video_codec_options,
)
from tools.video_outputs import (
EncodedItem,
OutputNamingOptions,
copy_video_metadata,
finalize_encoded_outputs,
output_begin_timestamp,
planned_output_path,
validate_encoded_video,
validate_output_color_metadata,
write_video_end_timestamp,
)
from tools.video_probe import VideoProbe, require_tool
from tools.video_probe import VideoProbe, is_hdr_transfer, probe_video, require_tool
from tools.video_reporting import format_validation_summary_lines
from tools.video_timeline import OutputTimeline
@@ -96,6 +96,54 @@ class EncodingSettings:
audio_options: AudioEncodeOptions
@dataclass(frozen=True)
class EncodingTools:
root: Path
ffmpeg: Path
ffprobe: str
exiftool: str
avs_runners: AvisynthRunnerSet
mkvmerge: Path | None
x264: Path | None
@dataclass
class EncodingAnswers:
timezone_default: timezone
video_copy_available: bool
values: dict[str, object] = field(default_factory=dict)
def get(self, key: str, default: object = None) -> object:
return self.values.get(key, default)
def set(self, key: str, value: object) -> None:
old_value = self.values.get(key)
self.values[key] = value
if old_value is None or old_value == value:
return
resets = {
"codec": {"crf", "preset", "profile", "level", "threads"},
"container": {"audio", "audio_bitrate"},
"timestamp_names": {"timezone"} if value is False else set(),
"audio": {"audio_bitrate", "audio_pitch"}
if value not in {"aac", "opus"}
else set(),
}
for dependent in resets.get(key, set()):
self.values.pop(dependent, None)
_ENV_CONTAINERS = {
"mp4": ".mp4", ".mp4": ".mp4",
"m": ".mkv", "mkv": ".mkv", ".mkv": ".mkv", "matroska": ".mkv",
}
_ENV_CODECS = {
"x264": "libx264", "h264": "libx264", "libx264": "libx264",
"x265": "libx265", "h265": "libx265", "hevc": "libx265", "libx265": "libx265",
"c": "copy", "copy": "copy", "streamcopy": "copy", "stream-copy": "copy",
}
def _plan_batch_outputs(
*,
inputs: list[VideoInput],
@@ -151,9 +199,6 @@ def encode_validated_video_only(
print("\nNo validated outputs to encode.")
return
ffmpeg = Path(require_tool("ffmpeg")).resolve()
default_timezone = _default_output_timezone(
[probes_by_path[item.path.resolve()] for item in inputs]
)
if sys.stdin.isatty() and not _encoding_env_is_set():
settings = ask_interactive_encoding_settings(
inputs=inputs,
@@ -161,20 +206,22 @@ def encode_validated_video_only(
clip_infos=clip_infos,
probes_by_path=probes_by_path,
)
if settings is None:
print("\nEncoding skipped.")
return
naming = settings.naming
codec_options = settings.codec_options
audio_options = settings.audio_options
else:
naming = ask_output_naming_options(default_timezone=default_timezone)
codec_options = ask_video_codec_options()
audio_options = ask_audio_options(
has_speed_changes=has_avisynth_speed_changes(timelines, clip_infos),
container_extension=codec_options.extension,
settings = _environment_encoding_settings(
_default_output_timezone([probes_by_path[item.path.resolve()] for item in inputs])
)
if settings is None:
print("\nEncoding skipped.")
return
naming, codec_options, audio_options = (
settings.naming,
settings.codec_options,
settings.audio_options,
)
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
specs_by_path = {
item.path.resolve(): spec for item, spec in zip(inputs, specs)
}
copy_available = _video_copy_available(inputs, timelines, clip_infos, probes_by_path)
if codec_options.codec == "copy" and not copy_available:
raise RuntimeError(
@@ -237,6 +284,7 @@ def encode_validated_video_only(
naming=naming,
extension=codec_options.extension,
)
tools = EncodingTools(root, ffmpeg, ffprobe, exiftool, avs_runners, mkvmerge, x264)
encoded_items: list[EncodedItem] = []
for item_index, item in enumerate(inputs, start=1):
timeline = timelines[item.path.resolve()]
@@ -248,165 +296,34 @@ def encode_validated_video_only(
clip_info = clip_infos.get(item.path.resolve())
probe = probes_by_path[item.path.resolve()]
if codec_options.codec == "copy":
output = output_paths[item.path.resolve()]
if audio_options.mode == "copy":
audio_source, source_has_audio = item.path, probe.audio_stream_count > 0
else:
audio_source, source_has_audio = prepare_audio_source(
avs_runners=avs_runners,
root=root,
encoded_items.append(
_remux_item(
tools=tools,
item=item,
original_has_audio=probe.audio_stream_count > 0,
item_index=item_index,
item_count=len(inputs),
probe=probe,
timeline=timeline,
audio_options=audio_options,
output=output_paths[item.path.resolve()],
)
result = remux_video(
ffmpeg=ffmpeg,
source=item.path,
audio_source=audio_source,
output=output,
audio_options=audio_options,
source_has_audio=source_has_audio,
frame_count=len(timeline.frames),
progress_label=f"script {item_index}/{len(inputs)}: {item.collapsed_relative}",
)
validate_encoded_video(
ffprobe,
result.output,
timeline,
expected_duration=timeline.duration_seconds,
expected_timestamps=timeline_frame_starts(timeline),
expected_audio=audio_options.mode != "none" and source_has_audio,
)
copy_video_metadata(exiftool, item.path, result.output)
write_video_end_timestamp(
exiftool, result.output, probe, timeline, timeline.duration_seconds
)
encoded_items.append(EncodedItem(input_item=item, output=result.output))
print(f" remuxed output: {result.output}")
continue
output_bit_depth = clip_info.bit_depth if clip_info is not None else 8
pixel_format = choose_video_pixel_format(
ffmpeg,
codec_options.codec,
output_bit_depth,
clip_info.chroma if clip_info is not None else None,
)
spec = _constraint_spec_for_item(item, timelines, clip_infos, probes_by_path)
encoded_spec = VideoConstraintSpec(
width=spec.width,
height=spec.height,
bit_depth=pixel_format_bit_depth(pixel_format),
chroma=spec.chroma,
fps=spec.fps,
)
effective_codec_options = _resolve_codec_options(codec_options, encoded_spec)
if effective_codec_options.profile or effective_codec_options.level:
print(
" constraints: "
f"profile {effective_codec_options.profile or 'none'}, "
f"level {effective_codec_options.level or 'none'}"
)
colorspace = ffmpeg_colorspace_from_matrix(timeline.matrix)
if output_bit_depth >= 10:
encoded_bit_depth = pixel_format_bit_depth(pixel_format)
print(
f" video: {output_bit_depth}-bit Avisynth output -> "
f"{encoded_bit_depth}-bit {pixel_format}"
)
if colorspace is not None:
print(f" color space: {matrix_name(timeline.matrix)} -> ffmpeg {colorspace}")
normal_deleted_frames = normal_deleted_frame_count(timeline)
preserve_video_timestamps = should_preserve_video_timestamps(timeline)
if not preserve_video_timestamps and timeline.timing_kind != "cfr":
print(" skipped encode: output timeline is not CFR")
continue
if not preserve_video_timestamps and timeline.dropped_frame_count and (
clip_info is not None and avisynth_duration_differs(clip_info, timeline)
):
print(
" skipped encode: drop_frame plus Avisynth speed change needs "
"timestamp-aware muxing"
)
continue
output_duration = (
timeline.duration_seconds
if preserve_video_timestamps
else encoded_duration_seconds(clip_info, timeline)
)
audio_tempo = audio_tempo_factor(timeline, output_duration)
output = output_paths[item.path.resolve()]
effective_audio_options = audio_options_for_probe(
audio_options,
probe,
timeline,
output_duration,
normal_deleted_frames=normal_deleted_frames,
)
audio_segments = (
timeline.audio_segments
if should_use_audio_segments(timeline, effective_audio_options)
else None
)
audio_source, source_has_audio = prepare_audio_source(
avs_runners=avs_runners,
root=root,
encoded_item = _encode_item(
tools=tools,
item=item,
original_has_audio=probe.audio_stream_count > 0,
audio_options=effective_audio_options,
item_index=item_index,
item_count=len(inputs),
timeline=timeline,
clip_info=clip_info,
probe=probe,
spec=_constraint_spec(specs_by_path[item.path.resolve()]),
codec_options=codec_options,
audio_options=audio_options,
output=output_paths[item.path.resolve()],
)
encode_kwargs = dict(
y4m_command=y4m_command_for_timeline(
avs_runner,
timeline,
force_timeline_rate=preserve_video_timestamps,
),
ffmpeg=ffmpeg,
script=script,
audio_source=audio_source,
output=output,
options=effective_codec_options,
audio_options=effective_audio_options,
audio_start=timeline.frames[0].start,
audio_duration=timeline.duration_seconds,
audio_segments=audio_segments,
audio_tempo=audio_tempo,
audio_sample_rate=probe.audio_sample_rate,
source_has_audio=source_has_audio,
allow_audio_copy=effective_audio_options.mode == "copy",
pixel_format=pixel_format,
colorspace=colorspace,
frame_count=len(timeline.frames),
progress_label=(
f"script {item_index}/{len(inputs)}: {item.collapsed_relative}"
),
)
if preserve_video_timestamps:
assert mkvmerge is not None
result = encode_video_with_timestamps(
mkvmerge=mkvmerge,
x264=x264,
frame_durations=[frame.duration for frame in timeline.frames],
**encode_kwargs,
)
else:
result = encode_video_only_y4m(**encode_kwargs)
validate_encoded_video(
ffprobe,
result.output,
timeline,
expected_duration=output_duration,
expected_timestamps=(
timeline_frame_starts(timeline) if preserve_video_timestamps else None
),
expected_audio=(
effective_audio_options.mode != "none" and source_has_audio
),
)
print_encoder_statistics(result.encoder_log)
copy_video_metadata(exiftool, item.path, result.output)
write_video_end_timestamp(exiftool, result.output, probe, timeline, output_duration)
encoded_items.append(EncodedItem(input_item=item, output=result.output))
print(f" encoded output: {result.output}")
if encoded_item is not None:
encoded_items.append(encoded_item)
if not encoded_items:
print("\nNo outputs encoded.")
@@ -415,6 +332,265 @@ def encode_validated_video_only(
finalize_encoded_outputs(root, encoded_items)
def _remux_item(
*,
tools: EncodingTools,
item: VideoInput,
item_index: int,
item_count: int,
probe: VideoProbe,
timeline: OutputTimeline,
audio_options: AudioEncodeOptions,
output: Path,
) -> EncodedItem:
if audio_options.mode == "copy":
audio_source, source_has_audio = item.path, probe.audio_stream_count > 0
else:
audio_source, source_has_audio = prepare_audio_source(
avs_runners=tools.avs_runners,
root=tools.root,
item=item,
original_has_audio=probe.audio_stream_count > 0,
audio_options=audio_options,
)
result = remux_video(
ffmpeg=tools.ffmpeg,
source=item.path,
audio_source=audio_source,
output=output,
audio_options=audio_options,
source_has_audio=source_has_audio,
frame_count=len(timeline.frames),
progress_label=f"script {item_index}/{item_count}: {item.collapsed_relative}",
)
return _finalize_encoded_item(
ffprobe=tools.ffprobe,
exiftool=tools.exiftool,
item=item,
probe=probe,
timeline=timeline,
result=result,
expected_duration=timeline.duration_seconds,
expected_timestamps=timeline_frame_starts(timeline),
expected_audio=audio_options.mode != "none" and source_has_audio,
label="remuxed",
)
def _encode_item(
*,
tools: EncodingTools,
item: VideoInput,
item_index: int,
item_count: int,
timeline: OutputTimeline,
clip_info: AvisynthClipInfo | None,
probe: VideoProbe,
spec: VideoConstraintSpec,
codec_options: VideoCodecOptions,
audio_options: AudioEncodeOptions,
output: Path,
) -> EncodedItem | None:
if probe.hdr_dynamic and not probe.hdr10plus and timeline.transfer in {16, 18}:
raise RuntimeError(
f"{item.collapsed_relative} has dynamic HDR metadata that is not HDR10+. "
"Stream copy preserves it, but re-encoding it is not implemented yet."
)
if is_hdr_transfer(probe.color_transfer) and timeline.transfer is None:
raise RuntimeError(
f"{item.collapsed_relative} is HDR, but the Avisynth runner did not report "
"output color properties. Use the current bundled runner before encoding HDR."
)
output_bit_depth = clip_info.bit_depth if clip_info else 8
pixel_format = choose_video_pixel_format(
tools.ffmpeg,
codec_options.codec,
output_bit_depth,
clip_info.chroma if clip_info else None,
)
options = _resolve_codec_options(
codec_options, replace(spec, bit_depth=pixel_format_bit_depth(pixel_format))
)
if options.profile or options.level:
print(
f" constraints: profile {options.profile or 'none'}, "
f"level {options.level or 'none'}"
)
color = ColorMetadata(
timeline.matrix, timeline.primaries, timeline.transfer, timeline.color_range
)
static_hdr = (
(probe.mastering_display, probe.max_content_light, probe.max_frame_average_light, timeline.transfer == 16)
if timeline.transfer in {16, 18}
else None
)
if output_bit_depth >= 10:
print(
f" video: {output_bit_depth}-bit Avisynth output -> "
f"{pixel_format_bit_depth(pixel_format)}-bit {pixel_format}"
)
if color.colorspace:
print(f" color space: {matrix_name(timeline.matrix)} -> ffmpeg {color.colorspace}")
preserve_timestamps = should_preserve_video_timestamps(timeline)
if not preserve_timestamps and timeline.timing_kind != "cfr":
print(" skipped encode: output timeline is not CFR")
return None
if not preserve_timestamps and timeline.dropped_frame_count and (
clip_info is not None and avisynth_duration_differs(clip_info, timeline)
):
print(" skipped encode: drop_frame plus Avisynth speed change needs timestamp-aware muxing")
return None
output_duration = (
timeline.duration_seconds if preserve_timestamps else encoded_duration_seconds(clip_info, timeline)
)
effective_audio = audio_options_for_probe(
audio_options,
probe,
timeline,
output_duration,
normal_deleted_frames=normal_deleted_frame_count(timeline),
)
audio_source, source_has_audio = prepare_audio_source(
avs_runners=tools.avs_runners,
root=tools.root,
item=item,
original_has_audio=probe.audio_stream_count > 0,
audio_options=effective_audio,
)
dynamic_hdr, dynamic_hdr_temp = _dynamic_hdr_metadata(tools, item, probe, timeline, options)
script = visible_script_path(tools.root, item)
request = VideoEncodeRequest(
y4m_command=y4m_command_for_timeline(
tools.avs_runners.for_script(script), timeline, force_timeline_rate=preserve_timestamps
),
ffmpeg=tools.ffmpeg,
script=script,
audio_source=audio_source,
output=output,
options=options,
audio_options=effective_audio,
audio_start=timeline.frames[0].start,
audio_duration=timeline.duration_seconds,
audio_segments=timeline.audio_segments if should_use_audio_segments(timeline, effective_audio) else None,
audio_tempo=audio_tempo_factor(timeline, output_duration),
audio_sample_rate=probe.audio_sample_rate,
source_has_audio=source_has_audio,
allow_audio_copy=effective_audio.mode == "copy",
pixel_format=pixel_format,
colorspace=color.colorspace,
color_metadata=color,
static_hdr=static_hdr,
dynamic_hdr10plus=dynamic_hdr,
frame_count=len(timeline.frames),
progress_label=f"script {item_index}/{item_count}: {item.collapsed_relative}",
)
try:
if preserve_timestamps:
if tools.mkvmerge is None:
raise RuntimeError("mkvmerge is required for timestamp-aware encoding")
result = encode_video_with_timestamps(
request=request,
mkvmerge=tools.mkvmerge,
x264=tools.x264,
frame_durations=[frame.duration for frame in timeline.frames],
)
else:
result = encode_video_only_y4m(request)
finally:
if dynamic_hdr_temp is not None:
dynamic_hdr_temp.cleanup()
return _finalize_encoded_item(
ffprobe=tools.ffprobe,
exiftool=tools.exiftool,
item=item,
probe=probe,
timeline=timeline,
result=result,
expected_duration=output_duration,
expected_timestamps=timeline_frame_starts(timeline) if preserve_timestamps else None,
expected_audio=effective_audio.mode != "none" and source_has_audio,
label="encoded",
)
def _dynamic_hdr_metadata(
tools: EncodingTools,
item: VideoInput,
probe: VideoProbe,
timeline: OutputTimeline,
options: VideoCodecOptions,
) -> tuple[Path | None, tempfile.TemporaryDirectory[str] | None]:
if not (probe.hdr10plus and timeline.transfer in {16, 18}):
return None, None
if options.codec != "libx265":
raise RuntimeError(
f"{item.collapsed_relative} has HDR10+ metadata. HDR-preserving re-encoding "
"requires x265; use MBT_ToSDR for SDR output."
)
if probe.codec != "hevc":
raise RuntimeError(f"HDR10+ metadata extraction currently requires HEVC input: {item.path}")
temporary = tempfile.TemporaryDirectory(prefix="mbt-hdr10plus-")
root = Path(temporary.name)
source_json = root / "source.hdr10plus.json"
output_json = root / "selected.hdr10plus.json"
try:
extract_hdr10plus_metadata(
ffmpeg=tools.ffmpeg,
hdr10plus_tool=Path(require_tool("hdr10plus_tool")),
source=item.path,
output=source_json,
)
select_hdr10plus_frames(
source_json=source_json,
source_frames=[frame.source_frame for frame in timeline.frames],
output_json=output_json,
)
except Exception:
temporary.cleanup()
raise
print(f" HDR10+: preserving metadata for {len(timeline.frames)} output frame(s)")
return output_json, temporary
def _finalize_encoded_item(
*,
ffprobe: str,
exiftool: str,
item: VideoInput,
probe: VideoProbe,
timeline: OutputTimeline,
result: VideoEncodeResult,
expected_duration: float,
expected_timestamps: list[float] | None,
expected_audio: bool,
label: str,
) -> EncodedItem:
validate_encoded_video(
ffprobe,
result.output,
timeline,
expected_duration=expected_duration,
expected_timestamps=expected_timestamps,
expected_audio=expected_audio,
)
if result.encoder_log:
print_encoder_statistics(result.encoder_log)
copy_video_metadata(exiftool, item.path, result.output)
validate_output_color_metadata(
result.output,
probe_video(ffprobe, result.output, {}),
timeline,
expected_hdr10plus=probe.hdr10plus and timeline.transfer in {16, 18},
)
write_video_end_timestamp(exiftool, result.output, probe, timeline)
print(f" {label} output: {result.output}")
return EncodedItem(input_item=item, output=result.output)
def ask_interactive_encoding_settings(
*,
inputs: list[VideoInput],
@@ -422,30 +598,19 @@ def ask_interactive_encoding_settings(
clip_infos: dict[Path, AvisynthClipInfo],
probes_by_path: dict[Path, VideoProbe],
) -> EncodingSettings | None:
answers: dict[str, object] = {
"_timezone_default": _default_output_timezone(
answers = EncodingAnswers(
timezone_default=_default_output_timezone(
[probes_by_path[item.path.resolve()] for item in inputs]
)
}
index = 0
has_speed_changes = has_avisynth_speed_changes(timelines, clip_infos)
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
answers["_video_copy_available"] = _video_copy_available(
inputs, timelines, clip_infos, probes_by_path
),
video_copy_available=_video_copy_available(
inputs, timelines, clip_infos, probes_by_path
),
)
def answer(key: str, value: object) -> None:
old_value = answers.get(key)
answers[key] = value
if key == "codec" and old_value is not None and old_value != value:
_forget(answers, {"crf", "preset", "profile", "level", "threads"})
if key == "container" and old_value is not None and old_value != value:
_forget(answers, {"audio", "audio_bitrate"})
if key == "timestamp_names" and value is False:
_forget(answers, {"timezone"})
if key == "audio" and value not in {"aac", "opus"}:
_forget(answers, {"audio_bitrate", "audio_pitch"})
index = 0
has_speed_changes = has_avisynth_speed_changes(
timelines, clip_infos, probes_by_path
)
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
while True:
steps = _encoding_steps(answers, has_speed_changes)
index = max(0, min(index, len(steps) - 1))
@@ -462,30 +627,28 @@ def ask_interactive_encoding_settings(
print(exc)
prompt_input("Press Enter to retry...")
continue
answer(key, value)
answers.set(key, value)
if key == "confirm" and value is False:
return None
if index == len(steps) - 1:
break
index += 1
codec = answers["codec"]
codec = answers.get("codec")
assert isinstance(codec, str)
audio_mode = answers.get("audio", "aac" if answers["container"] == ".mp4" else "opus")
container = str(answers.get("container", ".mp4"))
audio_mode = answers.get("audio", "aac" if container == ".mp4" else "opus")
assert isinstance(audio_mode, str)
return EncodingSettings(
naming=OutputNamingOptions(
use_timestamps=bool(answers["timestamp_names"]),
timezone_value=answers.get(
"timezone",
answers["_timezone_default"],
),
use_timestamps=bool(answers.get("timestamp_names")),
timezone_value=answers.get("timezone", answers.timezone_default),
),
codec_options=VideoCodecOptions(
codec=codec,
crf=int(answers.get("crf", 21)),
preset=str(answers.get("preset", "slow")),
extension=str(answers["container"]),
extension=container,
profile=_none_if_none(str(answers.get("profile", "none"))),
level=_none_if_none(str(answers.get("level", "none"))),
threads=_threads_from_answer(answers.get("threads", "auto")),
@@ -516,7 +679,103 @@ def _encoding_env_is_set() -> bool:
return any(os.environ.get(name) for name in names)
def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list[str]:
def _environment_encoding_settings(default_timezone: timezone) -> EncodingSettings:
timestamp_names = _env_is_truthy("MBT_VIDEO_TIMESTAMP_NAMES")
timezone_value = default_timezone
if timestamp_names and (raw_timezone := os.environ.get("MBT_VIDEO_TIMEZONE", "").strip()):
try:
timezone_value = parse_timezone_offset(raw_timezone)
except ValueError as exc:
raise RuntimeError(f"Invalid MBT_VIDEO_TIMEZONE: {exc}") from exc
container = _environment_choice(
"MBT_VIDEO_CONTAINER",
"mp4",
_ENV_CONTAINERS,
"mp4 or mkv",
)
codec = _environment_choice(
"MBT_VIDEO_CODEC",
"x265",
_ENV_CODECS,
"x264, x265, or copy",
)
crf = _environment_int("MBT_VIDEO_CRF", 16 if codec == "libx264" else 21)
preset = os.environ.get("MBT_VIDEO_PRESET", "slow").strip().lower() or "slow"
if preset not in ENCODER_PRESETS:
raise RuntimeError(f"MBT_VIDEO_PRESET must be one of: {'/'.join(ENCODER_PRESETS)}")
threads = _environment_threads()
audio_mode = _parse_audio(os.environ.get("MBT_VIDEO_AUDIO", "none"))
preserve_pitch = _environment_audio_pitch()
return EncodingSettings(
naming=OutputNamingOptions(timestamp_names, timezone_value),
codec_options=VideoCodecOptions(
codec=codec,
crf=crf,
preset=preset,
extension=container,
profile=_optional_environment("MBT_VIDEO_PROFILE"),
level=_optional_environment("MBT_VIDEO_LEVEL"),
threads=threads,
),
audio_options=AudioEncodeOptions(
mode=audio_mode,
bitrate=os.environ.get("MBT_VIDEO_AUDIO_BITRATE", "").strip()
or default_audio_bitrate(audio_mode),
preserve_pitch=preserve_pitch,
),
)
def _env_is_truthy(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "y", "on"}
def _environment_choice(
name: str,
default: str,
values: dict[str, str],
allowed: str,
) -> str:
value = os.environ.get(name, default).strip().lower()
try:
return values[value]
except KeyError as exc:
raise RuntimeError(f"{name} must be {allowed}") from exc
def _environment_int(name: str, default: int) -> int:
value = os.environ.get(name, "").strip()
try:
return int(value) if value else default
except ValueError as exc:
raise RuntimeError(f"{name} must be an integer") from exc
def _environment_threads() -> int | None:
value = os.environ.get("MBT_VIDEO_THREADS", "").strip().lower()
if not value or value == "auto":
return None
threads = _environment_int("MBT_VIDEO_THREADS", 0)
if threads < 1:
raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer")
return threads
def _environment_audio_pitch() -> bool:
value = os.environ.get("MBT_VIDEO_AUDIO_PITCH", "").strip().lower()
if not value or value in {"preserve", "preserved", "keep", "same"}:
return True
if value in {"shift", "change", "changed", "speed"}:
return False
raise RuntimeError("MBT_VIDEO_AUDIO_PITCH must be preserve or shift")
def _optional_environment(name: str) -> str | None:
return os.environ.get(name, "").strip() or None
def _encoding_steps(answers: EncodingAnswers, has_speed_changes: bool) -> list[str]:
steps = ["timestamp_names"]
if answers.get("timestamp_names", True):
steps.append("timezone")
@@ -533,7 +792,7 @@ def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list
def _render_encoding_screen(
answers: dict[str, object],
answers: EncodingAnswers,
current_key: str,
specs: list[EncodingOutputSpec],
has_speed_changes: bool,
@@ -563,7 +822,7 @@ def _render_encoding_screen(
print(light_red("x264 is unavailable because this batch contains output above 10-bit.\n"))
def _encoding_table_keys(answers: dict[str, object], has_speed_changes: bool) -> list[str]:
def _encoding_table_keys(answers: EncodingAnswers, has_speed_changes: bool) -> list[str]:
keys = [
"timestamp_names",
"container",
@@ -584,7 +843,7 @@ def _encoding_table_keys(answers: dict[str, object], has_speed_changes: bool) ->
def _question_for_key(
key: str,
answers: dict[str, object],
answers: EncodingAnswers,
specs: list[EncodingOutputSpec],
) -> str:
if key == "timestamp_names":
@@ -611,7 +870,7 @@ def _question_for_key(
return prompt + (light_red(" ".join(warnings)) if warnings else "")
if key == "codec":
options = ["x264", "x265"]
if answers.get("_video_copy_available"):
if answers.video_copy_available:
options.append("copy")
if _x264_unavailable(specs):
options[0] = red_strikethrough(options[0])
@@ -624,20 +883,12 @@ def _question_for_key(
if key == "preset":
default = _default_answer(key, answers, specs)
return f"Encoder preset [{default}] ({'/'.join(ENCODER_PRESETS)}): "
if key == "profile":
codec = str(answers["codec"])
options = _profile_options(codec, specs)
unsupported = _not_supported_by_all(codec, options, specs, profile=True)
return "Profile " + _options_prompt(
options,
str(_default_answer(key, answers, specs)),
unsupported,
) + ": "
if key == "level":
codec = str(answers["codec"])
options = _level_options(codec, specs)
unsupported = _not_supported_by_all(codec, options, specs, profile=False)
return "Level " + _options_prompt(
if key in {"profile", "level"}:
codec = str(answers.get("codec"))
profile = key == "profile"
options = _profile_options(codec, specs) if profile else _level_options(codec, specs)
unsupported = _not_supported_by_all(codec, options, specs, profile=profile)
return key.title() + " " + _options_prompt(
options,
str(_default_answer(key, answers, specs)),
unsupported,
@@ -647,7 +898,7 @@ def _question_for_key(
if key == "audio":
return f"Audio [{_default_answer(key, answers, specs)}] (opus/aac/flac/copy/none): "
if key == "audio_bitrate":
return f"{str(answers['audio']).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: "
return f"{str(answers.get('audio')).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: "
if key == "audio_pitch":
return "Speed-changed audio pitch [preserve] (preserve/shift): "
if key == "confirm":
@@ -658,7 +909,7 @@ def _question_for_key(
def _parse_answer(
key: str,
raw: str,
answers: dict[str, object],
answers: EncodingAnswers,
specs: list[EncodingOutputSpec],
) -> object:
default = _default_answer(key, answers, specs)
@@ -698,7 +949,7 @@ def _parse_answer(
if lowered in {"x265", "h265", "hevc", "libx265"}:
return "libx265"
if lowered in {"copy", "c"}:
if not answers.get("_video_copy_available"):
if not answers.video_copy_available:
raise ValueError(
"Video copy is unavailable because a validated script changes "
"video frames or properties."
@@ -714,12 +965,13 @@ def _parse_answer(
if lowered not in ENCODER_PRESETS:
raise ValueError(f"Preset must be one of: {'/'.join(ENCODER_PRESETS)}.")
return lowered
if key == "profile":
options = _profile_options(str(answers["codec"]), specs)
return _parse_option("profile", value, options)
if key == "level":
options = _level_options(str(answers["codec"]), specs)
return _parse_option("level", value, options)
if key in {"profile", "level"}:
options = (
_profile_options(str(answers.get("codec")), specs)
if key == "profile"
else _level_options(str(answers.get("codec")), specs)
)
return _parse_option(key, value, options)
if key == "threads":
if lowered == "auto":
return "auto"
@@ -745,18 +997,19 @@ def _parse_answer(
def _default_answer(
key: str,
answers: dict[str, object],
answers: EncodingAnswers,
specs: list[EncodingOutputSpec],
) -> object:
if key in answers:
return answers[key]
value = answers.get(key)
if value is not None:
return value
codec = str(answers.get("codec", "libx265"))
if key == "confirm":
return True
if key == "timestamp_names":
return True
if key == "timezone":
return answers.get("_timezone_default", local_timezone())
return answers.timezone_default
if key == "container":
return ".mp4"
if key == "codec":
@@ -864,17 +1117,6 @@ def _constraint_spec(spec: EncodingOutputSpec) -> VideoConstraintSpec:
)
def _constraint_spec_for_item(
item: VideoInput,
timelines: dict[Path, OutputTimeline],
clip_infos: dict[Path, AvisynthClipInfo],
probes_by_path: dict[Path, VideoProbe],
) -> VideoConstraintSpec:
return _constraint_spec(
_encoding_specs([item], timelines, clip_infos, probes_by_path)[0]
)
def _resolve_codec_options(
options: VideoCodecOptions,
spec: VideoConstraintSpec,
@@ -971,15 +1213,15 @@ def _parse_option(name: str, value: str, options: list[str]) -> str:
def _parse_audio(value: str) -> str:
lowered = value.strip().lower()
if lowered in {"aac", "a"}:
if lowered in {"aac", "a", "reencode", "re-encode"}:
return "aac"
if lowered in {"copy", "c"}:
if lowered in {"copy", "c", "streamcopy", "stream-copy"}:
return "copy"
if lowered in {"none", "no", "n"}:
if lowered in {"none", "no", "n", "off", "0"}:
return "none"
if lowered in {"opus", "o"}:
if lowered in {"opus", "o", "libopus"}:
return "opus"
if lowered in {"flac", "f"}:
if lowered in {"flac", "f", "lossless"}:
return "flac"
raise ValueError("Audio must be aac, opus, flac, copy, or none.")
@@ -988,11 +1230,6 @@ def _yes_no_suffix(default: bool) -> str:
return "Y/n" if default else "y/N"
def _forget(answers: dict[str, object], keys: set[str]) -> None:
for key in keys:
answers.pop(key, None)
def _setting_label(key: str) -> str:
return {
"timestamp_names": "timestamp names",
@@ -1013,7 +1250,7 @@ def _setting_label(key: str) -> str:
def _setting_value(
key: str,
value: object,
answers: dict[str, object],
answers: EncodingAnswers,
specs: list[EncodingOutputSpec],
) -> str:
if key == "timestamp_names":
@@ -1049,7 +1286,7 @@ def _setting_value(
def _filename_preview(
answers: dict[str, object],
answers: EncodingAnswers,
specs: list[EncodingOutputSpec],
*,
limit: int,
@@ -1152,14 +1389,9 @@ def prepare_audio_source(
def _format_audio_options(options: AudioEncodeOptions) -> str:
if options.mode == "none":
return "none"
if options.mode == "aac":
if options.mode in {"aac", "opus"}:
pitch = "preserve pitch" if options.preserve_pitch else "shift pitch with speed"
return f"AAC {options.bitrate}, {pitch}"
if options.mode == "opus":
pitch = "preserve pitch" if options.preserve_pitch else "shift pitch with speed"
return f"Opus {options.bitrate}, {pitch}"
if options.mode == "flac":
return "FLAC"
if options.mode == "copy":
return "copy with best-effort beginning/end trim"
return options.mode
return f"{options.mode.upper()} {options.bitrate}, {pitch}"
return {"flac": "FLAC", "copy": "copy with best-effort beginning/end trim"}.get(
options.mode, options.mode
)
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from dataclasses import dataclass
_MATRIX = {1: "bt709", 5: "smpte170m", 6: "smpte170m", 9: "bt2020nc", 10: "bt2020nc"}
_PRIMARIES = {1: "bt709", 9: "bt2020", 12: "smpte432"}
_TRANSFER = {1: "bt709", 16: "smpte2084", 18: "arib-std-b67"}
_RANGE = {0: "tv", 1: "pc"}
@dataclass(frozen=True)
class ColorMetadata:
matrix: int | None
primaries: int | None
transfer: int | None
color_range: int | None
@property
def colorspace(self) -> str | None:
return _MATRIX.get(self.matrix)
def ffmpeg_args(self) -> list[str]:
values = [
("-colorspace", self.colorspace),
("-color_primaries", _PRIMARIES.get(self.primaries)),
("-color_trc", _TRANSFER.get(self.transfer)),
("-color_range", _RANGE.get(self.color_range)),
]
return [part for option, value in values if value is not None for part in (option, value)]
def x264_args(self) -> list[str]:
values = [
("--colormatrix", self.colorspace),
("--colorprim", _PRIMARIES.get(self.primaries)),
("--transfer", _TRANSFER.get(self.transfer)),
("--range", _RANGE.get(self.color_range)),
]
return [part for option, value in values if value is not None for part in (option, value)]
def x265_params(self) -> str:
values = [
("colorprim", _PRIMARIES.get(self.primaries)),
("transfer", _TRANSFER.get(self.transfer)),
("colormatrix", self.colorspace),
("range", "full" if self.color_range == 1 else "limited" if self.color_range == 0 else None),
]
return ":".join(f"{name}={value}" for name, value in values if value is not None)
def mkvmerge_args(self) -> list[str]:
values = [
("--color-matrix-coefficients", self.matrix),
("--color-primaries", self.primaries),
("--color-transfer-characteristics", self.transfer),
("--color-range", {0: 1, 1: 2}.get(self.color_range)),
]
return [part for option, value in values if value is not None for part in (option, f"0:{value}")]
def hevc_bitstream_filter(self) -> str | None:
values = [
("colour_primaries", self.primaries),
("transfer_characteristics", self.transfer),
("matrix_coefficients", self.matrix),
("video_full_range_flag", self.color_range),
]
params = ":".join(f"{name}={value}" for name, value in values if value is not None)
return f"hevc_metadata={params}" if params else None
def expected_probe_values(self) -> dict[str, str | None]:
return {
"matrix": {1: "Rec709", 5: "Rec601", 6: "Rec601", 9: "Rec2020", 10: "Rec2020"}.get(self.matrix),
"primaries": _PRIMARIES.get(self.primaries),
"transfer": _TRANSFER.get(self.transfer),
"range": _RANGE.get(self.color_range),
}
+141 -106
View File
@@ -10,7 +10,9 @@ from pathlib import Path
from threading import Thread
from typing import Callable
from tools.avisynth_paths import avs_path
from tools.console import PipelineProgressView, ProgressView
from tools.video_color import ColorMetadata
from tools.video_formatting import chroma_family
@@ -46,6 +48,31 @@ class AudioEncodeOptions:
preserve_pitch: bool = True
@dataclass(frozen=True)
class VideoEncodeRequest:
y4m_command: list[str]
ffmpeg: Path
script: Path
audio_source: Path
output: Path
options: VideoCodecOptions
audio_options: AudioEncodeOptions
audio_start: float
audio_duration: float | None
audio_segments: list[tuple[float, float]] | None
audio_tempo: float
audio_sample_rate: int | None
source_has_audio: bool
allow_audio_copy: bool
pixel_format: str
colorspace: str | None
color_metadata: ColorMetadata | None
static_hdr: tuple[str | None, int | None, int | None, bool] | None
dynamic_hdr10plus: Path | None
frame_count: int | None
progress_label: str
def default_audio_bitrate(mode: str) -> str:
return {"aac": "192k", "opus": "128k"}.get(mode, "")
@@ -136,14 +163,24 @@ def supported_pixel_formats(ffmpeg: Path, codec: str, family: str) -> list[tuple
return [(8, f"yuv{family}p")] if family == "420" else []
def ffmpeg_colorspace_from_matrix(matrix: int | None) -> str | None:
if matrix == 1:
return "bt709"
if matrix in {5, 6}:
return "smpte170m"
if matrix in {9, 10}:
return "bt2020nc"
return None
def add_x265_color_metadata(command: list[str], color_metadata: ColorMetadata) -> None:
value = color_metadata.x265_params()
if value:
add_codec_params(command, "-x265-params", value)
def x264_static_hdr_options(
static_hdr: tuple[str | None, int | None, int | None, bool] | None,
) -> list[str]:
if static_hdr is None:
return []
mastering_display, max_content, max_average, _ = static_hdr
options: list[str] = []
if mastering_display:
options.extend(["--mastering-display", mastering_display])
if max_content is not None:
options.extend(["--cll", f"{max_content},{max_average or 0}"])
return options
def render_audio_wav(
@@ -197,6 +234,8 @@ def remux_video(
command.extend(["-c:a", "copy"])
else:
add_audio_encoder_options(command, audio_options)
if output.suffix.lower() == ".mp4":
command.extend(["-movflags", "+write_colr"])
command.append(str(output))
process = subprocess.Popen(
command,
@@ -222,60 +261,20 @@ def remux_video(
)
def encode_video_only_y4m(
*,
y4m_command: list[str],
ffmpeg: Path,
script: Path,
audio_source: Path | None,
output: Path,
options: VideoCodecOptions | None = None,
audio_options: AudioEncodeOptions | None = None,
audio_start: float = 0.0,
audio_duration: float | None = None,
audio_segments: list[tuple[float, float]] | None = None,
audio_tempo: float = 1.0,
audio_sample_rate: int | None = None,
source_has_audio: bool = False,
allow_audio_copy: bool = False,
pixel_format: str = "yuv420p",
colorspace: str | None = None,
frame_count: int | None = None,
progress_label: str | None = None,
) -> VideoEncodeResult:
if options is None:
options = VideoCodecOptions(codec="libx264", crf=16, preset="slow")
if audio_options is None:
audio_options = AudioEncodeOptions(mode="none")
output.parent.mkdir(parents=True, exist_ok=True)
command = ffmpeg_y4m_command(
ffmpeg=ffmpeg,
audio_source=audio_source,
output=output,
options=options,
audio_options=audio_options,
audio_start=audio_start,
audio_duration=audio_duration,
audio_segments=audio_segments,
audio_tempo=audio_tempo,
audio_sample_rate=audio_sample_rate,
source_has_audio=source_has_audio,
allow_audio_copy=allow_audio_copy,
pixel_format=pixel_format,
colorspace=colorspace,
)
def encode_video_only_y4m(request: VideoEncodeRequest) -> VideoEncodeResult:
request.output.parent.mkdir(parents=True, exist_ok=True)
command = ffmpeg_y4m_command(request)
run = run_y4m_encoder(
y4m_command=y4m_command,
script=script,
y4m_command=request.y4m_command,
script=request.script,
encoder_command=command,
frame_count=frame_count,
progress_label=progress_label or output.name,
frame_count=request.frame_count,
progress_label=request.progress_label,
read_progress=read_ffmpeg_progress,
)
if run.encoder_returncode != 0:
raise RuntimeError(
f"FFmpeg failed for {script} with exit {run.encoder_returncode}:\n"
f"FFmpeg failed for {request.script} with exit {run.encoder_returncode}:\n"
+ run.encoder_log
+ (
"\nAvisynth runner also exited with "
@@ -287,71 +286,61 @@ def encode_video_only_y4m(
)
if run.renderer_returncode != 0:
raise RuntimeError(
f"Avisynth runner failed for {script} with exit {run.renderer_returncode}:\n"
f"Avisynth runner failed for {request.script} with exit {run.renderer_returncode}:\n"
+ run.renderer_log
)
return VideoEncodeResult(
script=script,
output=output,
script=request.script,
output=request.output,
renderer_returncode=run.renderer_returncode,
ffmpeg_returncode=run.encoder_returncode,
encoder_log=run.encoder_log,
)
def ffmpeg_y4m_command(
*,
ffmpeg: Path,
audio_source: Path | None,
output: Path,
options: VideoCodecOptions,
audio_options: AudioEncodeOptions,
audio_start: float,
audio_duration: float | None,
audio_segments: list[tuple[float, float]] | None,
audio_tempo: float,
audio_sample_rate: int | None,
source_has_audio: bool,
allow_audio_copy: bool,
pixel_format: str,
colorspace: str | None,
) -> list[str]:
def ffmpeg_y4m_command(request: VideoEncodeRequest) -> list[str]:
command = [
str(ffmpeg), "-y", "-hide_banner", "-loglevel", "info", "-nostats",
str(request.ffmpeg), "-y", "-hide_banner", "-loglevel", "info", "-nostats",
"-progress", "pipe:2", "-f", "yuv4mpegpipe", "-i", "pipe:0",
]
if audio_options.mode != "none" and source_has_audio:
if audio_source is None:
raise RuntimeError("An audio source is required when audio encoding is enabled")
if request.audio_options.mode != "none" and request.source_has_audio:
add_audio_input_options(
command,
audio_options=audio_options,
audio_start=audio_start,
audio_duration=audio_duration,
allow_audio_copy=allow_audio_copy,
audio_options=request.audio_options,
audio_start=request.audio_start,
audio_duration=request.audio_duration,
allow_audio_copy=request.allow_audio_copy,
)
command.extend(["-i", str(audio_source)])
command.extend(["-i", str(request.audio_source)])
command.extend([
"-map", "0:v:0", "-c:v", options.codec, "-preset", options.preset,
"-crf", str(options.crf), "-pix_fmt", pixel_format,
"-map", "0:v:0", "-c:v", request.options.codec, "-preset", request.options.preset,
"-crf", str(request.options.crf), "-pix_fmt", request.pixel_format,
])
add_video_profile_level(command, options)
add_video_threads(command, options)
if colorspace is not None:
command.extend(["-colorspace", colorspace])
add_video_profile_level(command, request.options)
add_video_threads(command, request.options)
if request.options.codec == "libx265" and request.color_metadata:
add_x265_color_metadata(command, request.color_metadata)
add_static_hdr_metadata(command, request.options, request.static_hdr)
add_dynamic_hdr10plus_metadata(command, request.options, request.dynamic_hdr10plus)
if request.color_metadata is not None:
command.extend(request.color_metadata.ffmpeg_args())
elif request.colorspace is not None:
command.extend(["-colorspace", request.colorspace])
add_audio_options(
command,
audio_options=audio_options,
audio_start=audio_start,
audio_duration=audio_duration,
audio_segments=audio_segments,
audio_tempo=audio_tempo,
audio_sample_rate=audio_sample_rate,
source_has_audio=source_has_audio,
allow_audio_copy=allow_audio_copy,
audio_options=request.audio_options,
audio_start=request.audio_start,
audio_duration=request.audio_duration,
audio_segments=request.audio_segments,
audio_tempo=request.audio_tempo,
audio_sample_rate=request.audio_sample_rate,
source_has_audio=request.source_has_audio,
allow_audio_copy=request.allow_audio_copy,
)
command.append(str(output))
if request.output.suffix.lower() == ".mp4" and request.color_metadata and request.color_metadata.ffmpeg_args():
command.extend(["-movflags", "+write_colr"])
command.append(str(request.output))
return command
@@ -404,7 +393,7 @@ def video_only_script(script: Path) -> Iterator[Path]:
wrapper = script.with_name(f".{script.name}.video-only.avs")
wrapper.write_text(
'mbt_video_only = true\n'
f'Import("{_avs_path(script.name)}")\n'
f'Import("{avs_path(script.name)}")\n'
"last\n",
encoding="utf-8",
)
@@ -414,10 +403,6 @@ def video_only_script(script: Path) -> Iterator[Path]:
wrapper.unlink(missing_ok=True)
def _avs_path(path: str) -> str:
return path.replace("\\", "/").replace('"', '\\"')
def add_audio_input_options(
command: list[str],
*,
@@ -490,11 +475,61 @@ def add_video_threads(command: list[str], options: VideoCodecOptions) -> None:
if options.threads is None:
return
if options.codec == "libx265":
command.extend(["-x265-params", f"pools={options.threads}"])
add_codec_params(command, "-x265-params", f"pools={options.threads}")
else:
command.extend(["-threads", str(options.threads)])
def add_static_hdr_metadata(
command: list[str],
options: VideoCodecOptions,
static_hdr: tuple[str | None, int | None, int | None, bool] | None,
) -> None:
if static_hdr is None:
return
mastering_display, max_content, max_average, is_pq = static_hdr
params: list[str] = ["hdr10=1"] if options.codec == "libx265" and is_pq else []
if mastering_display:
params.append(f"master-display={mastering_display}")
if max_content is not None:
params.append(f"max-cll={max_content},{max_average or 0}")
if not params:
return
option = "-x265-params" if options.codec == "libx265" else "-x264-params"
add_codec_params(command, option, ":".join(params))
def add_dynamic_hdr10plus_metadata(
command: list[str],
options: VideoCodecOptions,
metadata_json: Path | None,
) -> None:
if metadata_json is None:
return
if options.codec != "libx265":
raise RuntimeError("HDR10+ re-encoding requires x265")
# FFmpeg passes x265 options as a colon-separated string. Escaping keeps
# the Windows drive separator inside the JSON path value.
json_path = x265_parameter_path(metadata_json.resolve())
add_codec_params(
command,
"-x265-params",
f"dhdr10-info={json_path}:dhdr10-opt=1",
)
def x265_parameter_path(path: Path) -> str:
return str(path).replace("\\", "/").replace(":", r"\:")
def add_codec_params(command: list[str], option: str, value: str) -> None:
if option in command:
index = command.index(option) + 1
command[index] = f"{command[index]}:{value}"
else:
command.extend([option, value])
def print_encoder_statistics(encoder_log: str) -> None:
statistics = encoder_statistics_lines(encoder_log)
if not statistics:
+23 -5
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from tools.avisynth_render import AvisynthClipInfo
from tools.video_encode_output import AudioEncodeOptions
from tools.video_formatting import rate_value
from tools.video_probe import VideoProbe
from tools.video_timeline import OutputTimeline
@@ -24,10 +25,6 @@ def preserves_entire_source_timeline(timeline: OutputTimeline) -> bool:
)
def missing_source_frames_between_kept(timeline: OutputTimeline) -> int:
return missing_source_frames([frame.source_frame for frame in timeline.frames])
def missing_source_frames(source_frames: list[int]) -> int:
missing = 0
for previous, current in zip(source_frames, source_frames[1:]):
@@ -106,14 +103,35 @@ def encoded_duration_seconds(
def has_avisynth_speed_changes(
timelines: dict[Path, OutputTimeline],
clip_infos: dict[Path, AvisynthClipInfo],
probes_by_path: dict[Path, VideoProbe],
) -> bool:
for path, timeline in timelines.items():
clip_info = clip_infos.get(path)
if clip_info is not None and avisynth_duration_differs(clip_info, timeline):
if clip_info is None or not avisynth_duration_differs(clip_info, timeline):
continue
probe = probes_by_path.get(path)
if probe is not None and _matches_source_rate(clip_info, probe):
continue
if clip_info.duration_seconds is not None:
return True
return False
def _matches_source_rate(clip_info: AvisynthClipInfo, probe: VideoProbe) -> bool:
if clip_info.fps_num <= 0 or clip_info.fps_den <= 0:
return False
clip_rate = clip_info.fps_num / clip_info.fps_den
source_rates = [
rate_value(probe.avg_frame_rate),
rate_value(probe.r_frame_rate),
]
return any(
rate is not None and abs(clip_rate - rate) / rate <= 0.005
for rate in source_rates
if rate is not None and rate > 0
)
def audio_tempo_factor(timeline: OutputTimeline, output_duration: float) -> float:
if output_duration <= 0:
raise RuntimeError("Encoded output duration must be positive")
+1 -342
View File
@@ -2,8 +2,6 @@ from __future__ import annotations
import os
import sys
from dataclasses import dataclass
from datetime import timezone
from pathlib import Path
from tools.avisynth_runner import (
@@ -11,20 +9,7 @@ from tools.avisynth_runner import (
runner_set_from_env_or_bundled,
validate_runner_path,
)
from tools.console import prompt_input, prompt_yes_no
from tools.timezones import local_timezone, parse_timezone_offset, timezone_to_string
from tools.video_encode_output import (
ENCODER_PRESETS,
AudioEncodeOptions,
VideoCodecOptions,
default_audio_bitrate,
)
@dataclass(frozen=True)
class OutputNamingOptions:
use_timestamps: bool
timezone_value: timezone
from tools.console import prompt_input
def ask_ffms2_plugin_path() -> Path | None:
@@ -79,329 +64,3 @@ def ask_avisynth_runners() -> AvisynthRunnerSet | None:
return None
runner = validate_runner_path(answer)
return AvisynthRunnerSet(default=runner, runner64=runner)
def require_avisynth_runners_for_encode() -> AvisynthRunnerSet:
runners = runner_set_from_env_or_bundled()
if runners is not None:
return runners
if not sys.stdin.isatty():
raise RuntimeError(
"Build tools/avisynth_runner/mbt_avs_runner on Linux, provide "
"tools/avisynth_runner/mbt_avs_runner.exe on Windows, or set "
"MBT_AVS_RUNNER before enabling video encode. For 32-bit Windows "
"AviSynth, set MBT_AVS_RUNNER32."
)
print(
"\nEncoding needs media-batch-tools' AviSynth runner executable "
"(mbt_avs_runner or mbt_avs_runner.exe)."
)
answer = prompt_input("AviSynth runner executable path: ").strip()
if not answer:
raise RuntimeError("Avisynth runner path is required for encoding.")
runner = validate_runner_path(answer)
return AvisynthRunnerSet(default=runner, runner64=runner)
def ask_encode_video_only() -> bool:
env_value = os.environ.get("MBT_ENCODE_VIDEO")
if env_value is not None:
return env_value.strip().lower() in {"1", "true", "yes", "y", "on"}
if not sys.stdin.isatty():
return False
return prompt_yes_no("Encode validated outputs now?", default=False)
def ask_output_naming_options(
*,
default_timezone: timezone | None = None,
) -> OutputNamingOptions:
env_value = os.environ.get("MBT_VIDEO_TIMESTAMP_NAMES")
if env_value is not None:
use_timestamps = env_value.strip().lower() in {"1", "true", "yes", "y", "on"}
elif sys.stdin.isatty():
use_timestamps = prompt_yes_no(
"Name encoded files from their edited beginning timestamp?",
default=True,
)
else:
use_timestamps = False
default_timezone = default_timezone or local_timezone()
env_timezone = os.environ.get("MBT_VIDEO_TIMEZONE", "").strip()
if not use_timestamps:
timezone_value = default_timezone
elif env_timezone:
try:
timezone_value = parse_timezone_offset(env_timezone)
except ValueError as exc:
raise RuntimeError(f"Invalid MBT_VIDEO_TIMEZONE: {exc}") from exc
elif sys.stdin.isatty():
while True:
answer = prompt_input(
"Output filename timezone "
f"[{timezone_to_string(default_timezone)}]: "
).strip()
if not answer:
timezone_value = default_timezone
break
try:
timezone_value = parse_timezone_offset(answer)
break
except ValueError as exc:
print(exc)
else:
timezone_value = default_timezone
return OutputNamingOptions(
use_timestamps=use_timestamps,
timezone_value=timezone_value,
)
def ask_video_codec_options() -> VideoCodecOptions:
codec = _codec_from_env()
extension = _container_extension_from_env()
crf = _int_from_env("MBT_VIDEO_CRF")
preset = os.environ.get("MBT_VIDEO_PRESET", "").strip()
profile = _optional_env("MBT_VIDEO_PROFILE")
level = _optional_env("MBT_VIDEO_LEVEL")
threads = _threads_from_env()
if sys.stdin.isatty():
if extension is None:
extension = _prompt_container_extension()
if codec is None:
codec = _prompt_codec()
if codec != "copy" and crf is None:
default_crf = 16 if codec == "libx264" else 21
crf = _prompt_int(f"CRF [{default_crf}]: ", default=default_crf)
if codec != "copy" and not preset:
preset = _prompt_preset(default="slow")
if codec != "copy" and "MBT_VIDEO_THREADS" not in os.environ:
threads = _prompt_threads(default=None)
else:
extension = extension or ".mp4"
codec = codec or "libx265"
crf = crf if crf is not None else (16 if codec == "libx264" else 21)
preset = preset or "slow"
return VideoCodecOptions(
codec=codec,
crf=crf if crf is not None else 21,
preset=preset or "slow",
extension=extension,
profile=profile,
level=level,
threads=threads,
)
def _prompt_preset(*, default: str) -> str:
options = "/".join(ENCODER_PRESETS)
while True:
value = prompt_input(f"Encoder preset [{default}] ({options}): ").strip().lower()
if not value:
return default
if value in ENCODER_PRESETS:
return value
print(f"Preset must be one of: {options}.")
def _threads_from_env() -> int | None:
raw = os.environ.get("MBT_VIDEO_THREADS", "").strip().lower()
if not raw or raw == "auto":
return None
try:
value = int(raw)
except ValueError as exc:
raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer") from exc
if value < 1:
raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer")
return value
def _prompt_threads(*, default: int | None) -> int | None:
default_text = str(default) if default is not None else "auto"
while True:
value = prompt_input(f"Encoder threads [{default_text}] (auto or positive integer): ").strip().lower()
if not value or value == "auto":
return default
try:
threads = int(value)
except ValueError:
print("Threads must be auto or a positive integer.")
continue
if threads >= 1:
return threads
print("Threads must be auto or a positive integer.")
def ask_audio_options(
*,
has_speed_changes: bool,
container_extension: str,
) -> AudioEncodeOptions:
env_mode = os.environ.get("MBT_VIDEO_AUDIO", "").strip().lower()
env_bitrate = os.environ.get("MBT_VIDEO_AUDIO_BITRATE", "").strip()
preserve_pitch = ask_audio_pitch_option(has_speed_changes=has_speed_changes)
if env_mode:
mode = _normalize_audio_mode(env_mode)
return AudioEncodeOptions(
mode=mode,
bitrate=env_bitrate or default_audio_bitrate(mode),
preserve_pitch=preserve_pitch,
)
if not sys.stdin.isatty():
return AudioEncodeOptions(mode="none", preserve_pitch=preserve_pitch)
while True:
prompt = "Audio: Enter=" + (
"Opus" if container_extension == ".mkv" else "AAC"
) + ", a=AAC, o=Opus, f=FLAC, c=copy/trim audio, n=no audio: "
answer = prompt_input(prompt).strip().lower()
if not answer:
mode = "opus" if container_extension == ".mkv" else "aac"
break
if answer in {"a", "aac"}:
mode = "aac"
break
if answer in {"o", "opus"}:
mode = "opus"
break
if answer in {"f", "flac"}:
mode = "flac"
break
if answer in {"c", "copy"}:
mode = "copy"
break
if answer in {"n", "no", "none"}:
mode = "none"
break
print("Please choose Enter, c, or n.")
bitrate = default_audio_bitrate(mode)
if mode in {"aac", "opus"}:
label = "AAC" if mode == "aac" else "Opus"
bitrate = prompt_input(f"{label} audio bitrate [{bitrate}]: ").strip() or bitrate
return AudioEncodeOptions(mode=mode, bitrate=bitrate, preserve_pitch=preserve_pitch)
def ask_audio_pitch_option(*, has_speed_changes: bool) -> bool:
env_value = os.environ.get("MBT_VIDEO_AUDIO_PITCH", "").strip().lower()
if env_value:
if env_value in {"preserve", "preserved", "keep", "same"}:
return True
if env_value in {"shift", "change", "changed", "speed"}:
return False
raise RuntimeError("MBT_VIDEO_AUDIO_PITCH must be preserve or shift")
if not has_speed_changes or not sys.stdin.isatty():
return True
while True:
answer = prompt_input(
"Speed-changed audio pitch: Enter=preserve pitch, s=shift with speed: "
).strip().lower()
if not answer or answer in {"p", "preserve", "keep", "same"}:
return True
if answer in {"s", "shift", "change", "speed"}:
return False
print("Please choose Enter or s.")
def _normalize_audio_mode(value: str) -> str:
if value in {"aac", "reencode", "re-encode"}:
return "aac"
if value in {"opus", "libopus"}:
return "opus"
if value in {"flac", "lossless"}:
return "flac"
if value in {"copy", "streamcopy", "stream-copy"}:
return "copy"
if value in {"none", "no", "off", "0"}:
return "none"
raise RuntimeError("MBT_VIDEO_AUDIO must be aac, opus, flac, copy, or none")
def _container_extension_from_env() -> str | None:
raw = os.environ.get("MBT_VIDEO_CONTAINER", "").strip().lower()
if not raw:
return None
return _normalize_container_extension(raw)
def _prompt_container_extension() -> str:
while True:
answer = prompt_input("Output container: Enter=MP4, m=MKV: ").strip().lower()
if not answer:
return ".mp4"
try:
return _normalize_container_extension(answer)
except ValueError as exc:
print(exc)
def _normalize_container_extension(value: str) -> str:
if value in {"mp4", ".mp4"}:
return ".mp4"
if value in {"m", "mkv", ".mkv", "matroska"}:
return ".mkv"
raise ValueError("container must be mp4 or mkv")
def _codec_from_env() -> str | None:
raw = os.environ.get("MBT_VIDEO_CODEC", "").strip().lower()
if not raw:
return None
return _normalize_codec(raw)
def _prompt_codec() -> str:
while True:
answer = prompt_input("Video codec: x264, Enter=x265, c=copy: ").strip().lower()
if not answer:
return "libx265"
try:
return _normalize_codec(answer)
except ValueError as exc:
print(exc)
def _normalize_codec(value: str) -> str:
if value in {"x264", "h264", "libx264"}:
return "libx264"
if value in {"x265", "h265", "hevc", "libx265"}:
return "libx265"
if value in {"c", "copy", "streamcopy", "stream-copy"}:
return "copy"
raise ValueError("codec must be x264, x265, or copy")
def _int_from_env(name: str) -> int | None:
raw = os.environ.get(name, "").strip()
if not raw:
return None
try:
return int(raw)
except ValueError as exc:
raise RuntimeError(f"{name} must be an integer") from exc
def _optional_env(name: str) -> str | None:
value = os.environ.get(name, "").strip()
return value or None
def _prompt_int(prompt: str, *, default: int) -> int:
while True:
answer = prompt_input(prompt).strip()
if not answer:
return default
try:
return int(answer)
except ValueError:
print("Please enter an integer.")
+103 -13
View File
@@ -8,11 +8,12 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from tools.console import prompt_input, prompt_yes_no
from tools.video_color import ColorMetadata
from tools.exiftool import run_exiftool_command
from tools.filesystem import unique_path
from tools.filenames import format_rounded_filename_stem, round_datetime_to_second
from tools.metadata_copy import copy_meaningful_metadata
from tools.video_inputs import VideoInput
from tools.video_options import OutputNamingOptions
from tools.video_probe import VideoProbe, probe_video
from tools.video_timeline import OutputTimeline
from tools.video_formatting import join_parts, video_parts
@@ -24,6 +25,12 @@ class EncodedItem:
output: Path
@dataclass(frozen=True)
class OutputNamingOptions:
use_timestamps: bool
timezone_value: timezone
def planned_output_path(
*,
script: Path,
@@ -65,12 +72,11 @@ def output_begin_timestamp(
if probe.duration_seconds is None:
raise RuntimeError(f"No source duration found for {probe.path}")
source_end = probe.metadata_end
if source_end.tzinfo is None:
source_end = source_end.replace(tzinfo=timezone.utc)
source_begin = source_end - timedelta(seconds=probe.duration_seconds)
source_begin = _stretched_source_begin_timestamp(probe, timeline)
first_frame_start = timeline.frames[0].start
return (source_begin + timedelta(seconds=first_frame_start)).astimezone(
return (source_begin + timedelta(
seconds=first_frame_start * timeline.absolute_timestretch
)).astimezone(
output_timezone
)
@@ -78,10 +84,47 @@ def output_begin_timestamp(
def output_end_timestamp(
probe: VideoProbe,
timeline: OutputTimeline,
output_duration: float,
) -> datetime:
begin = output_begin_timestamp(probe, timeline, timezone.utc)
return begin + timedelta(seconds=output_duration)
source_begin = _stretched_source_begin_timestamp(probe, timeline)
return source_begin + timedelta(
seconds=_last_consumed_source_end(probe, timeline)
* timeline.absolute_timestretch
)
def _stretched_source_begin_timestamp(
probe: VideoProbe,
timeline: OutputTimeline,
) -> datetime:
if probe.metadata_end is None:
raise RuntimeError(f"No metadata end timestamp found for {probe.path}")
if probe.duration_seconds is None:
raise RuntimeError(f"No source duration found for {probe.path}")
source_end = probe.metadata_end
if source_end.tzinfo is None:
source_end = source_end.replace(tzinfo=timezone.utc)
return source_end - timedelta(
seconds=probe.duration_seconds * timeline.absolute_timestretch
)
def _last_consumed_source_end(probe: VideoProbe, timeline: OutputTimeline) -> float:
source_frames = timeline.consumed_source_frames or [
frame.source_frame for frame in timeline.frames
]
if not source_frames:
raise RuntimeError(f"No source frames were consumed for {probe.path}")
source_frame = max(source_frames)
if source_frame >= len(probe.frame_timestamps):
raise RuntimeError(
f"source frame {source_frame} is outside probed frame table for {probe.path}"
)
start = probe.frame_timestamps[source_frame]
if source_frame + 1 < len(probe.frame_timestamps):
return probe.frame_timestamps[source_frame + 1]
if probe.duration_seconds is None:
raise RuntimeError(f"No source duration found for {probe.path}")
return max(start, probe.duration_seconds)
def write_video_end_timestamp(
@@ -89,13 +132,15 @@ def write_video_end_timestamp(
output: Path,
probe: VideoProbe,
timeline: OutputTimeline,
output_duration: float,
) -> None:
if output.suffix.lower() not in {".mp4", ".mov"}:
print(f" skipped container timestamp write for {output.suffix}")
return
end_time = round_datetime_to_second(
output_end_timestamp(probe, timeline, output_duration).astimezone(timezone.utc)
output_end_timestamp(
probe,
timeline,
).astimezone(timezone.utc)
)
timestamp = end_time.strftime("%Y:%m:%d %H:%M:%S")
args = [
@@ -115,11 +160,26 @@ def write_video_end_timestamp(
capture_output=True,
text=True,
)
print(f" wrote video end timestamp: {timestamp} UTC")
speed_note = (
""
if timeline.absolute_timestretch == 1
else f" ({timeline.absolute_timestretch:g}x real-time)"
)
print(f" wrote video end timestamp: {timestamp} UTC{speed_note}")
def copy_video_metadata(exiftool: str, source: Path, output: Path) -> None:
copy_meaningful_metadata(exiftool, source, output)
copy_meaningful_metadata(
exiftool,
source,
output,
extra_excluded_tags=[
"--QuickTime:ColorRepresentation",
"--QuickTime:ColorPrimaries",
"--QuickTime:TransferCharacteristics",
"--QuickTime:MatrixCoefficients",
],
)
if output.suffix.lower() in {".mp4", ".mov"}:
print(" copied source metadata")
else:
@@ -189,6 +249,36 @@ def validate_encoded_video(
return probe
def validate_output_color_metadata(
output: Path,
probe: VideoProbe,
timeline: OutputTimeline,
*,
expected_hdr10plus: bool = False,
) -> None:
expected = ColorMetadata(
timeline.matrix, timeline.primaries, timeline.transfer, timeline.color_range
).expected_probe_values()
actual = {
"matrix": probe.color_matrix,
"primaries": probe.color_primaries,
"transfer": probe.color_transfer,
"range": probe.color_range,
}
mismatches = [
f"{name} {actual[name] or 'missing'} (expected {value})"
for name, value in expected.items()
if value is not None and actual[name] != value
]
if mismatches:
raise RuntimeError(
f"Encoded output lost color metadata for {output}: "
+ "; ".join(mismatches)
)
if expected_hdr10plus and not probe.hdr10plus:
raise RuntimeError(f"Encoded output lost HDR10+ metadata for {output}")
def validate_encoded_timestamps(
output: Path,
actual: list[float],
+153 -33
View File
@@ -116,6 +116,15 @@ class VideoProbe:
gps_latitude: float | None
gps_longitude: float | None
warnings: list[str]
color_range: str | None = None
color_transfer: str | None = None
color_primaries: str | None = None
mastering_display: str | None = None
max_content_light: int | None = None
max_frame_average_light: int | None = None
hdr_peak_luminance: float | None = None
hdr_dynamic: bool = False
hdr10plus: bool = False
def require_tool(name: str) -> str:
@@ -169,6 +178,19 @@ def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe:
if timing.timing_kind == "unknown":
warnings.append("Not enough frame timestamps to classify CFR/VFR timing.")
color_space = _as_str(stream.get("color_space"))
color_range = _as_str(stream.get("color_range"))
color_transfer = _as_str(stream.get("color_transfer"))
color_primaries = _as_str(stream.get("color_primaries"))
side_data = list(stream.get("side_data_list") or [])
if is_hdr_transfer(color_transfer):
# HEVC commonly stores HDR10 static and HDR10+ dynamic metadata on
# decoded frames, not the stream header.
side_data.extend(_probe_first_frame_side_data(ffprobe, path))
mastering_display, max_content_light, max_frame_average_light, hdr_peak_luminance, hdr_dynamic, hdr10plus = (
_hdr_side_data(side_data)
)
return VideoProbe(
path=path,
codec=_as_str(stream.get("codec_name")),
@@ -177,8 +199,8 @@ def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe:
pixel_format=_as_str(stream.get("pix_fmt")),
bit_depth=_video_bit_depth(stream),
video_bit_rate=video_bit_rate,
color_space=_as_str(stream.get("color_space")),
color_matrix=_avisynth_matrix_from_color_space(_as_str(stream.get("color_space"))),
color_space=color_space,
color_matrix=_avisynth_matrix_from_color_space(color_space),
expected_color_matrix=_expected_color_matrix(
_to_int(stream.get("width")),
_to_int(stream.get("height")),
@@ -201,26 +223,29 @@ def probe_video(ffprobe: str, path: Path, metadata: dict) -> VideoProbe:
gps_latitude=gps_latitude,
gps_longitude=gps_longitude,
warnings=warnings,
color_range=color_range,
color_transfer=color_transfer,
color_primaries=color_primaries,
mastering_display=mastering_display,
max_content_light=max_content_light,
max_frame_average_light=max_frame_average_light,
hdr_peak_luminance=hdr_peak_luminance,
hdr_dynamic=hdr_dynamic,
hdr10plus=hdr10plus,
)
def _probe_stream(ffprobe: str, path: Path) -> tuple[dict, dict]:
command = [
data = _ffprobe_json(
ffprobe,
"-v",
"error",
path,
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name,width,height,pix_fmt,bits_per_raw_sample,bits_per_sample,bit_rate,color_space,color_range,color_transfer,color_primaries,avg_frame_rate,r_frame_rate,time_base,duration,nb_frames",
"stream=codec_name,width,height,pix_fmt,bits_per_raw_sample,bits_per_sample,bit_rate,color_space,color_range,color_transfer,color_primaries,avg_frame_rate,r_frame_rate,time_base,duration,nb_frames:stream_side_data=side_data_type,red_x,red_y,green_x,green_y,blue_x,blue_y,white_point_x,white_point_y,min_luminance,max_luminance,max_content,max_average",
"-show_entries",
"format=duration",
"-of",
"json",
str(path),
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
data = json.loads(completed.stdout or "{}")
)
streams = data.get("streams") or []
if not streams:
raise RuntimeError(f"No video stream found in {path}")
@@ -232,22 +257,16 @@ def _probe_audio_streams(
path: Path,
fallback_duration: float | None,
) -> list[AudioStreamProbe]:
command = [
data = _ffprobe_json(
ffprobe,
"-v",
"error",
path,
"-select_streams",
"a",
"-show_entries",
"stream=index,codec_name,channels,sample_rate,bit_rate,duration",
"-show_entries",
"packet=stream_index,size",
"-of",
"json",
str(path),
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
data = json.loads(completed.stdout or "{}")
)
streams = data.get("streams") or []
packet_bytes: Counter[int] = Counter()
for packet in data.get("packets") or []:
@@ -272,21 +291,30 @@ def _probe_audio_streams(
]
def _probe_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]:
command = [
def _probe_first_frame_side_data(ffprobe: str, path: Path) -> list[dict]:
data = _ffprobe_json(
ffprobe,
"-v",
"error",
path,
"-select_streams",
"v:0",
"-read_intervals",
"%+#1",
"-show_entries",
"frame_side_data=side_data_type,red_x,red_y,green_x,green_y,blue_x,blue_y,white_point_x,white_point_y,min_luminance,max_luminance,max_content,max_average",
)
frames = data.get("frames") or []
return list((frames[0] if frames else {}).get("side_data_list") or [])
def _probe_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]:
data = _ffprobe_json(
ffprobe,
path,
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time,size",
"-of",
"json",
str(path),
]
completed = subprocess.run(command, check=True, capture_output=True, text=True)
data = json.loads(completed.stdout or "{}")
)
timestamps: list[float] = []
total_bytes = 0
for packet in data.get("packets") or []:
@@ -297,6 +325,16 @@ def _probe_video_packets(ffprobe: str, path: Path) -> tuple[list[float], int]:
return sorted(timestamps), total_bytes
def _ffprobe_json(ffprobe: str, path: Path, *args: str) -> dict:
completed = subprocess.run(
[ffprobe, "-v", "error", *args, "-of", "json", str(path)],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout or "{}")
def _average_bit_rate(total_bytes: int, duration: float | None) -> int | None:
if total_bytes <= 0 or duration is None or duration <= 0:
return None
@@ -465,7 +503,9 @@ def _to_float(value: object) -> float | None:
if value is None or value == "N/A":
return None
try:
return float(value)
text = str(value)
numerator, separator, denominator = text.partition("/")
return float(numerator) / float(denominator) if separator else float(text)
except (TypeError, ValueError):
return None
@@ -474,7 +514,8 @@ def _to_int(value: object) -> int | None:
if value is None or value == "N/A":
return None
try:
return int(value)
numeric = _to_float(value)
return int(numeric) if numeric is not None else None
except (TypeError, ValueError):
return None
@@ -507,6 +548,85 @@ def _avisynth_matrix_from_color_space(color_space: str | None) -> str | None:
return None
def avisynth_primaries_from_color_primaries(value: str | None) -> int:
normalized = (value or "").lower()
if normalized in {"bt709", "smpte170m", "bt470bg", "smpte240m"}:
return 1
if normalized in {"bt2020", "bt2020nc", "bt2020ncl"}:
return 9
if normalized in {"smpte432", "displayp3", "display-p3"}:
return 12
return 2
def avisynth_transfer_from_color_transfer(value: str | None) -> int:
normalized = (value or "").lower()
if normalized in {"bt709", "smpte170m", "bt470bg", "bt601"}:
return 1
if normalized in {"smpte2084", "pq"}:
return 16
if normalized in {"arib-std-b67", "hlg"}:
return 18
return 2
def avisynth_range_from_color_range(value: str | None) -> int:
return 1 if (value or "").lower() in {"pc", "jpeg", "full"} else 0
def is_hdr_transfer(value: str | None) -> bool:
return (value or "").lower() in {"smpte2084", "pq", "arib-std-b67", "hlg"}
def hdr_kind(value: str | None, *, dynamic: bool = False) -> str | None:
if not is_hdr_transfer(value):
return None
if dynamic:
return "HDR10+"
return "HDR10" if (value or "").lower() in {"smpte2084", "pq"} else "HLG"
def _hdr_side_data(
side_data: list[dict],
) -> tuple[str | None, int | None, int | None, float | None, bool, bool]:
mastering: str | None = None
max_content: int | None = None
max_average: int | None = None
peak_luminance: float | None = None
dynamic = False
hdr10plus = False
for entry in side_data:
kind = str(entry.get("side_data_type") or "").lower()
if "mastering display" in kind:
mastering = _format_mastering_display(entry)
peak_luminance = _to_float(entry.get("max_luminance"))
elif "content light" in kind:
max_content = _to_int(entry.get("max_content"))
max_average = _to_int(entry.get("max_average"))
elif "dynamic hdr" in kind or "hdr10+" in kind or "dovi" in kind:
dynamic = True
hdr10plus = hdr10plus or "smpte2094-40" in kind or "hdr10+" in kind
return mastering, max_content, max_average, peak_luminance, dynamic, hdr10plus
def _format_mastering_display(entry: dict) -> str | None:
keys = (
"green_x", "green_y", "blue_x", "blue_y", "red_x", "red_y",
"white_point_x", "white_point_y", "max_luminance", "min_luminance",
)
values = [_to_float(entry.get(key)) for key in keys]
if any(value is None for value in values):
return None
green_x, green_y, blue_x, blue_y, red_x, red_y, white_x, white_y, maximum, minimum = values
return (
f"G({round(green_x * 50000)},{round(green_y * 50000)})"
f"B({round(blue_x * 50000)},{round(blue_y * 50000)})"
f"R({round(red_x * 50000)},{round(red_y * 50000)})"
f"WP({round(white_x * 50000)},{round(white_y * 50000)})"
f"L({round(maximum * 10000)},{round(minimum * 10000)})"
)
def _expected_color_matrix(width: int | None, height: int | None) -> str | None:
if width is None or height is None:
return None
+22 -4
View File
@@ -20,7 +20,7 @@ from tools.video_formatting import (
)
from tools.video_inputs import VideoInput
from tools.video_outputs import output_begin_timestamp
from tools.video_probe import VideoProbe
from tools.video_probe import VideoProbe, hdr_kind
from tools.video_timeline import OutputTimeline
@@ -252,6 +252,9 @@ def print_probe_summary(inputs: list[VideoInput], probes: list[VideoProbe]) -> N
f"{format_frame_count(probe.stream_frame_count, probe.timing.frame_count)}"
)
print(f" video: {_format_probe_video_line(probe)}")
hdr_line = _format_hdr_line(probe)
if hdr_line:
print(f" HDR: {hdr_line}")
if probe.audio_streams:
for index, stream in enumerate(probe.audio_streams, start=1):
print(f" audio {index}: {_format_audio_stream(stream)}")
@@ -272,8 +275,7 @@ def print_probe_summary(inputs: list[VideoInput], probes: list[VideoProbe]) -> N
def _format_probe_video_line(probe: VideoProbe) -> str:
return join_parts(
video_parts(
parts = video_parts(
codec=probe.codec or "unknown codec",
width=probe.width,
height=probe.height,
@@ -282,7 +284,23 @@ def _format_probe_video_line(probe: VideoProbe) -> str:
matrix=probe.color_matrix or "unknown",
bit_rate=probe.video_bit_rate,
)
)
kind = hdr_kind(probe.color_transfer, dynamic=probe.hdr10plus)
if kind:
parts.append(kind)
return join_parts(parts)
def _format_hdr_line(probe: VideoProbe) -> str | None:
kind = hdr_kind(probe.color_transfer, dynamic=probe.hdr10plus)
if kind is None:
return None
parts = ["PQ" if (probe.color_transfer or "").lower() == "smpte2084" else "HLG"]
parts.append("full range" if (probe.color_range or "").lower() == "pc" else "limited range")
if probe.max_content_light is not None:
parts.append(f"MaxCLL {probe.max_content_light}")
if probe.max_frame_average_light is not None:
parts.append(f"MaxFALL {probe.max_frame_average_light}")
return join_parts(parts)
def _format_audio_stream(stream) -> str:
+42
View File
@@ -28,6 +28,10 @@ class OutputTimeline:
dropped_frame_count: int
source_frame_count: int
matrix: int | None
absolute_timestretch: float = 1.0
primaries: int | None = None
transfer: int | None = None
color_range: int | None = None
def build_output_timeline(
@@ -40,6 +44,10 @@ def build_output_timeline(
audio_segments: list[tuple[float, float]] = []
consumed_source_frames: list[int] = []
matrices: set[int] = set()
primaries_values: set[int] = set()
transfers: set[int] = set()
ranges: set[int] = set()
timestretches: set[float] = set()
dropped_count = 0
leading_trimmed = False
last_consumed_source_frame: int | None = None
@@ -79,6 +87,14 @@ def build_output_timeline(
consumed_source_frames.append(frame.source_frame)
if frame.matrix is not None:
matrices.add(frame.matrix)
if frame.primaries is not None:
primaries_values.add(frame.primaries)
if frame.transfer is not None:
transfers.add(frame.transfer)
if frame.color_range is not None:
ranges.add(frame.color_range)
if frame.absolute_timestretch is not None:
timestretches.add(frame.absolute_timestretch)
_append_audio_segment(
audio_segments,
start=probe.frame_timestamps[frame.source_frame],
@@ -102,6 +118,20 @@ def build_output_timeline(
f"Output frames have mixed _Matrix values for {probe.path}: "
+ ", ".join(str(value) for value in sorted(matrices))
)
_require_one_color_value("_Primaries", primaries_values, probe)
_require_one_color_value("_Transfer", transfers, probe)
_require_one_color_value("_ColorRange", ranges, probe)
if len(timestretches) > 1:
raise RuntimeError(
f"Output frames have mixed absolute timestretch values for {probe.path}: "
+ ", ".join(str(value) for value in sorted(timestretches))
)
absolute_timestretch = next(iter(timestretches), 1.0)
if absolute_timestretch <= 0:
raise RuntimeError(
f"Output frames have an invalid absolute timestretch for {probe.path}: "
f"{absolute_timestretch}"
)
source_count = len(probe.frame_timestamps)
beginning_trimmed = leading_trimmed or kept_frames[0].source_frame > 0
@@ -120,9 +150,21 @@ def build_output_timeline(
dropped_frame_count=dropped_count,
source_frame_count=source_count,
matrix=next(iter(matrices)) if matrices else None,
absolute_timestretch=absolute_timestretch,
primaries=next(iter(primaries_values)) if primaries_values else None,
transfer=next(iter(transfers)) if transfers else None,
color_range=next(iter(ranges)) if ranges else None,
)
def _require_one_color_value(name: str, values: set[int], probe: VideoProbe) -> None:
if len(values) > 1:
raise RuntimeError(
f"Output frames have mixed {name} values for {probe.path}: "
+ ", ".join(str(value) for value in sorted(values))
)
def _append_audio_segment(
segments: list[tuple[float, float]],
*,
+70 -122
View File
@@ -4,12 +4,14 @@ import re
import subprocess
import sys
import tempfile
from dataclasses import replace
from pathlib import Path
from tools.console import PipelineProgressView, ProgressView
from tools.video_color import ColorMetadata
from tools.video_encode_output import (
AudioEncodeOptions,
VideoCodecOptions,
VideoEncodeRequest,
VideoEncodeResult,
add_audio_input_options,
add_audio_options,
@@ -17,78 +19,46 @@ from tools.video_encode_output import (
pixel_format_bit_depth,
read_ffmpeg_progress,
run_y4m_encoder,
x264_static_hdr_options,
)
def encode_video_with_timestamps(
*,
y4m_command: list[str],
ffmpeg: Path,
request: VideoEncodeRequest,
mkvmerge: Path,
x264: Path | None,
script: Path,
frame_durations: list[float],
audio_source: Path,
output: Path,
options: VideoCodecOptions,
audio_options: AudioEncodeOptions,
audio_start: float,
audio_duration: float | None,
audio_segments: list[tuple[float, float]] | None,
audio_tempo: float,
audio_sample_rate: int | None,
source_has_audio: bool,
allow_audio_copy: bool,
pixel_format: str,
colorspace: str | None,
frame_count: int | None = None,
progress_label: str | None = None,
) -> VideoEncodeResult:
if not frame_durations:
raise RuntimeError("Cannot encode VFR output with no frame durations")
output.parent.mkdir(parents=True, exist_ok=True)
request.output.parent.mkdir(parents=True, exist_ok=True)
# Network shares may allow the final output but deny creating temporary folders.
with tempfile.TemporaryDirectory(prefix="mbt-vfr-") as temp_dir:
temp_root = Path(temp_dir)
timecodes = temp_root / "frames.timecodes.txt"
write_timecode_v2(timecodes, frame_durations)
intermediate = temp_root / (
"encoded.h264" if options.codec == "libx264" else "encoded.mkv"
"encoded.h264" if request.options.codec == "libx264" else "encoded.mkv"
)
if options.codec == "libx264":
if request.options.codec == "libx264":
if x264 is None:
raise RuntimeError("x264 CLI is required for timestamp-aware x264 encoding")
renderer_returncode, encoder_log = encode_x264_y4m(
y4m_command=y4m_command,
request=request,
x264=x264,
script=script,
timecodes=timecodes,
output=intermediate,
options=options,
pixel_format=pixel_format,
colorspace=colorspace,
frame_count=len(frame_durations),
progress_label=progress_label,
)
elif options.codec == "libx265":
elif request.options.codec == "libx265":
result = encode_video_only_y4m(
y4m_command=y4m_command,
ffmpeg=ffmpeg,
script=script,
audio_source=None,
output=intermediate,
options=options,
audio_options=AudioEncodeOptions(mode="none"),
pixel_format=pixel_format,
colorspace=colorspace,
frame_count=len(frame_durations),
progress_label=progress_label,
replace(request, output=intermediate, audio_options=AudioEncodeOptions(mode="none"))
)
renderer_returncode = result.renderer_returncode
encoder_log = result.encoder_log
else:
raise RuntimeError(f"Unsupported timestamp-aware codec: {options.codec}")
raise RuntimeError(f"Unsupported timestamp-aware codec: {request.options.codec}")
timed_video = temp_root / "timed.mkv"
apply_video_timecodes(
@@ -96,27 +66,16 @@ def encode_video_with_timestamps(
source=intermediate,
timecodes=timecodes,
output=timed_video,
color_metadata=request.color_metadata,
)
ffmpeg_returncode = mux_timed_video_with_audio(
ffmpeg=ffmpeg,
request=request,
timed_video=timed_video,
audio_source=audio_source,
output=output,
audio_options=audio_options,
audio_start=audio_start,
audio_duration=audio_duration,
audio_segments=audio_segments,
audio_tempo=audio_tempo,
audio_sample_rate=audio_sample_rate,
source_has_audio=source_has_audio,
allow_audio_copy=allow_audio_copy,
frame_count=len(frame_durations),
progress_label=progress_label,
)
return VideoEncodeResult(
script=script,
output=output,
script=request.script,
output=request.output,
renderer_returncode=renderer_returncode,
ffmpeg_returncode=ffmpeg_returncode,
encoder_log=encoder_log,
@@ -136,30 +95,22 @@ def write_timecode_v2(path: Path, frame_durations: list[float]) -> None:
def encode_x264_y4m(
*,
y4m_command: list[str],
request: VideoEncodeRequest,
x264: Path,
script: Path,
timecodes: Path,
output: Path,
options: VideoCodecOptions,
pixel_format: str,
colorspace: str | None,
frame_count: int | None,
progress_label: str | None,
) -> tuple[int, str]:
run = run_y4m_encoder(
y4m_command=y4m_command,
script=script,
y4m_command=request.y4m_command,
script=request.script,
encoder_command=x264_command(
request=request,
x264=x264,
timecodes=timecodes,
output=output,
options=options,
pixel_format=pixel_format,
colorspace=colorspace,
),
frame_count=frame_count,
progress_label=progress_label or script.name,
frame_count=request.frame_count,
progress_label=request.progress_label,
read_progress=read_x264_progress,
)
if run.encoder_returncode != 0:
@@ -167,12 +118,12 @@ def encode_x264_y4m(
if run.renderer_returncode != 0:
details += "\nAvisynth runner:\n" + run.renderer_log
raise RuntimeError(
f"x264 failed for {script} with exit {run.encoder_returncode}:\n"
f"x264 failed for {request.script} with exit {run.encoder_returncode}:\n"
+ details.strip()
)
if run.renderer_returncode != 0:
raise RuntimeError(
f"Avisynth runner failed for {script} with exit {run.renderer_returncode}:\n"
f"Avisynth runner failed for {request.script} with exit {run.renderer_returncode}:\n"
+ run.renderer_log
)
return run.renderer_returncode, run.encoder_log
@@ -180,12 +131,10 @@ def encode_x264_y4m(
def x264_command(
*,
request: VideoEncodeRequest,
x264: Path,
timecodes: Path,
output: Path,
options: VideoCodecOptions,
pixel_format: str,
colorspace: str | None,
) -> list[str]:
command = [
str(x264),
@@ -194,24 +143,27 @@ def x264_command(
"--tcfile-in",
str(timecodes),
"--crf",
str(options.crf),
str(request.options.crf),
"--preset",
options.preset,
request.options.preset,
"--output-depth",
str(pixel_format_bit_depth(pixel_format)),
str(pixel_format_bit_depth(request.pixel_format)),
"--output-csp",
_x264_output_csp(pixel_format),
_x264_output_csp(request.pixel_format),
"--verbose",
"--no-progress",
]
if options.profile:
command.extend(["--profile", options.profile])
if options.level:
command.extend(["--level", options.level])
if options.threads is not None:
command.extend(["--threads", str(options.threads)])
if colorspace:
command.extend(["--colormatrix", colorspace])
if request.options.profile:
command.extend(["--profile", request.options.profile])
if request.options.level:
command.extend(["--level", request.options.level])
if request.options.threads is not None:
command.extend(["--threads", str(request.options.threads)])
if request.color_metadata is not None:
command.extend(request.color_metadata.x264_args())
elif request.colorspace:
command.extend(["--colormatrix", request.colorspace])
command.extend(x264_static_hdr_options(request.static_hdr))
command.extend(["--output", str(output), "-"])
return command
@@ -230,6 +182,7 @@ def apply_video_timecodes(
source: Path,
timecodes: Path,
output: Path,
color_metadata: ColorMetadata | None = None,
) -> None:
completed = subprocess.run(
[
@@ -241,6 +194,7 @@ def apply_video_timecodes(
str(output),
"--timestamps",
f"0:{timecodes}",
*(color_metadata.mkvmerge_args() if color_metadata else []),
str(source),
],
capture_output=True,
@@ -256,23 +210,11 @@ def apply_video_timecodes(
def mux_timed_video_with_audio(
*,
ffmpeg: Path,
request: VideoEncodeRequest,
timed_video: Path,
audio_source: Path,
output: Path,
audio_options: AudioEncodeOptions,
audio_start: float,
audio_duration: float | None,
audio_segments: list[tuple[float, float]] | None,
audio_tempo: float,
audio_sample_rate: int | None,
source_has_audio: bool,
allow_audio_copy: bool,
frame_count: int | None,
progress_label: str | None,
) -> int:
command = [
str(ffmpeg),
str(request.ffmpeg),
"-y",
"-hide_banner",
"-loglevel",
@@ -283,31 +225,37 @@ def mux_timed_video_with_audio(
"-i",
str(timed_video),
]
if audio_options.mode != "none" and source_has_audio:
if request.audio_options.mode != "none" and request.source_has_audio:
add_audio_input_options(
command,
audio_options=audio_options,
audio_start=audio_start,
audio_duration=audio_duration,
allow_audio_copy=allow_audio_copy,
audio_options=request.audio_options,
audio_start=request.audio_start,
audio_duration=request.audio_duration,
allow_audio_copy=request.allow_audio_copy,
)
command.extend(["-i", str(audio_source)])
command.extend(["-i", str(request.audio_source)])
command.extend(["-map", "0:v:0", "-c:v", "copy"])
if output.suffix.lower() == ".mp4":
command.extend(["-video_track_timescale", "90000"])
if request.color_metadata:
command.extend(request.color_metadata.ffmpeg_args())
if request.options.codec == "libx265":
bitstream_filter = request.color_metadata.hevc_bitstream_filter() if request.color_metadata else None
if bitstream_filter:
command.extend(["-bsf:v", bitstream_filter])
if request.output.suffix.lower() == ".mp4":
command.extend(["-video_track_timescale", "90000", "-movflags", "+write_colr"])
add_audio_options(
command,
audio_options=audio_options,
audio_start=audio_start,
audio_duration=audio_duration,
audio_segments=audio_segments,
audio_tempo=audio_tempo,
audio_sample_rate=audio_sample_rate,
source_has_audio=source_has_audio,
allow_audio_copy=allow_audio_copy,
audio_options=request.audio_options,
audio_start=request.audio_start,
audio_duration=request.audio_duration,
audio_segments=request.audio_segments,
audio_tempo=request.audio_tempo,
audio_sample_rate=request.audio_sample_rate,
source_has_audio=request.source_has_audio,
allow_audio_copy=request.allow_audio_copy,
)
command.append(str(output))
command.append(str(request.output))
process = subprocess.Popen(
command,
@@ -317,12 +265,12 @@ def mux_timed_video_with_audio(
)
ffmpeg_stderr = read_ffmpeg_progress(
process,
total_frames=frame_count,
label=f"Muxing {progress_label or output.name}",
total_frames=request.frame_count,
label=f"Muxing {request.progress_label}",
)
if process.returncode != 0:
raise RuntimeError(
f"FFmpeg failed while muxing timestamped output {output} with exit "
f"FFmpeg failed while muxing timestamped output {request.output} with exit "
f"{process.returncode}:\n{ffmpeg_stderr}"
)
return process.returncode
+8 -11
View File
@@ -4,6 +4,7 @@ import subprocess
import sys
from pathlib import Path
from tools.avisynth_paths import avs_path
from tools.console import clear_screen
from tools.avisynth_runner import AvisynthRunnerSet
from tools.avisynth_render import AvisynthClipInfo, run_validation_script
@@ -161,7 +162,7 @@ def _run_import_diagnostics(
),
(
"import-helper",
f'Import("{_avs_path(str(helper.resolve()))}")\n'
f'Import("{avs_path(str(helper.resolve()))}")\n'
'BlankClip(length=1, width=16, height=16, pixel_type="YV12")\n',
),
]
@@ -169,7 +170,7 @@ def _run_import_diagnostics(
cases.append(
(
"load-ffms2",
f'LoadPlugin("{_avs_path(str(Path(plugin_path).resolve()))}")\n'
f'LoadPlugin("{avs_path(str(Path(plugin_path).resolve()))}")\n'
'BlankClip(length=1, width=16, height=16, pixel_type="YV12")\n',
)
)
@@ -178,19 +179,19 @@ def _run_import_diagnostics(
(
"ffvideo",
_optional_load_plugin(plugin_path)
+ f'FFVideoSource("{_avs_path(str(Path(source_path).resolve()))}", '
+ f'cachefile="{_avs_path(str((source_dir / (stem + ".diag.ffindex")).resolve()))}")\n',
+ f'FFVideoSource("{avs_path(str(Path(source_path).resolve()))}", '
+ f'cachefile="{avs_path(str((source_dir / (stem + ".diag.ffindex")).resolve()))}")\n',
),
(
"hidden-source",
"mbt_video_only = true\n"
f'Import("{_avs_path(str(hidden_source.resolve()))}")\n'
f'Import("{avs_path(str(hidden_source.resolve()))}")\n'
"last\n",
),
(
"editable",
"mbt_video_only = true\n"
f'Import("{_avs_path(str(visible.resolve()))}")\n'
f'Import("{avs_path(str(visible.resolve()))}")\n'
"last\n",
),
]
@@ -228,11 +229,7 @@ def _remove_validation_artifacts(csv_path: Path) -> None:
def _optional_load_plugin(plugin_path: str | None) -> str:
if not plugin_path:
return ""
return f'LoadPlugin("{_avs_path(str(Path(plugin_path).resolve()))}")\n'
def _avs_path(path: str) -> str:
return path.replace("\\", "/").replace('"', '\\"')
return f'LoadPlugin("{avs_path(str(Path(plugin_path).resolve()))}")\n'
def _indent(text: str) -> str: