This is not well-tested so beware (I only tested with one encode)...
This code alters Video NHMLs (used by MP4Box) to be timed like MKV timecode file specifies, and also at the same time fixes the corresponding audio NHML to make it sync with the new video timecodes.
It is written in Lua and should run on all platforms (with enough memory) that have Lua reference interpretter (lua) available.
Download NHMLFixup.luaLanguage: lua
#!/usr/bin/env lua
----------------------------------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------------------------
-- NHMLFixup v1 by Ilari (2010-09-16).
-- Update timecodes in NHML Audio/Video track timing to conform to given MKV v2 timecodes file.
-- Syntax: NHMLFixup <video-nhml-file> <audio-nhml-file> <mkv-timecodes-file> [<delay>]
-- <delay> is number of milliseconds to delay the video (in order to compensate for audio codec delay, reportedly
-- does not work right with some demuxers).
----------------------------------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------------------------
----------------------------------------------------------------------------------------------------------------------
-- Function load_timecode_file(FILE file, number timescale)
-- Loads timecode data from file @file, using timescale of @timescale frames per second. Returns array of scaled
-- timecodes.
----------------------------------------------------------------------------------------------------------------------
load_timecode_file = function(file, timescale)
local line, ret;
line = file:read("*l");
if line ~= "# timecode format v2" then
error("Timecode file is not in MKV timecodes v2 format");
end
ret = {};
while true do
line = file:read("*l");
if not line then
break;
end
local timecode = tonumber(line);
if not timecode then
error("Can't parse timecode '" .. line .. "'.");
end
table.insert(ret, math.floor(0.5 + timecode / 1000 * timescale));
end
return ret;
end
----------------------------------------------------------------------------------------------------------------------
-- Function make_reverse_index_table(Array array)
-- Returns table, that has entry for each entry in given array @array with value being rank of value, 1 being smallest
-- and #array the largest. If @lookup is non-nil, values are looked up from that array.
----------------------------------------------------------------------------------------------------------------------
make_reverse_index_table = function(array, lookup)
local sorted, ret;
local i;
sorted = {};
for i = 1,#array do
sorted[i] = array[i];
end
table.sort(sorted);
ret = {};
for i = 1,#sorted do
ret[sorted[i]] = (lookup and lookup[i]) or i;
end
return ret;
end
----------------------------------------------------------------------------------------------------------------------
-- Function max_causality_violaton(Array CTS, Array DTS)
-- Return the maximum number of time units CTS and DTS values violate causality. #CTS must equal #DTS.
----------------------------------------------------------------------------------------------------------------------
max_causality_violation = function(CTS, DTS)
local max_cv = 0;
local i;
for i = 1,#CTS do
max_cv = math.max(max_cv, DTS[i] - CTS[i]);
end
return max_cv;
end
----------------------------------------------------------------------------------------------------------------------
-- Function fixup_video_times(Array sampledata, Array timecodes, Number spec_delay)
-- Fixes video timing of @sampledata (fields CTS and DTS) to be consistent with timecodes in @timecodes. Returns the
-- CTS offset of first sample (for fixing audio). @spec_delay is special delay to add (to fix A/V sync).
----------------------------------------------------------------------------------------------------------------------
fixup_video_times = function(sampledata, timecodes, spec_delay)
local cts_tab = {};
local dts_tab = {};
local k, v, i;
if #sampledata ~= #timecodes then
error("Number of samples (" .. #sampledata .. ") does not match number of timecodes (" .. #timecodes
.. ").");
end
for i = 1,#sampledata do
cts_tab[i] = sampledata[i].CTS;
dts_tab[i] = sampledata[i].DTS;
end
cts_lookup = make_reverse_index_table(cts_tab, timecodes);
dts_lookup = make_reverse_index_table(dts_tab, timecodes);
-- Perform time translation and find max causality violation.
local max_cv = 0;
for i = 1,#sampledata do
sampledata[i].CTS = cts_lookup[sampledata[i].CTS];
sampledata[i].DTS = dts_lookup[sampledata[i].DTS];
max_cv = math.max(max_cv, sampledata[i].DTS - sampledata[i].CTS);
end
-- Add maximum causality violation to CTS to eliminate the causality violations.
-- Also find the minimum CTS.
local min_cts = 999999999999999999999;
for i = 1,#sampledata do
sampledata[i].CTS = sampledata[i].CTS + max_cv + spec_delay;
--Spec_delay should not apply to audio.
min_cts = math.min(min_cts, sampledata[i].CTS - spec_delay);
end
return min_cts;
end
----------------------------------------------------------------------------------------------------------------------
-- Function fixup_video_times(Array sampledata, Number min_video_cts, Number video_timescale, Number audio_timescale)
-- Fixes video timing of @sampledata (field CTS) to be consistent with video minimum CTS of @cts. Video timescale
-- is assumed to be @video_timescale and audio timescale @audio_timescale.
----------------------------------------------------------------------------------------------------------------------
fixup_audio_times = function(sampledata, min_video_cts, video_timescale, audio_timescale)
local fixup = math.floor(0.5 + min_video_cts * audio_timescale / video_timescale);
for i = 1,#sampledata do
sampledata[i].CTS = sampledata[i].CTS + fixup;
end
end
----------------------------------------------------------------------------------------------------------------------
-- Function translate_NHML_TS_in(Array sampledata);
-- Translate NHML CTSOffset fields in @sampledata into CTS fields.
----------------------------------------------------------------------------------------------------------------------
translate_NHML_TS_in = function(sampledata, default_dDTS)
local i;
local dts = 0;
for i = 1,#sampledata do
if not sampledata[i].DTS then
sampledata[i].DTS = dts + default_dDTS;
end
dts = sampledata[i].DTS;
if sampledata[i].CTSOffset then
sampledata[i].CTS = sampledata[i].CTSOffset + sampledata[i].DTS;
else
sampledata[i].CTS = sampledata[i].DTS;
end
end
end
----------------------------------------------------------------------------------------------------------------------
-- Function translate_NHML_TS_out(Array sampledata);
-- Translate CTS fields in @sampledata into NHML CTSOffset fields.
----------------------------------------------------------------------------------------------------------------------
translate_NHML_TS_out = function(sampledata)
local i;
for i = 1,#sampledata do
sampledata[i].CTSOffset = sampledata[i].CTS - sampledata[i].DTS;
if sampledata[i].CTSOffset < 0 then
error("INTERNAL ERROR: translate_NHML_TS_out: Causality violation: CTS=" .. tostring(
sampledata[i].CTS) .. " DTS=" .. tostring(sampledata[i].DTS) .. ".");
end
sampledata[i].CTS = nil;
end
end
----------------------------------------------------------------------------------------------------------------------
-- Function map_table_to_number(Table tab);
-- Translate all numeric fields in table @tab into numbers.
----------------------------------------------------------------------------------------------------------------------
map_table_to_number = function(tab)
local k, v;
for k, v in pairs(tab) do
local n = tonumber(v);
if n then
tab[k] = n;
end
end
end
----------------------------------------------------------------------------------------------------------------------
-- Function map_fields_to_number(Array sampledata);
-- Translate all numeric fields in array @sampledata into numbers.
----------------------------------------------------------------------------------------------------------------------
map_fields_to_number = function(sampledata)
local i;
for i = 1,#sampledata do
map_table_to_number(sampledata[i]);
end
end
----------------------------------------------------------------------------------------------------------------------
-- Function escape_xml_text(String str)
-- Return XML escaping of text str.
----------------------------------------------------------------------------------------------------------------------
escape_xml_text = function(str)
str = string.gsub(str, "&", "&");
str = string.gsub(str, "<", "<");
str = string.gsub(str, ">", ">");
str = string.gsub(str, "\"", """);
str = string.gsub(str, "\'", "'");
return str;
end
----------------------------------------------------------------------------------------------------------------------
-- Function escape_xml_text(String str)
-- Return XML unescaping of text str.
----------------------------------------------------------------------------------------------------------------------
unescape_xml_text = function(str)
str = string.gsub(str, "'", "\'");
str = string.gsub(str, """, "\"");
str = string.gsub(str, ">", ">");
str = string.gsub(str, "<", "<");
str = string.gsub(str, "&", "&");
return str;
end
----------------------------------------------------------------------------------------------------------------------
-- Function serialize_table_to_xml_entity(File file, String tag, Table data, bool noclose);
-- Write @data as XML start tag of type @tag into @file. If noclose is true, then tag will not be closed.
----------------------------------------------------------------------------------------------------------------------
serialize_table_to_xml_entity = function(file, tag, data, noclose)
local k, v;
file:write("<" .. tag .. " ");
for k, v in pairs(data) do
file:write(k .. "=\"" .. escape_xml_text(tostring(v)) .. "\" ");
end
if noclose then
file:write(">\n");
else
file:write("/>\n");
end
end
----------------------------------------------------------------------------------------------------------------------
-- Function serialize_array_to_xml_entity(File file, String tag, Array data);
-- Write each element of @data as empty XML tag of type @tag into @file.
----------------------------------------------------------------------------------------------------------------------
serialize_array_to_xml_entity = function(file, tag, data)
local i;
for i = 1,#data do
serialize_table_to_xml_entity(file, tag, data[i]);
end
end
----------------------------------------------------------------------------------------------------------------------
-- Function write_NHML_data(File file, Table header, Table sampledata)
-- Write entiere NHML file.
----------------------------------------------------------------------------------------------------------------------
write_NHML_data = function(file, header, sampledata)
file:write("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
serialize_table_to_xml_entity(file, "NHNTStream", header, true);
serialize_array_to_xml_entity(file, "NHNTSample", sampledata);
file:write("</NHNTStream>\n");
end
----------------------------------------------------------------------------------------------------------------------
-- Function open_file_checked(String file, String mode, bool for_write)
-- Return file handle to file (checking that open succeeds).
----------------------------------------------------------------------------------------------------------------------
open_file_checked = function(file, mode, for_write)
local a, b;
a, b = io.open(file, mode);
if not a then
error("Can't open '" .. file .. "': " .. b);
end
return a;
end
----------------------------------------------------------------------------------------------------------------------
-- Function call_with_file(fun, string file, String mode, ...);
-- Call fun with opened file handle to @file (in mode @mode) as first parameter.
----------------------------------------------------------------------------------------------------------------------
call_with_file = function(fun, file, mode, ...)
-- FIXME: Handle nils returned from function.
local handle = open_file_checked(file, mode);
local ret = {fun(handle, ...)};
handle:close();
return unpack(ret);
end
----------------------------------------------------------------------------------------------------------------------
-- Function xml_parse_tag(String line);
-- Returns the xml tag type for @line plus table of attributes.
----------------------------------------------------------------------------------------------------------------------
xml_parse_tag = function(line)
-- More regexping...
local tagname;
local attr = {};
tagname = string.match(line, "<(%S+).*>");
if not tagname then
error("'" .. line .. "': Parse error.");
end
local k, v;
for k, v in string.gmatch(line, "([^ =]+)=\"([^\"]*)\"") do
attr[k] = unescape_xml_text(v);
end
return tagname, attr;
end
----------------------------------------------------------------------------------------------------------------------
-- Function load_NHML(File file)
-- Loads NHML file @file. Returns header table and samples array.
----------------------------------------------------------------------------------------------------------------------
load_NHML = function(file)
-- Let's regexp this shit...
local header = {};
local samples = {};
while true do
local line = file:read();
if not line then
error("Unexpected end of NHML file.");
end
local xtag, attributes;
xtag, attributes = xml_parse_tag(line);
if xtag == "NHNTStream" then
header = attributes;
elseif xtag == "NHNTSample" then
table.insert(samples, attributes);
elseif xtag == "/NHNTStream" then
break;
elseif xtag == "?xml" then
else
print("WARNING: Unrecognized tag '" .. xtag .. "'.");
end
end
return header, samples;
end
----------------------------------------------------------------------------------------------------------------------
-- Function reame_errcheck(String old, String new)
-- Rename old to new. With error checking.
----------------------------------------------------------------------------------------------------------------------
rename_errcheck = function(old, new)
local a, b;
a, b = os.rename(old, new);
if not a then
error("Can't rename '" .. old .. "' -> '" .. new .. "': " .. b);
end
end
if #arg < 3 or #arg > 4 then
error("Syntax: NHMLFixup.lua <video.nhml> <audio.nhml> <timecodes.txt> [<delaycompensation>]");
end
-- Load the NHML files.
io.stdout:write("Loading '" .. arg[1] .. "'..."); io.stdout:flush();
video_header, video_samples = call_with_file(load_NHML, arg[1], "r");
io.stdout:write("Done.\n");
io.stdout:write("Loading '" .. arg[2] .. "'..."); io.stdout:flush();
audio_header, audio_samples = call_with_file(load_NHML, arg[2], "r");
io.stdout:write("Done.\n");
io.stdout:write("String to number conversion on video header..."); io.stdout:flush();
map_table_to_number(video_header);
io.stdout:write("Done.\n");
io.stdout:write("String to number conversion on video samples..."); io.stdout:flush();
map_fields_to_number(video_samples);
io.stdout:write("Done.\n");
io.stdout:write("String to number conversion on audio header..."); io.stdout:flush();
map_table_to_number(audio_header);
io.stdout:write("Done.\n");
io.stdout:write("String to number conversion on audio samples..."); io.stdout:flush();
map_fields_to_number(audio_samples);
io.stdout:write("Done.\n");
io.stdout:write("Computing CTS for video samples..."); io.stdout:flush();
translate_NHML_TS_in(video_samples, video_header.DTS_increment or 0);
io.stdout:write("Done.\n");
io.stdout:write("Computing CTS for audio samples..."); io.stdout:flush();
translate_NHML_TS_in(audio_samples, audio_header.DTS_increment or 0);
io.stdout:write("Done.\n");
-- Alter timescale if needed and load the timecode data.
delay = 0;
rdelay = 0;
if arg[4] then
local n = tonumber(arg[4]);
if not n then
error("Bad delay.");
end
rdelay = n;
delay = math.floor(0.5 + rdelay / 1000 * video_header.timeScale);
end
timecode_data = call_with_file(load_timecode_file, arg[3], "r", video_header.timeScale);
MAX_MP4BOX_TIMECODE = 0x7FFFFFF;
if timecode_data[#timecode_data] > MAX_MP4BOX_TIMECODE then
-- Workaround MP4Box bug.
divider = math.ceil(timecode_data[#timecode_data] / MAX_MP4BOX_TIMECODE);
print("Notice: Dividing timecodes by " .. divider .. " to workaround MP4Box timecode bug.");
io.stdout:write("Performing division..."); io.stdout:flush();
video_header.timeScale = video_header.timeScale / divider;
for i = 1,#timecode_data do
timecode_data[i] = timecode_data[i] / divider;
end
--Recompute delay.
delay = math.floor(0.5 + rdelay / 1000 * video_header.timeScale);
io.stdout:write("Done.\n");
end
-- Do the actual fixup.
io.stdout:write("Fixing up video timecodes..."); io.stdout:flush();
audio_fixup = fixup_video_times(video_samples, timecode_data, delay);
io.stdout:write("Done.\n");
io.stdout:write("Fixing up audio timecodes..."); io.stdout:flush();
fixup_audio_times(audio_samples, audio_fixup, video_header.timeScale, audio_header.timeScale);
io.stdout:write("Done.\n");
-- Save the NHML files.
io.stdout:write("Computing CTSOffset for video samples..."); io.stdout:flush();
translate_NHML_TS_out(video_samples);
io.stdout:write("Done.\n");
io.stdout:write("Computing CTSOffset for audio samples..."); io.stdout:flush();
translate_NHML_TS_out(audio_samples);
io.stdout:write("Done.\n");
io.stdout:write("Saving '" .. arg[1] .. ".tmp'..."); io.stdout:flush();
call_with_file(write_NHML_data, arg[1] .. ".tmp", "w", video_header, video_samples);
io.stdout:write("Done.\n");
io.stdout:write("Saving '" .. arg[2] .. ".tmp'..."); io.stdout:flush();
call_with_file(write_NHML_data, arg[2] .. ".tmp", "w", audio_header, audio_samples);
io.stdout:write("Done.\n");
io.stdout:write("Renaming '" .. arg[1] .. ".tmp' -> '" .. arg[1] .. "'..."); io.stdout:flush();
rename_errcheck(arg[1] .. ".tmp", arg[1]);
io.stdout:write("Done.\n");
io.stdout:write("Renaming '" .. arg[2] .. ".tmp' -> '" .. arg[2] .. "'..."); io.stdout:flush();
rename_errcheck(arg[2] .. ".tmp", arg[2]);
io.stdout:write("Done.\n");
io.stdout:write("All done.\n");