Enhance Avisynth video workflow and metadata tools
This commit is contained in:
+409
-64
@@ -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")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def avs_path(path: str) -> str:
|
||||
return path.replace("\\", "/").replace('"', '\\"')
|
||||
@@ -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.
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user